Evidence outputs
Every SBOMFlow run writes plain files into a directory you choose. There is no database and no hidden state: what you see in the output directory is the system of record. This page explains each artifact — what it contains, whether it is a default or opt-in output, whether it carries an observation or a human decision, and who should look at it.
Note
Observation vs decision is the most important column. An observation is
what the engine derived from files and hashes. A decision exists only when
a named human recorded one. The two are never merged.
The default artifacts#
Produced by every analyze/audit run, offline, with no flags (the exact count is pinned by a drift-guard test that runs a real analysis and compares it against the published figure, so this page never needs to hard-code it):
| Artifact | What it is | Observation or decision | Who reads it |
|---|---|---|---|
evidence-pack.json | The complete machine-readable record: components with provenance, dependency edges, evidence items, gaps, findings, signals, and observed license evidence. | Observations, plus reviewer status fields that default to unreviewed | Engineers and integrators; everything else derives from this |
artifact-manifest.json | SHA-256, size, and classification for every scanned file, with whether it contributed components or evidence. | Observation | Anyone verifying that evidence traces to exact bytes |
scan-warnings.json | Every malformed or recognized-but-unsupported input, with a stable code and exact path. Nothing is silently dropped. | Observation | The engineer running the scan; CI in strict mode |
cyclonedx-sbom.json | CycloneDX 1.6 SBOM with purls, licenses, and the dependency graph. Structurally validated. | Observation | Customers, importers, security tooling |
spdx-sbom.json | SPDX 2.3 SBOM with purl external references and relationships. Every enumerated field — primaryPackagePurpose, relationshipType, referenceCategory, checksum algorithm — is written from the enum in the vendored official schema, so an OS package is OPERATING_SYSTEM (SBOMFlow up to and including v0.4.0 wrote a non-standard OPERATING-SYSTEM; see the changelog if you stored one). | Observation | Customers and license workflows |
cra-coverage.json | Observed evidence mapped to a versioned model of CRA Annex I. Manual-only areas are labelled not_automatically_assessable — never failed, never passed. | Observation | Product-security and compliance owners |
reachability.json | Conservative source-reference triage per finding. not_observed means "our deterministic scanners did not observe a reference" — never "safe". | Observation | Vulnerability triage |
release-gate.json | The gate evaluation: which policies were enforced, what blocked, what was suppressed by reviewed VEX, and the exit code. Informational unless you enforce --fail-on-*. Records the full policy body it enforced (policy_identity), so the release can be replayed later without the original policy file; a run under a packaged --policy-profile preset also records the exact preset bytes it ran under (policy_model). Both are replay provenance only. | Observation of your configured policy | Release engineering and CI |
release-record.json | This release's identity, counts, artifact hashes, and SBOM references — the unit that release comparison consumes. | Observation | Release history workflows |
issues.json / issues.md | Actionable items from gaps and findings, with stable keys, lifecycle states, and suggested owners. Known-exploited findings sort first. | Observation | Whoever fixes things; trackers |
audit-log.jsonl | Append-only, hash-chained run and decision events. Later modification breaks the chain and fails validation. | Record of events, including human decisions when they happen | Auditors and reviewers |
assessment-report.html | A single-file, human-readable report of all of the above. Opens in any browser, no server. | Observation | Everyone — start here |
vuln-source-health.json | Which vulnerability sources ran, their freshness, and whether any produced no usable intelligence. | Observation | Anyone asking "why were no CVEs found?" |
advisory-reconciliation.json | How multiple advisory sources describing the same finding were reconciled: per-source severity candidates, the precedence rule applied (a source is selected, never averaged), and an explicit flag when sources conflict. | Observation | Reviewers asking "which source's severity am I looking at, and did the sources disagree?" |
decision-migration.json | Provenance of review-decision identity migrations: which recorded decisions map onto the current finding identities. An ambiguous or stale legacy decision is reported and left unapplied — migration never broadens a decision. | Observation — bookkeeping, never a review outcome | Reviewers upgrading across releases who need to know which past decisions still bind |
bsi-tr03183-2-presence.json | Which BSI TR-03183-2 SBOM data fields your SBOM carries, per field: present, partial, missing, unknown (SBOMFlow cannot observe it), or invalid (recorded in a different form than the guideline states). | Observation — field presence, never a conformance or adequacy assessment | EU manufacturers asked "does your SBOM carry the BSI fields?" |
observation-coverage.json | What the scan did and did not inspect: every discovered file's single terminal disposition (parsed, recognized-but-unsupported, hashed-only, refused, binary, not-composition-relevant, …), the composition signals seen, and why a zero-component run may be incomplete. Every ratio names its numerator and denominator — there is deliberately no aggregate coverage score. | Observation — never a completeness claim, and never a decision | Anyone asking "did it actually look at my project?" |
unknown-input-candidates.json | Files no parser recognised that may still carry composition evidence: an unparsed manifest, lockfile, build descriptor, on-device package database, or firmware blob. Each row names the rules that fired, the corroborating signals, and a fixed confidence label (never a probability). | Observation — a proposal for a human, never a component and never a finding | Anyone asking "what did it see but not understand?" |
ai-evidence.json (experimental) | The AI model artifacts observed in the release tree, how each was identified (a documented magic number, a structural invariant, or the filename alone), its size and SHA-256, plus the entity graph and the observed-versus-declared reconciliation. Nothing is loaded, deserialised or executed. traversal records how much of the tree the walk actually examined, so an inventory a resource budget cut short is reported as PARTIAL rather than as a complete one. | Observation — an inventory, never a score, a verdict, or a safety or conformity claim | Anyone asking "what intelligence shipped in this release?" |
Unknown-input candidates (unknown-input-candidates.json)#
The third state of an input, and the one that used to be invisible.
SBOMFlow already models the two known states: an input it parses, and an input it recognises but cannot parse yet (which it warns about). The state it could not express was a file it recognises as nothing at all — hashed into the manifest, then silently contributing nothing. This artifact is that state, made visible.
A row appears when a file nothing else classified looks like it might carry composition. It is a worklist for a reviewer, and the boundaries are hard:
- Never a component. The Sentinel creates no component, emits no purl, and never guesses an ecosystem from a filename. Nothing here reaches the SBOM. A heuristic that invents inventory is worse than the blindness it replaces, because the invention is invisible.
- A label, never a number. Confidence is one of a fixed vocabulary —
likely_manifest,likely_lockfile,likely_build_descriptor,likely_package_database,likely_firmware_or_binary,structured_unknown,low_signal. There is no calibrated model behind a "0.82", and printing one would dress a filename heuristic up as a measurement. - Two independent signals, or nothing. Rising above
low_signalrequires at least two corroborating signals that observe genuinely different properties of the file. A file's shape (it parses as JSON/TOML/XML/YAML) never counts — every config file in existence has a shape. Neither does high entropy, nor living under avendor/directory. A rule that promotes on a single keyword is rejected outright: a Sentinel that flags every JSON file is noise, and noise trains reviewers to ignore the warnings that matter. - Content is sampled, never recorded. Classification reads a bounded head+tail sample (64 KiB total, however large the file) taken only from files nothing else classified — a parsed or recognised file costs no extra byte. The artifact carries
sampled_bytes(an integer) and derived signal names. The sampled bytes themselves are never written anywhere. - Bounded and honest. The row list is capped; when the cap bites, the document says
truncated: trueand still reports the true total. Warnings are capped too — a 1,000-candidate tree produces a handful of warnings, not 1,000. - No gate. Informational (exit 0).
--strictcan surface it in CI; nothing else blocks on it.
Nothing decompresses, executes, or reaches the network.
Observation coverage (observation-coverage.json)#
The artifact that makes zero components readable.
Every other SBOM tool can hand you an empty inventory that looks exactly like a clean one. SBOMFlow refuses to: a file it hashes but does not understand is counted, and a run that parses nothing while build descriptors, source, binaries, unsupported inputs or parse failures were present says so out loud (possible_missed_composition, with a machine-readable cause).
What it does not do matters just as much:
- No aggregate score. Each ratio keeps its numerator and denominator apart. A single "87% covered" would hide which dimension was weak and would imply a completeness claim SBOMFlow does not make — no static scanner can prove it found every component.
- No false alarms. The warning comes from a decision table, not from
if no components: warn. A genuinely empty tree, a docs-only tree, and a deliberately component-free target whose manifest SBOMFlow did parse all stay silent. A tool that cries wolf gets ignored. - No gate. Coverage is informational (exit 0).
--strictcan surface it in CI; nothing else blocks on it.
sbomflow validate recomputes every total from the per-file rows, so a coverage report whose numbers do not add up is rejected rather than believed.
License evidence (a section of evidence-pack.json)#
Every run records observed license evidence over the scanned tree: LICENSE / LICENCE / COPYING files (by presence and SHA-256 — the full text is not parsed) and SPDX-License-Identifier: header tags in source files. A tag counts only where the SPDX convention puts it — in a comment — so code that merely mentions the tag name is not reported as declaring a license. Each declared tag expression is checked for SPDX syntax validity against a pinned SPDX License List snapshot; a malformed expression becomes an invalid_license_expression warning and an unrecognised identifier an unknown_license_id note — the raw string is always preserved. The assessment-report.html renders the rollup.
This is observed metadata for a reviewer, never a conclusion: it does not state the licence a component or the product is "really" under, and never infers a component's licence from a nearby LICENSE file. Turning a declared licence into a concluded one is a human-reviewed step.
Declared vs concluded in the SBOMs. A component's declared licence reaches SPDX licenseDeclared (and CycloneDX expression) only when it is a single, syntactically valid SPDX expression; its canonical form is emitted with the source recorded (SPDX licenseComments / a sbomflow:license_provenance property). An invalid or multi-licence declaration whose AND/OR relationship the source never states becomes NOASSERTION with every raw string preserved verbatim — never guessed. SPDX licenseConcluded stays NOASSERTION unless a reviewer supplies a license_conclusions file (config license_conclusions:) mapping a purl to a concluded expression; the engine never concludes a licence.
Both halves of that mapping must be text the reviewer wrote. A purl or a conclusion supplied as a JSON/YAML null, number, boolean, list or mapping is reported as license_conclusion_invalid and applied to nothing — the value is never converted to text, because a conversion would put a determination nobody made into the field SPDX reserves for a human's. The warning names the type, so it reads differently from the one for a field left absent or blank: the repair is to quote an expression, not to supply a missing key.
Per-component parser provenance (observed_by, a field of evidence-pack.json components)#
Each component row records which deterministic parser routine produced it in an additive observed_by field — a stable parser id such as cargo-lock, npm-package-lock, container-deb, yocto-manifest, or imported-cyclonedx for a supplier SBOM import. It exists for replay and debugging: "which code path observed this row" is a recorded fact, not a reconstruction. A row merged from several sources keeps the id of the parser that produced its primary source_file; every contributing file remains listed in source_files.
Boundaries: observed_by never affects component identity, merging, advisory matching, origin/claim labels, or any gate. The engine version is recorded once per pack (tool_version), never per component. An empty value means the producing parser was not recorded — hash-identified binary inventory rows stay empty, and packs written by older SBOMFlow versions load with the field absent. Absent provenance is reported as absent; it is never backfilled or guessed.
NTIA minimum-elements presence (a section of evidence-pack.json)#
Every run records an ntia_minimum_elements block. For each of the seven fields in NTIA's minimum elements list — component name, version, a unique identifier (package URL), supplier, dependency relationships, the SBOM author, and a timestamp — it reports whether the field is present, partial (some components carry it), or absent, with counts and missing-component examples.
This is honest field presence, never a conformance assessment: the block carries no "compliant" / "conforms" / "certified" language, and a field SBOMFlow does not record — a per-component supplier organisation, since SBOMFlow records source/build provenance rather than who produced each component — is reported absent rather than inferred. It answers "which NTIA fields are populated?" in one command; a human judges sufficiency.
CISA 2025 draft SBOM minimum elements (a validate presence check)#
sbomflow validate runs a presence check for the four data fields CISA's 2025 Minimum Elements for a Software Bill of Materials adds to the NTIA 2021 baseline — component hash, license, generation tool name, and generation context — over SBOMFlow's own emitted CycloneDX 1.6 and SPDX 2.3 documents. For each element it prints whether the SBOM carries it, and names any absent one with the cataloged sbom_minimum_element_missing warning.
**This guidance is a draft, and this is a presence check — never a conformance certification.** CISA published the 2025 Minimum Elements as a Request for Comment in the Federal Register on 2025-08-22 (docket CISA-2025-0007); the comment period closed 2025-10-03 and, as of this writing, it is not a final rule. SBOMFlow observes which fields its output carries; it never claims the SBOM "conforms to" the guidance, and it never fabricates a value it did not observe.
Where each element lives, and the honest scope of each check:
| Element | CycloneDX 1.6 | SPDX 2.3 | Scope |
|---|---|---|---|
| generation tool name | metadata.tools name | creationInfo.creators Tool: | always emitted |
| generation context | metadata.lifecycles (discovery) | creationInfo.comment | records only that SBOMFlow generated the SBOM by automated discovery/analysis — never a claim about your build stage |
| component license | component licenses | package licenseDeclared | per-component in both formats: the declared licence where a source declares one, and the explicit SPDX-standard NOASSERTION "no assertion" sentinel for the rest — a disclosed unknown, never an invented licence |
| component hash | component hashes | package checksums | required of components SBOMFlow identifies by content (firmware blobs, binaries); a component resolved to a Package URL from a lockfile carries that purl, and SBOMFlow never invents a hash for bytes it did not fetch |
Because the guidance is a draft, a missing element is surfaced as a validate note (never an error, and never a gate). See the warning reference for sbom_minimum_element_missing.
Supplier intake (a section of evidence-pack.json)#
Every run records a supplier_intake block. When the scanned tree carries the conventional suppliers/<name>/ directories (each with a declared intake.json manifest), the block records every supplier (contact-free id, received date, scope note, declared files with SHA-256s), which merged components and supplier-VEX statements trace back to which supplier (component_attribution / vex_attribution), and any version_conflicts where two suppliers claim one component at different versions — both claims recorded, a warning raised, and a human decides. A manifest whose supplier or scope is not text — a JSON null, a number, a boolean, a list or an object — is reported and the supplier is skipped, rather than read as though somebody had typed the value. Without a suppliers/ directory the block is empty: discovery ran and zero suppliers were intaken.
Attribution is factual origin, never judgment: supplier-declared components remain first-class for advisory matching and gating, and supplier VEX remains context that never suppresses a gate.
Due-diligence records (a section of evidence-pack.json)#
Every run records a due_diligence block. When the due_diligence config key points at a reviewer-owned records file, the block carries the operator's Article 13(6) third-party due-diligence records (which checks were performed per component, when, by which role, with what outcome), a per-supplier rollup, and counts; each latest record also becomes a hashed evidence item attached to its named components. Without a records file the block is empty. The block carries a fixed banner — recording is not adequacy: SBOMFlow hashes and surfaces the records for review and never judges the diligence sufficient.
Component ownership labels (a section of evidence-pack.json)#
Every run records an owner_labels block. When the owners config key points at a local rules file ({"rules": [{"match": "pkg:pypi/*", "owner": "platform-python"}]}), each rule's glob is tried against a component's purl, then its name, then its recorded source file — first matching rule in file order wins — and the block maps each matched component to its team label. The label rides the review queue, issues.json, and tracker sync plans as owner_label so findings route to the right team (Jira and GitHub plans also gain a per-issue owner-<team> label for tracker-side filters). It is strictly separate from the engine's built-in suggested_owner functional-area hint, and it is presentation and routing metadata only: never an assignee, never a decision, never a gate input. An unmatched component simply has no label (no "unknown" noise), a later rule an earlier rule shadows draws a warning rather than a silent skip, and a label that looks like a person instead of a team draws a privacy warning. A rule whose match or owner is not text — a JSON null, a number, a boolean, a list or an object — is reported and skipped rather than converted into a rule nobody wrote; it never reaches the first-match-wins ordering, so it cannot shadow a real rule. Without an owners file the block is empty.
Imported-SBOM quality (a section of evidence-pack.json)#
When you import a supplier's SBOM with --import-sbom / --import-sboms, every run records an imported_sbom_quality list — one entry per imported document — with the same honest field-presence idea as the NTIA check above, but pointed at what your supplier handed you instead of SBOMFlow's own output:
| Field | Meaning |
|---|---|
source | the imported file's resolved path |
components | components SBOMFlow actually imported from this document (entries lacking both a name and a valid purl were never imported and are not counted) |
with_version | of those, how many declare a version |
with_purl | how many declare a resolvable package URL |
with_license | how many declare a license |
with_supplier | how many declare a supplier/organization in the document itself (CycloneDX supplier, SPDX 2.x supplier) |
unknown_purl_types | purl types present but outside SBOMFlow's own ecosystem support (e.g. swift, cocoapods) — still imported, just without ecosystem-specific handling such as version-range matching |
This is counts only, never a grade: no percentage-as-score, no letter grade, no pass/fail verdict — "your supplier's SBOM lacks versions on 6 of 22 components" is a fact you can act on, not a rating. Counts are computed from each document before any merge with your local scan or another imported document, so a component your own build also observed can never inflate what the supplier's document actually declared. with_supplier is always 0 for SPDX 3.x documents: that profile expresses supplier via a graph relationship rather than a flat field, and SBOMFlow does not guess at that shape.
BSI TR-03183-2 field presence (bsi-tr03183-2-presence.json)#
The EU-facing counterpart. Every run also writes bsi-tr03183-2-presence.json, reporting each data field of BSI TR-03183-2 (Cyber Resilience Requirements for Manufacturers and Products, Part 2: Software Bill of Materials, v2.1.0) as one of five states:
| Status | Meaning |
|---|---|
present | every component (or the document) carries the field |
partial | some components carry it; the report names missing examples |
missing | none carry it, although SBOMFlow's model can record it |
unknown | SBOMFlow cannot observe this field from build artifacts at all, so its absence says nothing about your product — every unknown field explains why |
invalid | a value is recorded, but not in the form the guideline states |
Two invalid results are expected and deliberate. TR-03183-2 states SHA-512 for the deployable component's hash; SBOMFlow records a SHA-256 content digest, so a recorded digest is reported invalid rather than quietly accepted. And the guideline states an email address or URL for the SBOM creator; SBOMFlow records the producing tool and version, which is neither — supply a creator contact in your own publishing step if a reviewer expects that form.
This is field presence, never conformance: the artifact carries no "compliant" / "conforms" / "certified" language, no score and no grade, and presence here is not evidence of CRA conformity. Fields TR-03183-2 requires but SBOMFlow cannot see — the executable/archive/structured properties of a shipped file, a per-component creator's email — are reported unknown, never guessed.
Opt-in artifacts#
Each appears only when you ask for it; all remain offline unless stated:
| Artifact | Flag / command | Observation or decision |
|---|---|---|
vex.json (OpenVEX) and cyclonedx-vex.json | --emit-vex | Decision-bearing: statuses come from your recorded vulnerability reviews; not_affected without a valid justification is downgraded, never emitted |
cyclonedx-vdr.json (Vulnerability Disclosure Report) | --emit-vdr | Observation — discloses every known finding, including untriaged ones. An untriaged finding carries no analysis block, because nobody has judged it. Where a review exists the analysis mirrors the VEX exactly. A VDR answers "what is known?"; a VEX answers "what does it mean?" |
ssvc.json | --emit-ssvc | Observation — a suggested priority from the CISA decision table; never sets status or blocks a gate |
Technical-documentation pack + eu-declaration-of-conformity.DRAFT.* | --emit-techdoc | Observation assembled for humans; the declaration is an UNSIGNED DRAFT and the watermark is validator-enforced |
cra-article14-*.DRAFT.json/.md | --emit-incident-report | Drafts with submitted: false; SBOMFlow never files or transmits them. Each carries a deadline_clock for its stage — elapsed-time arithmetic over instants you record with --incident-awareness-at and friends, never a legal deadline and never a decision that anything is reportable. A window whose anchor you did not record is UNKNOWN by name, never a computed date |
cra-article14-*.SRP-ORDER.DRAFT.json/.md | --emit-incident-report | The same drafts in ENISA's own Single Reporting Platform field order, for a human to transcribe from. No API exists; nothing is invented, dropped, or reordered, and the counts are not a readiness score |
release-drift.json | --previous-output or compare-releases | Observation: added/removed/changed components, findings, evidence, gates, and support periods between two releases. ai_changes carries the AI evidence delta — a model whose bytes differ, a declared supplier/licence/base model/dataset provenance/pinned digest that moved, an external provider, an agent tool or permission, lineage edges, and observed-versus-declared disagreements that appeared or cleared. An empty ai_changes is never agreement by itself: state, reason and no_change_meaning distinguish "compared and identical" from "nothing was comparable" (no AI record on the previous release, an unreadable one, or a run with no scan). No record says a change is harmful or disallowed |
evidence-aging.json | analyze --store DIR | Observation, context only: review-age per in-scope requirement — how many stored releases since a human last accepted its evidence (0 = accepted this run, "never" = no acceptance on record). A stored release whose coverage report could not be read still counts as a release, and is named under prior_releases_skipped with why (absent, unreadable, or tombstoned by retention, with the trash path the index records). An unread release newer than the acceptance makes that age an upper bound and says so. never_accepted is a claim about every stored release, so a run that could not read them all reports acceptance_undetermined instead. Never changes review state or a gate; the store is read-only |
finding-regressions.json | analyze --store DIR | Observation, context only: findings that were present in an earlier stored release, resolved in a later one, and are present again now (a depth-one drift report cannot see this). Matched by finding_key, so a re-identified advisory on the same component stays one finding. Sets no VEX and blocks no gate by itself |
carry-forward.json | analyze --store DIR | Observation, context only: findings this release re-observes that a human already determined in an earlier stored release — so re-review does not start from an empty page. Read from the stored releases' vulnerability_reviews.json (the human decision), never from the pack's engine-observed status. Every candidate's disposition is needs_confirmation and stays that way: a prior decision never applies itself, sets no VEX status here, and blocks no gate. Each row also states why the prior answer may not still hold — the component version moved since the decision, or earlier releases disagreed — so confirming is a deliberate act rather than a reflex. Rendered as a worklist in reviewer.html |
freshness-horizon.json | report freshness <output-dir> | Planning context only: one dated horizon — accepted-evidence validity windows, advisory-snapshot staleness, waiver expiries, and the support-period end — sorted soonest-first with expired / expiring_soon / fresh / undetermined status. Read-only; changes no review state and blocks no gate. An advisory snapshot whose age vuln-source-health.json could not determine — no date recorded, an unreadable one, or a date later than the run — is undetermined: stale_at and days_remaining are null, the recorded input is echoed verbatim, and the row repeats that artifact's own age_undetermined_reason. It is never given a horizon derived from a date that produced no age, so the two artifacts cannot say different things about one snapshot; a source the run never consulted has no snapshot to age and appears here not at all. Every day-count is relative to the --as-of pin, so the pin is strict: a malformed --as-of is refused (exit 2) rather than silently replaced by the wall clock, and a pin more than a day in the future is reported in the artifact's warnings list and on stdout — the horizon is still produced, the breadcrumb is never dropped. It also carries an overdue view of what is ALREADY past its date — CISA KEV remediation due dates that have passed, and expired exceptions re-queued as context. Read-only and never an SLA: it blocks nothing, changes no gate, reopens no decision, and asserts nothing about being on time or compliant. A KEV due date is CISA's date for federal agencies, not one SBOMFlow sets |
analysis-control-receipt.json | doctor --analysis-control PATH | Observation about your installation, not about your product — the one entry here that is not written into an output directory. It executes a reserved synthetic corpus shipped inside the package, fully offline, in a throwaway directory, and records which named controls this installed copy could perform: SBOM readers on valid, malformed and future-spec documents, declared and zero dependency edges, one exact advisory identity plus its near-name and near-version refusals, and the optional JSON-Schema capability. Outcomes are passed / failed / not_run / unsupported / degraded, kept distinct so an absent optional extra never reads as a pass; control_health is healthy / degraded / failed / indeterminate, and an absent, unreadable or empty corpus is indeterminate, never healthy. A packaged fixture that did not ship is failed, never skipped. It reads no customer project, blocks no gate, and states nothing about a product being safe, secure, compliant, certified, conformant or approved. Exit 0 only when every declared control passed; a written non-healthy receipt exits 6 |
evidence-bundle.json/html/zip | audit (ZIP via --zip) | Portable reviewer handoff copied from existing artifacts; byte-reproducible ZIP; adds no new claims |
| Sharing pack | sharing flags on audit | A redacted, documented subset for importers/distributors |
issues.csv, SARIF, PDF summary | export flags | Observations reformatted for spreadsheets, code-scanning UIs, and print |
reviews.json, vulnerability_reviews.json, waivers, approvals | review / approve commands | Decisions — recorded with reviewer identity, timestamps, and audit events |
| Importer evidence (OCI layout, CMake, linker map, Zephyr, MCUboot, update manifests, provenance checks) | dedicated commands | Observations from explicitly supplied local inputs |
cyclonedx-mlbom.json (CycloneDX 1.6 ML-BOM) | --emit-mlbom | Observation — the AI inventory in CycloneDX's ML-BOM shape. BOM-Linked to cyclonedx-sbom.json, never copied into it. Adds no claim the always-written ai-evidence.json does not already make |
spdx3-aibom.json (SPDX 3.0.1 AI + Dataset profiles) | --emit-aibom | Observation — the same AI inventory in SPDX. Add --emit-spdx3 and it imports that document's product element by IRI instead of restating it |
The two open-standard AI-BOMs#
ai-evidence.json is written on every run and is the record. The two AI-BOMs are opt-in projections of it into third-party standards, for a consumer whose tooling reads CycloneDX or SPDX. They add no authority, resolve no conflict, and reach no verdict; both are validated against the publisher's own schema, vendored in the package.
They link, they do not duplicate. The ML-BOM carries a CycloneDX BOM-Link (urn:cdx:<serial>/<version>) to cyclonedx-sbom.json rather than folding model weights into the component list a vulnerability scanner walks. The SPDX AI-BOM declares spdx3-sbom.json's product element in SpdxDocument.import and hangs its contains relationships off that IRI — two SPDX documents naming one IRI are naming one element. Without --emit-spdx3 there is no external IRI to import, so the product is declared locally and the document's own comment says which happened.
The unknown is stated, never guessed. SPDX 3.0.1 gives three properties SBOMFlow cannot observe an explicit noAssertion member — ai_autonomyType, ai_useSensitivePersonalInformation and dataset_datasetType — and those are emitted as noAssertion rather than omitted, because an absent property reads to many consumers as "no". ai_safetyRiskAssessment is never emitted: its enumeration has no "no assertion" member and SBOMFlow performs no safety assessment, so any value would be an invented risk rating. A CycloneDX modelCard is omitted entirely when nothing was declared, because an empty card asserts a model card exists.
Run-level properties both AI-BOMs carry#
These describe the DOCUMENT and the run that produced it, not any one component, so they are not information loss — there is nothing in the internal graph they fail to carry. They are listed here because every sbomflow: name a reader may encounter is documented somewhere on this page, and a test asserts exactly that.
| Property | Says |
|---|---|
sbomflow:release_id | The release identity this inventory belongs to — the same one the rest of the pack uses. |
sbomflow:ai_entity_count | How many AI entities the document describes. |
sbomflow:ai_entities_in_release | How many of them the graph claims actually ship in this release. An entity can be recorded without being claimed. |
sbomflow:boundary | The no-overclaim sentence: engineering evidence only, no compliance, conformity, certification or safety claim, no conflict resolved, no human review replaced. |
sbomflow:software_bom_link | The CycloneDX BOM-Link to cyclonedx-sbom.json. |
sbomflow:software_bom_link_absent | Present INSTEAD when no usable serial number was supplied, so the absence of a link is stated rather than inferred from a missing key. |
sbomflow:software_product_link | The element-level BOM-Link to the software SBOM's product component. |
sbomflow:software_product_link_absent | Present INSTEAD when the product's bom-ref contains whitespace or a control character, which an IRI fragment cannot carry. Percent-encoding it would produce a link that no longer equals the bom-ref it resolves to, so no element link is emitted and this says why. |
Information loss: CycloneDX 1.6 ML-BOM#
Facts SBOMFlow holds that the standard has no field for. Each one is carried through CycloneDX's own documented extension mechanism — namespaced properties — so it is visible without ever being dressed up as a standard field.
| Fact | How it is carried | Why |
|---|---|---|
Identification strength (signature / structure / extension_only) | property sbomflow:observed:identified_by | CycloneDX records a hash but has no field for how a file's FORMAT was established. A model matched by a documented magic number and one matched only by its filename are different claims and stay distinguishable here. |
| Model container format and size in bytes | properties sbomflow:observed:format, sbomflow:observed:size_bytes | No native CycloneDX component field carries either for a model artifact. |
| SBOMFlow entity id and entity kind | properties sbomflow:entity_id, sbomflow:entity_kind | The bom-ref is derived from the entity id but is not the id. The kind is kept because prompt, agent and evaluation entities have no CycloneDX component type of their own. |
| Whether the name is a supplier's name or just a filename | property sbomflow:name_basis | CycloneDX has one name field and no way to qualify where it came from. |
| Observed file locations (one entity can have several) | properties sbomflow:locator:N | Sameness for an observed model is its digest, so the same weights shipped twice are ONE component with two locations. CycloneDX has no per-component file-location list. |
| Observed-versus-declared conflicts | properties sbomflow:conflict:<attribute> | CycloneDX has no representation for two sources disagreeing about one attribute. The disagreeing values are emitted together, unresolved. |
| Evidence provenance (observed, declared, or both) | property sbomflow:evidence_provenance | CycloneDX evidence describes licence/copyright analysis, not this distinction. |
| Declared base model (model lineage) | property sbomflow:declared:baseModel | pedigree.ancestors would require synthesising a component for a model SBOMFlow never observed, which would put an unobserved artifact into the inventory. The declaration is carried as a string instead. |
| Typed graph relationships other than 'ships in release' | properties sbomflow:relationship:<predicate> | dependencies[] carries only 'ships in this release'. CycloneDX has no general typed-relationship vocabulary, so trained_on, derived_from, uses_model, invokes_service, governed_by_prompt and evaluates are named properties pointing at the other end's bom-ref. |
| A DECLARED digest, which is an expectation and not a verification | property sbomflow:unverified_digest | hashes[] is emitted ONLY for a digest SBOMFlow computed from bytes it read. A manifest's expectedHash describes a file that was never opened, and publishing it as a hash would republish 'the supplier says it should be X' as 'we checked and it is X'. |
| Why the inventory holds what it holds (no scan ran / the scan recognised nothing / the record was unreadable) | properties sbomflow:inventory_state, sbomflow:inventory_state_note | CycloneDX has no field for the provenance of an EMPTY list, and those three causes are different facts of which only one is reassuring. |
| Whether the tree walk that produced the inventory finished | properties sbomflow:inventory_partial, sbomflow:inventory_completeness_unknown | A scan a resource budget cut short must not project into a BOM that looks complete; CycloneDX models no such completeness signal. |
| Scan warning codes raised while walking the tree, and whether a declared manifest was supplied | properties sbomflow:scan_warning:N, sbomflow:manifest_supplied | Both are properties of the RUN rather than of a component. ai-evidence.json remains the full record. |
| Reconciliation summary (how many facts were comparable at all) | not carried — read ai-evidence.json | The denominator behind 'zero conflicts' is a property of the run, not of a component, and no BOM field describes it. The ML-BOM links back to the release; ai-evidence.json remains the record of what could be compared. |
Any declared field this writer does not place in a native CycloneDX field is still emitted, as sbomflow:declared:<field>. Nothing is dropped.
Information loss: SPDX 3.0.1 AI + Dataset profiles#
Carried through SPDX's own extension_CdxPropertiesExtension, which is why the document declares the extension profile alongside ai, core, dataset and software.
| Fact | How it is carried | Why |
|---|---|---|
Identification strength (signature / structure / extension_only) | extension property sbomflow:observed:identified_by | SPDX records a hash through verifiedUsing but has no property for how a file's FORMAT was established, so a magic-number match and a filename guess would otherwise look identical. |
| Model container format and size in bytes | extension properties sbomflow:observed:format, sbomflow:observed:size_bytes | dataset_datasetSize counts dataset entries, not bytes, and no AIPackage property carries a container format. |
| SBOMFlow entity id and entity kind | extension properties sbomflow:entity_id, sbomflow:entity_kind | The spdxId is derived from the entity id but is not the id. The kind is kept because prompt, service, agent and evaluation entities have no AI or Dataset profile class and are emitted as software_Package. |
| Whether the name is a supplier's name or just a filename | extension property sbomflow:name_basis | SPDX has one name property and no way to qualify its origin. |
| Observed file locations (one entity can have several) | extension properties sbomflow:locator:N | An observed model's identity is its digest, so the same weights shipped twice are ONE package with two locations; SPDX models no such list. |
| Observed-versus-declared conflicts | extension properties sbomflow:conflict:<attribute> | SPDX has no representation for two sources disagreeing about one property. The disagreeing values are emitted together and left unresolved. |
| Evidence provenance (observed, declared, or both) | extension property sbomflow:evidence_provenance | SPDX 3.0.1 has no per-property provenance in the AI or Dataset profile. |
| Declared base model (model lineage) | extension property sbomflow:declared:baseModel | A descendantOf relationship would need an SPDX element for a model SBOMFlow never observed. Minting one would put an unobserved artifact into the document, so the declaration stays a string. |
governed_by_prompt and evaluates graph relationships | extension properties sbomflow:relationship:<predicate> | SPDX 3.0.1's relationship vocabulary has no equivalent for either. trained_on, derived_from, uses_model and invokes_service DO map (to trainedOn, descendantOf, dependsOn, dependsOn) and are emitted as real relationships. |
| Declared dataset provenance and sensitivity prose | extension properties sbomflow:declared:provenance, sbomflow:declared:sensitivity | dataset_dataCollectionProcess describes HOW data was collected and dataset_confidentialityLevel is a closed enum; neither is what these free-text declarations say, and mapping them would restate a declaration as something else. |
| Reconciliation summary (how many facts were comparable at all) | not carried — read ai-evidence.json | The denominator behind 'zero conflicts' is a property of the run, not of a package, and SPDX has no field for it. |
| Safety risk rating | deliberately never emitted | ai_safetyRiskAssessment has no 'no assertion' member and SBOMFlow performs no safety assessment, so the property is omitted rather than filled. |
| A DECLARED digest, which is an expectation and not a verification | extension property sbomflow:unverified_digest | SPDX names the property verifiedUsing — the element WAS verified by that method — so only a digest SBOMFlow computed from bytes it read may appear there. A manifest's expectedHash describes a file that was never opened. |
| Why the inventory holds what it holds (no scan ran / the scan recognised nothing / the record was unreadable) | SpdxDocument.comment | SPDX has no field for the provenance of an EMPTY collection, and those three causes are different facts of which only one is reassuring. |
| Whether the tree walk that produced the inventory finished | SpdxDocument.comment | A scan a resource budget cut short must not project into a document that looks complete; SPDX models no such completeness signal. |
| Scan warning codes raised while walking the tree | SpdxDocument.comment | A property of the RUN rather than of a package; SPDX has no field for it. |
As in the ML-BOM, any declared field this writer does not place in a native SPDX property is still emitted, as sbomflow:declared:<field>. Nothing is dropped — and a test asserts both directions, so an extension name with no row above fails the build just as a row with no emitted name does.
Provenance chips: where every row came from#
Every component, advisory finding, and evidence row — in the assessment report and in the reviewer console — carries exactly one small provenance chip. The chip's text names the source family the row's fact came from; its title carries the full provenance string.
| Chip | Appears on | Means |
|---|---|---|
observed | component, finding | SBOMFlow saw this in the scanned tree. |
imported-sbom | component, finding | A supplier document claimed it; SBOMFlow did not observe it. |
corroborated | component, finding | Seen locally and claimed by a supplier document. |
local-sample-feed, osv, nvd, … | finding | The advisory source the finding came from. |
file, config, test, … | evidence | The kind of source the evidence item was read from. |
N sources | evidence | Several sources back this row; the title lists them all. |
unrecorded | any row | No provenance was captured for this row. |
unrecorded is highlighted deliberately. A row whose provenance was never captured must say so: a blank cell would read as "observed", which is a claim SBOMFlow has not earned. Chips are real text rather than icons, so screen readers announce them and they survive printing.
Empty sections: what an absence means#
A section with no rows is a reported state, not a blank table. Every generated HTML surface — the assessment report, reviewer console, evidence bundle, drift report, and dashboard — renders the same empty-state block naming what is absent, what that absence does and does not establish, and the next command. Empty sections are never hidden: the section and its column headers stay visible, because the absence is itself part of the evidence.
The wording is deliberate where a reader is most likely to misread it:
- No advisory findings means no advisory matched the observed components. It is not proof that the release is free of vulnerabilities. It reflects the advisory sources this run used and the components it observed, so check
usable_intelligenceinvuln-source-health.jsonbefore reading it as an all-clear. That flag counts real sources only: a run whose findings came from the bundled synthetic sample feed reportsusable_intelligence: falsewithsynthetic_intelligence_used: true, and a run that consulted no source at all reportsno_source_configured: true. Zero findings means something different in each case. - No missing-evidence gaps means every requirement SBOMFlow can assess automatically has an observed signal. Manual-only requirements never create gaps, so this is not a statement of completeness or conformity.
- No approvals recorded means no human has signed the release off. An empty ledger blocks a release only when a policy enforces required roles or a quorum.
A count that cannot be read shows as —, never as a number#
The same rule applies one level down, to the numbers themselves. sbomflow dashboard can be pointed at a release-index.json it did not write, and the portfolio rollup reads each release's release-drift.json off disk. When a field that should hold a count holds something else — a string, a list, null, a fractional number, true, or a negative number — that value is not converted into a count:
- a dashboard cell renders
—, the same mark the page uses for a timestamp it does not have, and the two headline counts add a line to the page's Index warnings block naming the field and what was found there; - a
release-drift.jsonwhose summary counts cannot be read joinsproducts_without_driftasunreadable, which is listed and, as that block already states, never counted as zero changes.
true is rejected explicitly rather than read as the number one, and a negative count is rejected by value with the number quoted back, because the type is right and the number is still impossible. A genuine 0 is untouched: zero is a real observation and still renders as 0.
Release identity: what release_id can and cannot separate#
release_id can and cannot separate#product_id and release_id in release-record.json are derived, not assigned: they are stable hashes of what you declared — product.name, product.version, plus the product line, variant, channel and build id. Declare the same inputs and you get the same ids on every machine, which is what lets a store, a release index and a drift comparison line one release up against another.
The consequence runs the other way too. If you declare nothing, SBOMFlow derives Unnamed Product / 0.0.0 — the honest answer for a product nobody named — and hashes those. Every undeclared run therefore derives the same ids, so two unrelated code trees analysed that way are one release by identity, and nothing keyed on release_id can separate them. The same is true of a scaffolded sbomflow.yaml whose REPLACE-ME values have not been edited yet.
That situation is reported rather than left silent. A run whose identity rests on a placeholder records the product_identity_from_placeholder warning in scan-warnings.json, and a release-drift.json whose two sides carry one release_id records that fact in its own warnings array.
Read both as observations about what was declared, not as findings about the product:
- The warning does not mean a collision has happened — only that the ids carry nothing that would prevent one.
- The drift line does not mean the two outputs are the same build, and does not mean they differ. It records that
release_idcannot tell them apart. The deltas in that file are unaffected and remain exactly what the two outputs contain. - Neither is a failure. The run completes and exits as it otherwise would, which is deliberate: analysing a directory before writing any config is the first thing
quickstartdoes.
Declaring product.name and product.version gives the release an identity of its own. Declaring them never renumbers releases you already recorded — ids already written to a store or an evidence pack are read as recorded and are never recomputed.
Re-running into an existing output directory#
Re-running analyze or audit into a directory that already holds a run does not silently mix the two. Choose what should happen:
sbomflow analyze . --output ./evidence # overlay (default)
sbomflow analyze . --output ./evidence --on-existing-output clean # one run, nothing stale
sbomflow analyze . --output ./evidence --on-existing-output refuse # write nothing, exit 2| Policy | What it does |
|---|---|
overlay | Writes over what is there. The historical behaviour, and the default, so existing workflows are unchanged. |
clean | Removes the artifacts SBOMFlow itself generates, then writes. The directory ends up holding exactly one run. |
refuse | If a previous run is detected, writes nothing and exits 2. |
Two guarantees are worth stating plainly:
cleanonly ever removes names SBOMFlow generates. A file you put in the directory — a supplier SBOM, your notes — is never touched, so pointing--outputat the wrong directory cannot destroy your data.refusetriggers on a previous run, not on a non-empty directory. A directory holding unrelated files is not an SBOMFlow output, so refusing there would be noise.
Opt-in artifacts are swept when their flag is off, whichever policy you pick. That matters most for the VEX family: a stale vex.json left beside a fresh evidence pack would assert not_affected about findings it never saw.
How to verify an output directory#
sbomflow validate ./evidenceValidation checks structure, cross-artifact consistency (counts, references, finding keys), hashes, the audit-log hash chain, and draft watermarks. A tampered or partially copied directory fails with exit code 4. Optional extras add official JSON-Schema validation when installed; without them the structural baseline still runs and says exactly what was skipped.
Note what that means in practice: the official-schema check is the one that only runs with an extra installed, so a default install cannot tell you whether an emitted SBOM would satisfy a consumer's own SPDX or CycloneDX tooling. Install it (pip install 'sbomflow[validate]') before shipping an SBOM to a customer.
The two SBOM exports are also round-trip stable: re-importing an exported cyclonedx-sbom.json or spdx-sbom.json yields the identical component set (name, version, and purl), so downstream tooling never sees an invented or dropped component. This is regression-tested offline, alongside the official CycloneDX 1.6 / SPDX 2.3 schema-conformance checks above.
Report theme (opt-in dark)#
Every generated HTML surface is light by default. Reviewers who live in dark editors can generate the dark palette instead:
sbomflow analyze . --output evidence --report-theme dark--report-theme {light,dark} is accepted by analyze, audit, compare-releases, dashboard, and review-view, and it reaches every surface that command writes — the assessment report, reviewer console, evidence bundle, sharing pack, drift report, and dashboard.
Three deliberate properties:
- Chosen when the file is written, not when it is read. There is no
prefers-color-schemeswitch and no JavaScript, so a portable evidence file looks the same on every machine that opens it, and the palette is reproducible from the command that produced it. - The default is unchanged. A light document emits no
data-themeattribute, so the dark rules in the stylesheet stay inert. - Paper is always light. Printing a dark document forces the light palette, whichever theme was generated — a dark page on white paper burns ink and reads badly.
Contrast#
Every foreground/background pair the stylesheet renders together meets WCAG 2.1 AA (4.5:1) in the dark palette. The ratios below are computed from the stylesheet itself by an automated test, so they cannot drift from what ships.
| Pair | Dark | Light |
|---|---|---|
body text on page (ink/paper) | 15.4:1 | 18.4:1 |
body text on panel (ink/panel) | 14.1:1 | 17.2:1 |
secondary text (slate/paper) | 7.7:1 | 7.0:1 |
de-emphasised text (faint/paper) | 5.5:1 | 3.3:1 |
pass badge (ok-fg/ok-bg) | 9.1:1 | 6.9:1 |
warning badge (warn-fg/warn-bg) | 8.9:1 | 6.6:1 |
danger badge (danger-fg/danger-bg) | 8.5:1 | 7.4:1 |
Note
The light palette's de-emphasised text (--faint, used by .muted and.none) sits at 3.3:1, below AA for text that small. That is pre-existing and
is deliberately not changed here: repainting a light token would alter every
report already generated, and this change's contract is that the default is
untouched. It is pinned as a known exception by the same automated test, so it
can neither grow nor be silently forgotten. The dark palette meets AA for the
same pair.
Determinism#
With a fixed --as-of timestamp, repeat runs over the same inputs are byte-identical — including the bundle ZIP. Without --as-of, only documented runtime timestamps (and fields derived from them, such as the audit-log hash chain) differ; components, edges, gaps, warnings, and purls do not. That is what makes the artifacts diff-able and reviewable.
For the field-level schema of every artifact — filename, schema id, and top-level keys — see the artifact schema reference.
Next: read your first bundle · warning codes · exit codes