
The Analyst's Stack Part 16 of 22
Orchestrating 7 security scanners into one report
One command runs a codebase through 6 security scanners, or a live URL through a seventh, and hands back a single list of problems. The interesting part is not the 7.
Why I built it
I write small tools that touch real material, and before one goes near that material I want to know 3 boring things: is a credential committed in it, is it standing on a dependency with a known hole, is the container config wrong.
There is a good free scanner for each of those questions and none that answers all of them. So you run several. That part is easy — each is one command.
Then you have several reports. One is a JSON object with a Results array. One has a
results array that means something different. One grades findings ERROR, WARNING,
INFO. One grades them CRITICAL through LOW. One has no severity field at all,
because everything it finds is a leaked credential and it considers that self-evidently
bad.
What you do next, if you are honest, is open a spreadsheet. I did that 5 times before admitting it was a permanent job.
What it solves
One command, one report, one row per problem. The scanners run in parallel, up to 6 at a time, and the slowest one sets the wall clock instead of the sum.
The ratio is the point: the code that runs the scanners is 271 lines. The code that makes their output comparable is 740.
$ NO_COLOR=1 COLUMNS=100 scan vulnapp --no-fail --out /tmp/p16-demo/scan-reports
Findings by scanner / severity
┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━┳━━━━━━┳━━━━━━━━━┓
┃ Scanner ┃ Critical ┃ High ┃ Medium ┃ Low ┃ Info ┃ Unknown ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━╇━━━━━━╇━━━━━━━━━┩
│ checkov │ 0 │ 0 │ 15 │ 0 │ 0 │ 0 │
│ osv-scanner │ 0 │ 7 │ 7 │ 0 │ 0 │ 0 │
│ osv-scanner+trivy │ 2 │ 6 │ 9 │ 0 │ 0 │ 0 │
│ semgrep │ 0 │ 6 │ 3 │ 0 │ 0 │ 0 │
│ trivy │ 0 │ 8 │ 1 │ 5 │ 0 │ 0 │
└───────────────────┴──────────┴──────┴────────┴─────┴──────┴─────────┘
Total: 2 critical · 27 high · 35 medium · 5 low · 0 info · 0 unknown
Wrote json: /tmp/p16-demo/scan-reports/scan-report.json
Wrote html: /tmp/p16-demo/scan-reports/scan-report.html
--url. The osv-scanner+trivy row is the merge described below. Real output, with colour
codes stripped and the tool’s own name replaced by scan.How it works
Collect. Seven scanners, each a plugin that registers itself. The dispatcher picks by target, so a folder gets the filesystem scanners and a URL gets the one that probes live services. A scanner whose binary is missing is recorded as skipped, with a reason, rather than silently dropped — an empty report that looks clean is the failure this stage exists to avoid.
Normalize. Every output becomes the same record: scan type, tool, title, severity, file, line range, description, a CVE if there is one, a suggested fix, a rule id, a fingerprint. Six severity levels, with each tool’s vocabulary mapped onto them.
Analyze. Findings collapse by fingerprint. Then the 2 dependency scanners get a second pass: the same CVE, package and file from both becomes one finding whose tool name is both names joined. Optionally, a model triages what is left.
Alert. A severity gate. If any finding matches the levels you asked to fail on — critical or high by default — the command exits 1. That is the whole notification layer, and in a pipeline it is enough.
Present. The same findings written 3 ways: JSON for another program, HTML with summary cards for a person, SARIF for a code host that can annotate a diff.

