Read an evidence bundle
An audit writes a directory of provenance-bearing artifacts. With inputs and run context pinned, the release artifacts are reproducible. This page explains the ones you will read most.
Key artifacts#
| Artifact | What it is |
|---|---|
evidence-pack.json | The canonical record: components, dependency edges, evidence items, gaps, requirement assessments, vulnerabilities, provenance. |
cyclonedx-sbom.json / spdx-sbom.json | Standard SBOMs derived from the observed composition. |
cra-coverage.json | Per-requirement CRA-oriented coverage and gaps for the declared product class. Written only by a run under the cra requirement regime (the default); a run under none assesses no requirement. |
issues.json / issues.md | Evidence-gap issues suitable for a tracker. |
release-gate.json | The pass/fail gate result and the reason. |
evidence-bundle.html / .json / .zip | A portable reviewer/auditor handoff. |
audit-log.jsonl | An append-only, hash-chained record designed to make later modification detectable. |
Note
Release artifacts are reproducible when inputs and run context are pinned;
opt-in operational phase durations are excluded from determinism checks. The
bundle composes existing artifacts — it adds no authority or conclusion of
its own.
Run observations: what the run itself flagged#
Every handoff carrier — evidence-bundle.html/.json, the sharing pack, the auditor package and the passport — carries a Run Observations block, so a pack never reads cleaner than the run that produced it:
- Scan warnings, as stable codes and how many times each was raised. Each warning's message and the path it was raised against stay in the run's own
scan-warnings.jsonand are not shared, so the codes travel without naming the manufacturer's tree. Explain any code offline withsbomflow explain <output-dir> --warning <code>. - Advisory sources, stating whether this run consulted a real advisory source at all. A run on the offline sample feed says so in every carrier: an advisory-finding count of zero from a run that consulted nothing real is a different answer from zero findings against real data, and the recipient of a shared pack has no other way to tell.
A missing or unreadable scan-warnings.json is reported as exactly that. It is never rendered as zero warnings — "no warning file here" and "this run raised none" are different facts.
Reading the coverage picture#
On a cra run, cra-coverage.json reports, for each in-scope requirement, whether evidence was observed and where gaps remain. A gap means evidence was not observed — it does not mean the requirement is unmet or that the product is non-conformant. A human reviewer decides.
Observed vs supplier-imported composition#
When a run includes supplier SBOM documents (--import-sbom), every component carries a claim_origin label so you always know which composition facts SBOMFlow verified against the build and which it recorded from a supplier document:
- observed — extracted from the scanned build tree by SBOMFlow.
- imported-sbom — recorded from a supplier-provided SBOM document; a supplier claim SBOMFlow did not independently observe in the build.
- corroborated — both: claimed by a supplier document and observed in the build.
The label appears on component rows in evidence-pack.json, as a column in the assessment report and reviewer console, on findings whose component is a supplier claim, in the bundle's component counts, and in cra-coverage.json (including which manufacturer-referenced components rest solely on an imported claim). It is factual origin, not judgment: imported components remain first-class for advisory matching and the release gate — nothing is downweighted, excluded, or re-decided by the label.
Why a finding is not blocking#
A finding that meets a gate threshold can still be non-blocking because a human decided so — a VEX review (fixed / valid not_affected) or a time-boxed waiver. So that "why is this not blocking?" is answerable everywhere you look, the gate records a suppression provenance entry per finding in release-gate.json (finding_suppressions), and the same record is rendered on the reviewer console, the issues list, and the evidence bundle:
- VEX — names the reviewer, the effective status, and its CISA justification. The finding's identity join is exact (
finding_key). - waiver — names the approver, the reason, and the expiry date, so an exception that is about to lapse is visible.
The two suppressor types are never collapsed into one label. This is provenance of a decision already recorded by a human — it asserts nothing new about the finding, and a known-exploited (KEV) finding that a reviewer marked not_affected still appears in the issues list, now carrying its suppressor so it is never mistaken for active-and-unaddressed.
Explaining a result#
To understand why a specific gap, finding, or gate decision appears:
sbomflow explain ./evidence --gap <gap-id>
sbomflow explain ./evidence --finding <finding-id>
sbomflow explain ./evidence --gateReviewing#
Machine observation is not human review. To record review decisions and approvals, use the review workflow:
sbomflow review ./evidenceSee Observed vs reviewed for why this boundary matters.
Printing a report (A4 or Letter)#
Every generated HTML surface is print-ready as-is. Use your browser's Print to PDF; no extra tooling is needed, and nothing is fetched from the network.
Both paper sizes produce the same document. The stylesheet sets page margins but deliberately does not pin a page size — pinning A4 would letterbox Letter, and pinning Letter would clip A4 — so the sheet you load decides the page box.
When printing, the report drops the navigation and the skip link, expands every collapsed <details> section so nothing is hidden on paper, and prints links as plain text (a URL you cannot click is noise in the margin).
Use this drill on any surface you intend to hand to an auditor:
- Long tables repeat their header row at the top of every page.
- No table row is split across a page boundary.
- Section headings are never stranded at the foot of a page, away from the content they introduce.
- No paragraph leaves a single line alone at the top or bottom of a page.
- Wide tables print all of their columns — nothing is cut off at the right edge.
- The result is identical on A4 and on Letter, apart from the page count.
Those last two are the ones that used to bite: the printed content box is narrower than the responsive breakpoint on both paper sizes, so a rule intended for phones would otherwise turn tables into horizontally scrolling blocks — which on paper means clipped columns and headers that never repeat.
Handing off: compose the carriers, then the ZIP#
auditor-pack and passport issue write their carriers into the output directory they read — the auditor package and its manifest, the recipient's README.txt, the redacted sharing pack, passport.json and passport.zip. bundle --zip does something different: it archives the whole directory as it stands, and validate checks that archive against the directory. A ZIP composed before the carriers therefore describes an earlier directory: validate exits 4 naming the files it is missing, and verify-bundle --layered reports the content dimension as extras_present. Compose every carrier first and the ZIP last:
sbomflow audit . --output ./evidence --as-of 2026-06-16T12:00:00+00:00
sbomflow auditor-pack ./evidence --as-of 2026-06-16T12:00:00+00:00
sbomflow passport issue ./evidence --issuer "Example Manufacturer" --issued-at 2026-06-16T12:00:00+00:00
sbomflow bundle ./evidence --zip ./evidence/evidence-bundle.zip --as-of 2026-06-16T12:00:00+00:00
sbomflow validate ./evidence
sbomflow verify-bundle ./evidence --layeredIn this order validate exits 0 and the content dimension reads verified. The bundle re-composed last lists what the auditor package added beside itself, and names the auditor package's own three files and the passport's as producer-excluded members rather than unexplained extras: each of those is checked through its own manifest (sbomflow passport verify ./evidence/passport.zip), not through the evidence bundle. If you add a carrier after the ZIP, run bundle --zip again.
A receiving pipeline that must not accept anything added to a bundle after it was composed passes --refuse-extras with --layered: a member the manifest does not list then fails the content layer's expectation and the command exits 7 (a changed or missing listed member still exits 3 first). Without the option an unlisted member reads extras_present and does not change the exit code, because the composition order above writes such members by design.
Verifying a handed-over bundle#
sbomflow verify-bundle <dir> runs the fast manifest-only check: it confirms the evidence-bundle.json bytes match the recorded digest and, with --key, that the signature verifies over those bytes. That proves the manifest is authentic — but a valid signature over the manifest says nothing about whether the listed member artifacts were altered.
For full trust, run the layered verification (a directory or a .zip):
sbomflow verify-bundle ./evidence --layeredPoint it at a .zip instead of a directory to verify a zipped handover. To also check the signature and signer authority, add --key reviewer.pub and one or more --authorized-key-sha256 <sha256-of-the-key-PEM> (the operator allow-list).
It reports four independent dimensions, never collapsed into one verdict:
- content — every listed member is present inside the bundle and matches its recorded sha256 and size (a tampered member fails here even if the signature is intact);
- envelope — the signature verifies over the exact manifest bytes;
- semantics — a DSSE/in-toto attestation has the expected payload type, statement type, predicate and subject binding (a wrong, attacker-chosen payload type is rejected before it is ever interpreted);
- authority — the verified signer is on your
--authorized-key-sha256allow-list; a cryptographically valid signature from a signer you did not authorise is reportedvalid_but_unauthorizedand is not trusted.
The authority line states what it actually checked, never more. An --authorized-key-sha256 allow-list records no signer validity window, so the line says there was none to evaluate. A trust policy that does record one (not_before / not_after) is evaluated against the moment you run the command and the line names that instant; a signer outside its window is signer_expired or signer_not_yet_valid, never authorised. There is no flag to evaluate the window as of some other instant, because nothing inside a bundle attests when it was signed — an instant typed at the command line would be an unverified claim able to overturn a real refusal.
Exit 0 only when no dimension failed; exit 3 on any failure — a tampered member, a wrong payload type, or an unauthorised signer. From a .zip, hostile archives (path traversal, symlinks, duplicate members, decompression bombs) are refused before any byte is trusted; nothing is ever extracted outside a temporary working directory. Verification is read-only and offline, and writes nothing.
Verifying a bundle written by an older SBOMFlow#
An evidence bundle is meant to be verifiable years after the release, on a machine whose SBOMFlow has moved on. When the bundle names a different tool or bundle-schema version than the one reading it, --layered adds one more line:
versions written by SBOMFlow 0.1.0, verified by 0.2.0 - reported as context; it changes no layer resultThat line is context, not a verdict. Version skew never changes a dimension, never changes the exit code, and an older bundle is the expected case rather than a defect. Where a check genuinely cannot be made, the dimension says so: an old bundle whose signature was not verified reports envelope not_checked, never a bare "verified".
sbomflow gate verify is stricter about age, deliberately. It re-runs a release's recorded gate, which needs the current evidence-pack shape, so against a release whose pack predates that shape it refuses with what it found and what to do — evidence-pack schema v2 predates v4; run 'sbomflow upgrade migrate-copy' on this release first — instead of re-deciding a gate on a shape it would have to guess at.
Both behaviours are pinned by a cross-version corpus of old output directories that today's verifier runs against on every build, including deliberately tampered copies of each.
Producing a stored release's record later#
A release kept in the store with store add should still produce its whole record, and show that every byte of it verifies, long after it shipped: on a newer SBOMFlow, on another machine, with no network. The steps below use existing commands only. The test suite walks them end to end, with the network disabled, over an aged store holding a release written by SBOMFlow 0.5.0, a release whose evidence pack has the oldest schema this release migrates (v0), and a release carrying recorded reviews, approvals and a hash-chained audit log.
sbomflow store --store ./.sbomflow-store list --json
sbomflow store --store ./.sbomflow-store verify --json > store-verify-before-export.json
sbomflow store --store ./.sbomflow-store export --output ./disclosure/estate --as-of 2026-06-16T12:00:00+00:00 --jsonstore list --jsonnames every release the index holds and where each one is stored (stored_path).store verify --jsonre-hashes every member of every stored release, re-checks each release's audit log against the chain head its bundle anchored (where it anchored one), and prints the whole-storeseal. Keep this output: it is the first page of the verification transcript and the anchor for step 4.store exportcopies every indexed release, byte for byte, into./disclosure/estate, beside acompleteness-manifest.jsonthat reconciles the copy against the store index. A release the index names but the store no longer holds is listed withexported: falseand its reason, the manifest readscomplete: false, and the command exits 1: it is named, never dropped. The manifest's per-releaseverifiedis the verdictstore verifyreaches for that release, computed over the copy: every member the release declares is re-hashed, not only its evidence pack. A copy that does not verify readsverified: false, names each failing member in itsreasonandproblems(for exampleapprovals.json,bundle_file_missing), the manifest readscomplete: false, and the command exits 4, asstore verifydoes.- Verify the copy, because the copy is what a recipient holds. Every member of every copied release is re-hashed, and the set of releases is compared with the seal recorded before the export, so a release lost on the way fails even though every survivor verifies. A missing or changed member is named by path in
problems:sbomflow store --store ./disclosure/estate verify --strict --expected-seal store-verify-before-export.json --json
Then, for each release directory in the copy (./disclosure/estate/ followed by its stored_path):
sbomflow upgrade inspect <release>names each artifact's schema family and version, andsbomflow upgrade plan <release>says what a migration would change. Where it would change something,sbomflow upgrade migrate-copy <release> --dest <new-directory>writes migrated copies to a new directory and leaves the release as it was. The migrated copy is a reading aid; the stored bytes stay the record.sbomflow verify-bundle <release> --layered --jsonchecks the bundle's content, envelope, semantics and authority as described above. An older release adds theversionscontext line and nothing else.sbomflow passport verify <release>/passport.zipre-checks every attested hash of a release that was issued a passport.sbomflow approve <release> --jsonshows the approval ledger without changing it: who approved which role, when, and until when, withaudit_chain: verifiedwhen the hash chain of recorded decisions is intact. Reviews travel as written, inreviews.json(andvulnerability_reviews.jsonandwaivers.jsonwhere they were recorded). Do not runreviewagainst the copy: it rewritesreview-queue.json.
Keep each step's standard output, for example in a transcript/ directory beside the export. The export and that transcript together are the record: the evidence pack, reviews, approvals, audit log, bundle and passport of every release, and a verification transcript showing they still hash to what was recorded. None of these steps writes into the store or into the copy.
This is an engineering record: what each release recorded, and a transcript showing its bytes still verify. It is for counsel or an auditor to read. It is not legal advice, and nothing in it states that the record is sufficient for any legal purpose. The export manifest carries its boundary verbatim:
A portable copy of what this store holds, in the formats it already holds them in. Copying evidence adds no claim: every gate decision, reviewer boundary and provenance is exactly what each release recorded. This manifest reconciles the export against the store's own index so a reader can confirm nothing was withheld; it is not a conformity claim and not legal advice.
What retention, expiry and licensing can and cannot touch#
- Retention removes a stored copy only when you apply a policy you wrote.
sbomflow store retain --policy <file>plans and changes nothing. With--applyit moves each pack the plan names to.trash/<stamp>/inside the store and keeps the release's index record, so the release is still listed.store verifyreports it astrashed_by_retentionwith the trash path, not as by-reference and not as a failure, and an export names it as not exported because retention trashed it, with the same path (step 3).sbomflow store empty-trash --applyis the only permanent deletion. A policy key this release does not recognise is refused rather than partly applied. - A preservation hold outranks every retention rule. A held release is always kept, the hold is checked before any rule, and
store empty-trash --applyrefuses while a held release's copy is staged in.trash/.sbomflow store hold <release> --actor NAME --reason TEXTplaces a hold andsbomflow store release-holdlifts it; each records who, when and the reason as stated, and neither changes the release or the store seal. A hold has no expiry. SBOMFlow records the hold and applies it to retention; it does not assess the stated reason. See PB-12 for how a rebuild of the index carries holds forward. - Licensing never touches reading. No step above reads an entitlement. With an expired, unverifiable entitlement installed, every step's output and the exported bytes are identical to a run with none installed; the test suite runs the journey both ways and compares them. SBOMFlow does not enforce licensing on any command.
- Expiry changes what a decision means at an instant, never whether it can be read. An approval recorded with
--expires-atsatisfies its role before that instant and is reportedexpiredfor it after, for example withsbomflow approve <release> --require-role <role> --as-of <instant> --json. Either way the recorded approval is listed, its audit chain verifies, and it is exported with the release. The text output marks the recordEXPIREDat exactly the instants the role is reportedexpired.store verify,passport verifyand the content, envelope and semantics layers ofverify-bundledecide nothing from the date they run on, andstore exportonly records that date as its timestamp (--as-ofpins it). The one date-dependent check is the authority layer's signer validity window, described above.
Encrypting a bundle at rest#
SBOMFlow does not implement encryption or key handling — evidence bundles are plain files you control. When you email or archive a bundle and want it encrypted at rest, wrap it with a standard external tool. Encryption is byte-preserving: after the recipient decrypts, the bundle is identical and still passes sbomflow validate / sbomflow verify-bundle.
# Sender — zip, then encrypt with age (or gpg):
sbomflow bundle ./evidence --zip evidence-bundle.zip
age -r age1exampleRecipientKey -o evidence-bundle.zip.age evidence-bundle.zip
# gpg --encrypt --recipient you@example.com evidence-bundle.zip
# Recipient — decrypt, extract, then verify integrity as usual:
age -d -i key.txt -o evidence-bundle.zip evidence-bundle.zip.age
unzip evidence-bundle.zip -d ./evidence
sbomflow validate ./evidence # exit 4 if anything was alteredThe decrypt-then-verify round-trip is regression-tested: a byte-preserving encrypt/decrypt leaves the bundle byte-identical and validation still passes. Choose recipients, keys, and key rotation with your own tooling — that is deliberately outside SBOMFlow.