Skip to content

Ingest CycloneDX SBOMs and resolve vulnerabilities from a local Trivy+Grype scan - #39

Open
AlexSimionGeorge wants to merge 13 commits into
mainfrom
sbom-to-depinder
Open

Ingest CycloneDX SBOMs and resolve vulnerabilities from a local Trivy+Grype scan#39
AlexSimionGeorge wants to merge 13 commits into
mainfrom
sbom-to-depinder

Conversation

@AlexSimionGeorge

Copy link
Copy Markdown

Adds a CycloneDX SBOM ingestion path to depinder: Syft and Trivy SBOMs become depinder projects, with vulnerabilities resolved from a local Trivy+Grype scan instead of a network advisory lookup.

What's here

SBOM plugins (src/plugins/sbom/)

  • cyclonedx.ts — parses CycloneDX documents into the depinder project model, anchoring projects on module artifacts for both Syft and Trivy output.
  • local-scan.ts — runs Trivy and Grype locally and maps their findings onto exact versions in the project model.
  • index.ts — plugin wiring and ecosystem registration.

Analysis command

  • Fixes the analyse command's --results and --plugins declarations.
  • Preflight: checks for Trivy and Grype before analysis and reports what is missing rather than failing mid-run.
  • Writes RFC 4180 CSV, and groups licences by the field registrars actually fill.
  • Stamps scanner and DB provenance next to the CSVs so a result set says which scanner and which advisory DB produced it.

Enrichment

  • Registry enrichment runs in parallel with retry and periodic cache writes.
  • The enrichment cache is scoped by ecosystem rather than plugin name, so two plugins over the same ecosystem share entries instead of colliding.
  • GitHub advisory lookup is skipped when GH_TOKEN is absent instead of erroring.

Fixes

  • Two license-loss bugs in the CycloneDX parser.
  • Exact-version vulnerability results are now explicit in the project model.

Tests

8 new test files (__tests__/sbom.*, __tests__/analyse.*, __tests__/plugin.ecosystem.test.ts) covering the parser, the local scan, plugin registration, preflight, CSV output, and the CLI surface.

Not in this PR

A per-directory AI index (AI_INDEX_FORMAT.md plus 17 directory CLAUDE.md files) sits on a separate local branch and is deliberately held back — it is an unrelated concern and shouldn't ride along with this diff.

🤖 Generated with Claude Code

AlexSimionGeorge and others added 13 commits August 17, 2026 17:04
… Trivy output

Reads the CycloneDX SBOMs that depminer's Voyager instrument produces offline on
client machines and turns them into DepinderProjects, so depinder can analyse
dependency trees that its native parsers cannot reach without a build.

CycloneDX is the chosen interchange format because it is the only one both tools
emit, so a single parser serves both and comparing Syft against Trivy measures
the tools rather than our parsing of them. It is also not lossy for our purposes:
file locations and language survive in properties, and license coverage is
identical to Syft's native format (421 of 1029 components on Apache Zeppelin).
SPDX was rejected empirically -- its license coverage collapses to 1 package.

The two tools differ structurally, and the parser handles both:
  - Trivy emits root -> one `application` node per manifest -> dependencies, so
    those intermediate nodes are the projects, already named by manifest path.
  - Syft emits a two-level graph whose root is a `file` node absent from the
    dependency graph, so a Syft SBOM maps to a single project.
In both, dependency refs are the target's bom-ref verbatim (verified: 1983/1983
Syft, 4155/4155 Trivy resolve by direct lookup), so the graph is keyed on bom-ref
and purl is treated purely as a payload field. Keying on purl would work for Syft
and break on Trivy, whose refs are opaque UUIDs.

One SBOM spans several ecosystems but a depinder Plugin has exactly one registrar,
so one plugin is registered per ecosystem, each filtering the shared SBOM to its
own purl type and reusing the native plugin's registrar and checker unchanged.
They write their own sbom-<eco>-*.csv results alongside the native plugins, which
is what makes the two routes comparable on the same repository.

Measured against the real offline Zeppelin run:
  Trivy  maven  67 projects  702 deps  1012 edges   67 direct   635 indirect
  Trivy  npm     3 projects  513 deps  1201 edges   95 direct   418 indirect
  Syft   maven   1 project   295 deps   435 edges   52 direct   243 indirect
  Syft   npm     1 project   489 deps  1187 edges    7 direct   482 indirect
The maven dep count is below the 1016 maven components in the file because 1016
collapse to 194 distinct name@version, and depinder's model is keyed on that.

Known gap: `type` (dev/test/provided) is left undefined because no format from
either tool carries dependency scope -- verified as 0 across both SBOMs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found by asking whether CycloneDX carries less data than Syft's native
JSON, then measuring instead of assuming.

CycloneDX allows three license shapes and real Syft output uses all of them:
`{license:{id}}` (352 on Zeppelin), `{license:{name,url}}` (67), and
`{expression: 'BSD-3-Clause OR MIT'}` (1). `expression` is a sibling of
`license`, not a field inside it, so reading only `license` silently dropped
compound SPDX expressions -- which are common in npm.