How I built it
Python, a command-line framework, and a thread pool. Each scanner is a small class whose job is to turn one tool’s output into a list of the common record. Adding an eighth is an afternoon; that was the point of the plugin shape.
Normalization was not an afternoon. It went wrong in ways that stayed invisible until I wrote tests for them.
The merge that downgraded things. When 2 dependency scanners report the same CVE I
keep the more severe grading. The internal ranking puts critical at 0 and unknown at 5, so
the most severe member of a group is the minimum. I wrote max. A low-severity
duplicate then quietly overrode a critical finding and sailed through the default gate,
and the build stayed green — the worst category of bug in a tool whose job is to stop a
build.
The merge that grouped strangers. My first version grouped duplicate CVEs on the CVE identifier alone. The same CVE in 2 lock files is 2 dependency trees and 2 different upgrades; merging them hides one. The key is now the CVE, the scan type, the file and the package.
The fingerprint includes the tool’s name. Deliberate, and the thing I would most like to improve. It removes exact repeats but does not collapse across tools, because 2 tools disagreeing about one line is information rather than noise.
The AI step is off by default. It reads normalized findings rather than raw scanner output and scores each for whether it looks real. Below a confidence of 0.3 the finding is relabelled as informational, with the model’s reasoning attached. Nothing a model says here removes a row from the report.
And it is not allowed to see everything. A secrets finding points at the exact line a credential sits on, so the usual 5-lines-either-side excerpt is the credential. Send that to a hosted model and you have exfiltrated the thing you were scanning for. The payload for a secrets finding carries no source excerpt — not a redacted one, none — and the model judges it from the rule name, the path and the line, which is enough.
A look at the code
The fingerprint, rewritten to stand alone:
import hashlib
def fingerprint(scanner, rule_id, file_path, line):
"""A finding's identity: what found it, which rule fired, and where.
`line` gets a sentinel when it is missing, so a finding with no line
number cannot collide with one that genuinely points at line 0.
"""
line_part = "none" if line is None else str(line)
parts = f"{scanner}|{rule_id or ''}|{file_path or ''}|{line_part}"
return hashlib.sha256(parts.encode()).hexdigest()[:16]
# The same finding on two runs an hour apart collapses to one row:
fingerprint("semgrep", "python.lang.security.audit.exec-detected",
"app/tasks.py", 41) # 'df85100c6ff5444a'
# A different tool, same file, same line, is a different row — on purpose:
fingerprint("bearer", "python_lang_exec", "app/tasks.py", 41)
# '940fc3513fa5bca3'
And the rule about what a model is allowed to look at:
def triage_item(finding, read_source_excerpt):
"""Build the one payload item a hosted model sees for this finding.
A secrets finding points at the exact line a credential sits on, so the
usual line-plus-or-minus-five excerpt is the credential itself. No
excerpt is safe here, so none is built. This must hold whether or not a
redaction pattern happens to recognise the credential's format, which is
why the check is on the finding's type and not on its contents.
"""
excerpt = "" if finding.scan_type == "secrets" else read_source_excerpt(finding)
return {
"fingerprint": finding.fingerprint,
"scanner": finding.scanner,
"title": finding.title,
"severity": finding.severity,
"file_path": finding.file_path,
"line_start": finding.line_start,
"code_excerpt": excerpt,
}
What it can’t do
Deduplication is mostly within a tool, not across them
Normalizing a severity throws information away
WARNING becomes medium. One
has no severity field, so every credential it finds is high — including the expired one in
a test fixture. Anything arriving with nothing usable defaults to medium, a guess wearing
a label. And the gate is exact membership rather than a threshold: asking it to fail on
medium and low will not fail it on a high finding. That is in the tests now because it
surprised me.It reports; it does not judge

Where this sits in the series
This is stage 2 of the spine with nothing else competing for attention. It has 7 sources, 7 shapes, and a pipeline that can do nothing at all until they are one shape. Change “scanner” to “platform” and it is every collection system in this series.
The credential rule is the argument from the do’s and
don’ts showing up as an if statement. You can
hold the principle — do not hand a third party the sensitive thing — and still ship the
code that hands it over, because it arrived inside a routine that was only ever meant to
add context. Principles become real when they are a line of code with a test behind them.
Both halves of this post continue elsewhere. Does your AI step actually work? is how you would find out whether the triage confidence score deserves the weight it carries. Storing and searching what you collect is the general form of the fingerprint: deciding when 2 records are the same thing is the same problem whether they are findings or events.