Skip to content

Deterministic output, so an SBOM can be attested by digest #20

Description

@richardmhope

Came out of asking whether this tool should sign SBOMs. The answer is no — it reads declarations from a source tree and never sees the built artefact, so a signature from it could only attest "IcebergSCA read these files", not "this describes what you shipped". That binding belongs to CI, which holds both, and the right shape is an in-toto attestation with the artefact as subject and the SBOM as predicate (cosign attest --predicate sbom.cdx.json --type cyclonedx).

But that hands us a requirement: an attestation is only useful if the predicate is stable. Ours is not.

The problem

_metadata in src/icebergsca/report/cyclonedx.py:79 writes wall-clock time into every SBOM:

"timestamp": report.finished_at.isoformat().replace("+00:00", "Z"),

finished_at comes from datetime.now(UTC) at core/scanner.py:103. So two scans of the same commit, with the same lockfiles and the same advisories, produce byte-different documents and therefore different digests. Consequences:

  • Digest-based attestation subjects churn on every run even when nothing changed.
  • "Did the SBOM change in this PR?" is unanswerable by comparison — every diff is a hit.
  • Caching or deduplicating SBOMs by content hash never hits.

The same wall-clock values reach json_.py:35-36 (started_at / finished_at) and sarif.py:78-79 (startTimeUtc / endTimeUtc). SARIF's are arguably fine — that document is a record of an invocation — but the SBOM's is not, and the JSON report is used for diffing.

Worth noting what is already right: we emit no CycloneDX serialNumber, which would otherwise be a random UUID with exactly the same problem. That omission should be treated as deliberate and kept.

Why the tests do not catch it

tests/conftest.py:37-38 pins FIXED_START / FIXED_END and make_report uses them, so every renderer test is deterministic by construction. Nothing exercises two real scans of the same input, which is the only way this shows up.

Scope

  • Honour SOURCE_DATE_EPOCH, the established convention for exactly this, and/or a --reproducible flag that omits or pins volatile fields
  • Decide per format what "volatile" means — the SBOM timestamp is the clear case; SARIF invocation times are defensible as-is; the JSON scan.started_at/finished_at need a decision
  • A test that runs the same scan twice and asserts byte equality of the SBOM. Given the fixed-timestamp fixtures, this has to go through scan() rather than make_report
  • Keep the absent serialNumber absent, with a comment saying why
  • Document the guarantee, and what is deliberately excluded from it

Also worth folding in here, since it is what makes the payoff visible and can land independently:

  • A cosign attest recipe in references/ci-integration.md. The SBOM section there currently stops at upload-artifact, which is the part of the story that produces no security property at all.

Not in scope

Signing, key handling, or any crypto dependency in this tool. Beyond the argument above, CLAUDE.md hand-writes SARIF and CycloneDX specifically to keep the dependency tree small — hand-writing a JSON serialiser is defensible, hand-writing DSSE or x509 is not, and pulling in a signing library contradicts the stance. Holding a key would also make a dependency scanner a target, which is the same reason we never shell out to mvn.

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