The no-code Adapter Builder: supporting any scanner without shipping a parser
Part of my series on RiskRancher, the open-source vulnerability manager I build. Source: github.com/Kuebiko-LLC/risk-rancher-core.
Intro
In the last post I said the trick to handling every scanner is to normalize findings into one model, and to keep the per-scanner mapping as configuration rather than code. This post is about that configuration: the Adapter Builder. It’s the feature I’m happiest with, because it means a user can teach RiskRancher a brand-new scanner in a couple of minutes, in the browser, and I never have to cut a release to support it.
I. An Adapter is just data
An Adapter is not code. It’s a row in the database that describes where to find each field in a given scanner’s export:
type Adapter struct {
Name string
SourceName string
FindingsPath string // where the array of findings lives
MappingTitle string
MappingAsset string
MappingSeverity string
MappingDescription string
MappingRemediation string
// ...
}
FindingsPath says where in the export the list of findings is. Then each Mapping* field says where, inside one finding, to read that value. That’s the entire definition of “how to read Trivy” or “how to read Qualys.” It’s six or so strings, stored, not compiled.
II. Building one takes a sample and a few clicks
The flow in the UI is deliberately dull:
- Go to Build New Adapter and drop in a sample JSON or CSV export from your scanner.
- Point at where the findings array lives.
- Map title, asset, and severity (and optionally description and remediation) by picking the field.
- Save, then ingest the full export.
No parser, no regex, no code. You’re describing the shape of your tool’s output once, and RiskRancher remembers it. The next export from that scanner just works.
III. How a mapping is applied
When an export comes in, RiskRancher walks each finding using the adapter’s paths. A mapping like vulnerability.id is just a dot-separated path into the decoded object:
keys := strings.Split(path, ".")
// descend through the finding object key by key to pull the value
Then it assembles the normalized Ticket straight from those mapped values:
ticket := Ticket{
Source: adapter.SourceName,
Title: interfaceToString(finding[adapter.MappingTitle]),
AssetIdentifier: interfaceToString(finding[adapter.MappingAsset]),
Severity: interfaceToString(finding[adapter.MappingSeverity]),
// ...description, remediation
}
So the adapter is the bridge between one tool’s idiosyncratic shape and the one model everything else uses. The mapping is the only tool-specific thing in the whole pipeline, and it lives as data.
IV. Why “configuration, not code” matters
Making adapters data instead of code buys three things. New scanners don’t need a release, so a user isn’t blocked on me. Adapters are portable, they’re just rows, so a mapping someone builds for an obscure scanner can be exported and shared. And the core stays small, because it doesn’t accumulate a parser module per vendor that I then have to maintain forever. The surface area that grows is a table, not the binary.
Conclusion
The difference between “we support 8 scanners” and “we support whatever you can export” is whether the mapping is code or configuration. By making an Adapter a handful of stored field paths that the ingester walks, RiskRancher lets anyone add a scanner in the browser without a parser or a release. If you’re building anything that imports from many messy sources, push the per-source knowledge out of your code and into data you can edit. Thanks for reading, and may your connectors never need a hotfix.