Worse, the same package can appear under several bom-refs when found in several
locations, and the copies do not always agree: 31 purls are duplicated on
Zeppelin and in 11 of them only some copies carry a license. Building the
dependency map overwrote earlier entries, so whichever copy came last won and
license data was lost non-deterministically. Duplicates are now merged, keeping
a license from any copy.

Licenses captured on the real Syft SBOM go from 395 to 406 -- exactly the 11
conflicting duplicates. Trivy now reports 498, matching the 498 license-bearing
components in the file exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trivy inserts each module's own artifact between the application node and
its real dependencies; the parser read the edge above it, so every module
reported exactly one direct dep (itself). A pom.xml child is now treated
as the self-anchor iff it is the sole child or has outgoing edges, and is
excluded from its own dependency map. Zeppelin: 484 direct deps across 67
modules (was 67), matching the predicted count exactly.

Syft's flat SBOM now splits into per-module projects using only Syft data:
maven components are grouped by their syft:location pom path, the monorepo
groupId is bootstrapped from the SBOM (the groupId occurring exactly once
in the most groups, degenerate name/name purls skipped), and each group's
unique component with that groupId is the anchor. Membership is the
dependsOn closure from the anchor, not the location group, because Syft
dedups components repo-wide. Zeppelin: 67/67 anchors correct, 441 direct
deps, 96.4% precision against pom-derived ground truth. Versionless purls
fall back to the component's version field. Non-maven ecosystems keep the
single-project behavior.

Verified: 30/30 sbom tests, tsc clean, both real Zeppelin SBOMs measured
against Reference/ground-truth-direct-deps.csv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-dependency loop awaited one registrar call at a time; ~1,800
libraries at ~40/min meant 40+ minute runs. Lookups now run through a
worker pool of 8 with in-flight dedupe per cache key, backoff retry on
HTTP 429 (2s/4s/8s), and a cache write every 50 new lookups so an
interrupted run keeps its progress instead of losing everything to the
end-of-run write.

