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
Also worth folding in here, since it is what makes the payoff visible and can land independently:
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.
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
_metadatainsrc/icebergsca/report/cyclonedx.py:79writes wall-clock time into every SBOM:finished_atcomes fromdatetime.now(UTC)atcore/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:The same wall-clock values reach
json_.py:35-36(started_at/finished_at) andsarif.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-38pinsFIXED_START/FIXED_ENDandmake_reportuses 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
SOURCE_DATE_EPOCH, the established convention for exactly this, and/or a--reproducibleflag that omits or pins volatile fieldsscan.started_at/finished_atneed a decisionscan()rather thanmake_reportserialNumberabsent, with a comment saying whyAlso worth folding in here, since it is what makes the payoff visible and can land independently:
cosign attestrecipe inreferences/ci-integration.md. The SBOM section there currently stops atupload-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.mdhand-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 tomvn.