Build one comparison key per finding from the package URL (purl) and a normalized advisory ID, then join both scanners' exports on it. Treat advisory IDs as aliases of each other, so a GHSA record and a CVE record for the same flaw count as one finding, not two.
The problem with raw exports
Two scanners will describe the same issue differently. One reports a package as "lodash 4.17.20." Another reports an npm package URL. One cites a CVE identifier, another cites a GitHub advisory identifier, and a third cites an OSV identifier. If you paste both exports in a spreadsheet and count rows, you will see a large disagreement that is mostly naming.
Before anyone says one tool found more, normalize.
Step 1: express every package as a purl
A package URL has the shape scheme, type, namespace, name, version, qualifiers and subpath. It is designed to identify the same package the same way across tools, and it is the standard identifier in CycloneDX and SPDX SBOMs and in most vulnerability databases. The purl specification lists the type names and rules per ecosystem.
Convert each export so that every row has a purl with a version. Watch for details that differ by ecosystem, such as name casing and separators in Python package names. A tool may print one form and another may print a normalized form, and the join will silently fail unless you fix this first.
Step 2: normalize advisory IDs through aliases
OSV records carry an aliases field that lists IDs for the same vulnerability in other databases. The OSV schema states that aliases should be treated as symmetric and transitive. That gives you a rule you can apply in a script: if A aliases B and B aliases C, all three are one issue.
Practical approach:
- Collect all advisory IDs from both exports.
- For each ID, fetch its record from a public database and read its aliases.
- Assign each group of aliases one canonical ID, for instance the lowest sorted ID in the group.
- Replace every ID in your exports with its canonical ID.
One caution: the schema notes aliases should not link a vulnerability in an upstream package to one in a downstream package. A distribution advisory for a patched build can be a different matter from the upstream package record. Keep those separate and note them.
Step 3: join and sort into four buckets
With key equals purl plus canonical ID, merge the lists. Every finding falls into one bucket.
| Bucket | Meaning | What to do |
|---|---|---|
| Both report | Agreement | Spot check a few |
| Only A reports | A found more, or B missed it, or A is wrong | Adjudicate |
| Only B reports | Same question reversed | Adjudicate |
| Same package, different advisory group | Possible alias gap | Check the records |
The third and fourth buckets are where all the learning is. The next post covers how to adjudicate findings only one tool reports.
Step 4: sanity check the join
Count rows before and after normalization for each scanner. The total should shrink for tools that report one finding per alias, and stay level for tools that already deduplicate. If a scanner's count drops by half, that is how much of its volume was duplicates. This links to the broader question of why two scanners report different counts.
A short worked example
Scanner A lists a finding for an npm package at version 4.17.20 under a CVE ID. Scanner B lists the same package under a GHSA ID. Raw join: two unmatched rows. After normalization both resolve to one canonical group and one purl, so the row lands in "Both report." One finding, not two.
Safeguard exports an SBOM from dependency scanning, which gives you a purl list to start from, and the free scan at gold.safeguard.sh/scan can supply one candidate's results for the join.
Quick check
Can I join on CVE ID alone?
Not reliably. Some findings have no CVE, and some tools cite other databases. Use aliases to group IDs first.
Do I need a script for this?
For a handful of findings a spreadsheet works. Past a few hundred rows, a short script saves hours and avoids copy errors.
Sources
- Package URL specification: purl structure, use in CycloneDX, SPDX and vulnerability databases.
- OSV schema: aliases field, symmetric and transitive rule, upstream and downstream caution.
Last reviewed: 19 October 2026.