Verified: full Zeppelin Syft run went from a projected 40+ minutes to
about 10, zero 429s observed at concurrency 8, progress counters intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GHSA call threw before the fetched registry data was stored, so a
missing token silently emptied every enrichment column, not just the
vulnerability ones. The lookup now runs only when GH_TOKEN is set, and a
failure degrades to a warning instead of discarding the library info.
This makes the SBOM analysis pipeline fully tokenless until the local
Trivy/Grype checker replaces GHSA (D-14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the GitHub Advisories API for the SBOM route: each SBOM file is
scanned once with trivy sbom and grype (binaries resolved via TRIVY_BIN/
GRYPE_BIN or PATH), findings are merged per package and CVE - Grype
supplies the vulnerable range and CVSS score, Trivy the publish
timestamp, identifiers and references are unioned. Scanner findings are
exact-version matches, so they bypass the semver range filter; a missing
scanner degrades to a warning and empty columns. The vulnerability DB
never runs on a client machine - extraction stays offline (D-14, D-15).

Verified on the real Zeppelin SBOMs: 3,683 merged findings, 4,403
vulnerable rows on the Syft route, spot-checked CVE-2018-10237 (guava),
CVE-2019-17571 (log4j 1.x) and CVE-2021-44228 (log4j-core). 13 new unit
tests on scanner-output fixtures; 30/30 sbom tests stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sbom parsers attach exact-version findings from Trivy/Grype, and analyse.ts
guarded its own write with `if (dep.vulnerabilities === undefined)`. That
sentinel conflated two different facts — "a parser already computed this" and
"this must not be range-filtered" — and nothing in the type model declared that
a parser may pre-fill the field at all.

DepinderProject gains one optional documented flag, exactVersionVulnerabilities,
set by the sbom parser when a scanner ran. analyse.ts collapses to a single
decision point, resolveVulnerabilities, with the semver-range filter extracted
into advisoriesMatchingVersion; both are exported and unit tested. The GitHub
advisory path is unchanged and still the fallback when no scanner ran, so the
six native plugins and graceful degradation behave exactly as before.

No new extension point: the repo already carries two dead generalisations of
this hook (VulnerabilityChecker.check, getPURL) plus an unwired batch
implementation in utils/vulnerabilities.ts. Both are kept, now with comments
naming their state and their missing consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`java` and `sbom-java` share a registrar object by reference, so they fetch
identical LibraryInfo for identical library names — but the cache key was
`${plugin.name}:${dep.name}`, so every library was fetched from the registry
once per route. Measured on the Zeppelin Syft SBOM: the sbom-java route alone
writes 1658 entries, all under a `sbom-java:` prefix disjoint from the `java:`
entries a native run writes.

Plugin gains an optional `ecosystem`, defaulting to `name`, and the sbom
plugins set it to the native plugin's name. Because the default is the name,
every pre-existing on-disk and mongo entry stays byte-identical and valid;
only the duplicate `sbom-*:` entries are orphaned. Re-measured after the
change: the same 1658 entries, now under `java:`.

update.ts reads the same prefix to select and to reconstruct library names, so
it moves to `ecosystemOf` in the same commit, and its plugin loop is deduped by
ecosystem — otherwise both plugins claim every `java:*` id and refresh it twice.

The shared namespace is only sound while both routes share registrar AND
checker (so both write the same GHSA data onto the entry); a test pins that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither declared a <value> placeholder, so Commander registered both as
booleans. Verified before the fix by parsing the real declarations:
`--results out --plugins sbom-java` gave `{R: true, P: [...]}`, `out` was
swallowed into the folders argument and then walked (usually ENOENT), and
`options.plugins` never reached getPluginsFromNames — results always landed in
./results and all twelve plugins always ran on every CLI invocation.

The command is now built by a factory so tests can parse from a clean slate;
`analyseCommand` is unchanged for depinder.ts. Checked every other command:
transformBlackDuckReports declares both of its options correctly, and update,
cache and extractFrameworkVersion declare no options — analyse was the only one
affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects, both measured on the Zeppelin Syft SBOM (16638 dependency rows,
registry calls stubbed):

Rows were built by raw interpolation with only two cells quoted, so a version
containing a comma — Maven ranges such as `[4.1,4.2000)` — split into extra
cells. Before: rows of 15, 16 and 24 columns, a real CSV reader failing at line
1173, and zero versions surviving with their comma. After: every row exactly 15
columns and 90 comma-carrying versions intact. Every cell now goes through
csvRow, in the libs and project-stats writers alike; undefined becomes an empty
cell rather than the literal "undefined".

The licences CSV grouped on per-version licences, which the maven registrar
leaves empty on every version (it fills the library-level `licenses` that
libs.csv already reports). So every library landed in one 'unknown' group whose
member names were interpolated as an array — one line, 16639 cells wide, no
header. It now groups on the library-level licence, falling back to the
per-version list for registrars that only fill that, and writes a header plus
one properly quoted row per licence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SBOM route shells out to Trivy and Grype, and a missing binary was only
discovered lazily, per file, mid-run, as one warning in a long log. The user
was left with a completed analysis, empty vulnerability columns and nothing
that said the measurement had not happened.

Probe both binaries once, up front, in analyseFiles — before any parsing. The
parser is the only layer that holds an SBOM path, so `sbomFilesFor` derives
the same thing from the file list and the sbom plugin selection, which also
keeps a native (non-sbom) run from probing scanners it never uses.

What the user is told, in the four states:
 - both present: one info line with each tool's version and DB build date
 - one present: a banner saying results are PARTIAL and which tool is gone,
   because the two tools find substantially different sets
 - neither, GH_TOKEN set: a banner saying it fell back to GitHub Advisories
 - neither, no token: a banner saying vulnerability analysis is DISABLED and
   that empty columns are a missing measurement, not a clean bill of health
Each names the fix (install the tool, or set TRIVY_BIN/GRYPE_BIN), and the
verdict is repeated as one line at the end of the run, where the results
paths are printed and the banner is thousands of lines back.

The probe also reads the versions and warns when they differ from the pinned
reference pair (DECISIONS.md D-16), which now lives in PINNED_SCANNER_VERSIONS
next to the code that checks it. Warn, never fail: an intentional upgrade must
not block a run — and neither must a missing scanner. Degradation stays
graceful throughout; `available` still means a scanner actually produced a
report, so the GHSA fallback is unaffected.

Verified by execution against the real pinned binaries and the real Zeppelin
SBOM in all four states, plus a stub reporting a wrong version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A vulnerability count is untraceable without the matcher and the DB build
behind it: two runs a week apart can legitimately disagree, and without a
record that is indistinguishable from a regression. This is not hypothetical
— two datasets in this workspace differ by 20% and establishing why cost real
effort (DECISIONS.md D-16).

Write sbom-scan-provenance.json into the results folder the run actually used
(`-r <folder>`, resolved by analyse.ts, not a hardcoded ./results): tool
versions and whether each matched the pin, Trivy's DB version and UpdatedAt,
Grype's DB schema and build date from `grype db status`, which SBOM files were
scanned, whether each scanner ran on each of them, and how many entries came
out. One file per run, not a column per row.

Failing to write it is a warning, not a run failure — provenance must never
cost you the analysis you just paid for.

Verified by execution: a real run over the Zeppelin Trivy SBOM with the pinned
binaries records 891 finding entries across 285 package keys, and a scanner-less
run records vulnerabilityAnalysis "disabled".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scanner version warning told users their counts were "not directly
comparable with earlier runs" and to "re-measure and update D-16". depinder
never reads a previous run back, and DECISIONS.md is not part of this repo, so
both halves were meaningless to anyone running the tool. The provenance JSON
also carried a `decision` field naming that document.

The warning now says what is actually actionable: counts depend on the scanner
version and its DB build date, and both are recorded in the provenance file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant