Ingest CycloneDX SBOMs and resolve vulnerabilities from a local Trivy+Grype scan - #39
Open
AlexSimionGeorge wants to merge 13 commits into
Open
Ingest CycloneDX SBOMs and resolve vulnerabilities from a local Trivy+Grype scan#39AlexSimionGeorge wants to merge 13 commits into
AlexSimionGeorge wants to merge 13 commits into
Conversation
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
analysecommand's--resultsand--pluginsdeclarations.Enrichment
GH_TOKENis absent instead of erroring.Fixes
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.mdplus 17 directoryCLAUDE.mdfiles) 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