Skip to content

Ingest an existing SBOM and check it against OSV #23

Description

@richardmhope

Listed in CLAUDE.md under Known gaps as "No SBOM ingest". Two things make it worth doing.

It covers the cases the scanner structurally cannot. Today we read manifests and lockfiles from a source tree, so a container image, a vendor-supplied component list, or anything built elsewhere is out of reach. Those all arrive as CycloneDX. Ingesting one lets us answer "what is wrong with this thing" without needing its source.

It is the hard prerequisite for anything server-shaped. A destination that only accepts what our own scanner emits is a dashboard for one tool, not a platform. Worth doing here regardless, because it is the same parsing work either way and far easier to get right in a stateless CLI with the existing test discipline than inside a service.

An SBOM is not an ecosystem

The natural instinct is to add it like any other format, but it does not fit. EcosystemSpec (src/icebergsca/ecosystems/base.py) maps filename patterns onto exactly one EcosystemId, and classify dispatches on the filename. A CycloneDX BOM carries purls across npm, PyPI, Maven and Go in one file, so it cannot be an ecosystem module — it is a different kind of input that produces dependencies for many ecosystems at once.

That probably means a separate entry point rather than a new ECOSYSTEMS entry. Whether that is scan growing the ability to recognise a BOM, or a distinct subcommand, is the first design decision.

The honesty problems, which are the real work

This is where the feature earns or loses its keep. Parsing components is easy; the following are not, and each is a route to the false clean this codebase exists to prevent.

We cannot know how complete someone else's SBOM is. Ours carries icebergsca:vulnerabilitiesChecked and icebergsca:complete precisely because a components-only document is an inventory, not an assessment. Other tools emit no such signal. A BOM produced from direct dependencies only, or from a partial scan, looks identical to a complete one — and reporting "3 findings" over it implies we checked everything. Whatever we do here, an ingested scan must not present the same confidence as one where we resolved the graph ourselves.

Directness may be unknowable. Dependency.direct is a plain bool (core/models.py:274). CycloneDX has a dependencies array of bom-ref edges — we emit one via _dependency_graph — but plenty of producers omit it, leaving a flat component list where nothing is direct or transitive. A bool cannot say "unknown", and this codebase is explicit that None for "we could not determine this" must stay distinct from a definite answer. --scope filtering and Finding.is_direct both read this field, so defaulting it silently is not acceptable.

Pin semantics shift. Components in a BOM carry exact versions, but whether those are installed or merely declared depends entirely on the producing tool. Pin.PINNED currently means "read from a lockfile — this is what is actually installed", a claim we cannot make on someone else's behalf.

Provenance has no line. Dependency.source is a file and line. An ingested component's source is the BOM itself, which is honest but changes what introduced_by means in the report and in SARIF locations.

None of these should be resolved by picking the convenient default. Each needs an answer that is either correct or visibly unknown.

purl parsing is the inverse of something we already have

_purl_namespace (core/models.py:220) maps a PackageRef to purl namespace/name, with the per-ecosystem quirks: Maven's group:artifact, npm's @scope/pkg, Go's path split, PEP 503 normalisation for PyPI. Ingest needs the inverse, and it is lossy in exactly those places. Unknown purl types — pkg:deb, pkg:oci, pkg:generic — must be skipped and reported, never silently dropped; SkippedFile and warnings are the existing homes for that.

Scope

  • Decide the entry point: scan recognising a BOM, or a separate subcommand
  • CycloneDX 1.4–1.6 component parsing, purl → PackageRef with round-trip tests against _purl_namespace
  • Ingest the dependencies edge array where present, for parents and directness
  • A model answer for unknown directness that does not lie
  • Unsupported purl types reported as skipped, with counts
  • Some report-level marker distinguishing "we resolved this graph" from "we were handed it", carried into every renderer — the vulnerabilities_checked precedent
  • Docs, references/json-report.md, and the skill

Verification

Round-tripping our own output is the natural first test and costs nothing: icebergsca sbom . --output x.cdx.json then ingesting x.cdx.json should reproduce the same package set. tests/test_report_formats.py already builds a report exercising every renderer branch, so the fixture exists.

Beyond that, ingest a BOM from a different producer — Syft or Trivy — since agreeing only with ourselves proves very little.

Not in scope for a first cut

SPDX. We do not emit it either (separate Known gap), and it is a distinct parser with its own identity model. CycloneDX first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions