Capabilities

SBOMFlow is the offline-first system of record for connected-device cybersecurity release evidence. This page lists what the product does today, with an honest status for each capability. Nothing here is a roadmap item; planned work is not listed as available.

How to read the status column#

StatusMeaning
DefaultRuns on every analyze/audit with no flags and no network.
Explicit inputRuns offline when you point SBOMFlow at a local file (for example a supplier SBOM or a vulnerability snapshot).
Opt-in flagOff until you pass the documented flag; still offline unless stated.
Opt-in networkContacts a service only under an explicit flag such as --use-osv. Never on by default.
Optional extraNeeds an optional Python extra (for example PyYAML or jsonschema). Baseline operation never requires it.
External toolUses a locally installed third-party tool when present; SBOMFlow works without it and says so.
Recognized, not parsedThe input is detected, hashed, and surfaced with a stable warning instead of being interpreted.
Shipped, not reachableThe code is in this release and no command, flag or configuration key resolves to it. It is named rather than omitted, because a module that exists — or a warning code that is documented — is not a capability you can use.

Important

Machine observations never become approvals. Reviewer status defaults to
unreviewed; VEX not_affected/fixed and evidence acceptance come only
from human review. See Observed vs reviewed.

Composition and identity#

Yocto kirkstone/scarthgap LTS builds and Buildroot legal-info builds can use the optional first-party emitters shipped for those build systems. They only copy existing manifests and write a deterministic SHA-256 inventory for later offline analysis; they do not run a gate or make a human decision.

CapabilityStatusOutput
Source manifests — requirements.txt, package.json, Cargo.toml, go.mod, Dockerfiles, firmware manifestsDefaultcomponents in the evidence pack and SBOMs
Lockfiles — npm package-lock.json/shrinkwrap, pnpm, Yarn Classic, Bun bun.lock, Deno deno.lock, Cargo, Go modules, Pipenv, Poetry, uv.lock, Composer, Bundler, Gradle, NuGet packages.lock.json, MSBuild project.assets.json, Conan, Swift Package.resolved, ESP-IDF dependencies.lock, Dart pub pubspec.lock, Elixir Hex mix.lock, Nix flake.lock, R renv.lock, Perl Carton cpanfile.snapshotDefaultresolved components and available dependency edges; locked git/path provenance is retained and unresolved local/workspace references warn
Recognized-but-unparsed dependency manifests — Maven pom.xml, requirements.lock, dependency-declaring pyproject.tomlRecognized, not parsedstable unsupported_lockfile warning naming the exact file
A resolved pin with no release version — a SwiftPM branch/commit pin, an ESP-IDF entry with no versionDefaultthe component is still recorded, but without a version (the commit is kept as provenance) and reported by lockfile_pin_without_release_version; a version is never invented from a branch name or a commit SHA
Ecosystems Package URL has no type for — ESP-IDF managed componentsDefaultrecorded honestly as pkg:generic/<namespace>/<name> — SBOMFlow mints no purl type and files them under no other ecosystem. Full inventory (name, exact version, component_hash, direct/transitive scope) with match_scope: none, counted as not-matchable in observation-coverage.json
Unpinned Python requirementsDefaultunpinned_requirements warning counting the declarations that could not be inventoried
Package URL identity — canonical purls with namespaces, qualifiers, version epochs, vendor/local suffixes, and container digests preservedDefaultpurls in CycloneDX/SPDX and stable component identity
Non-purl identifiers beside the purl — CPE 2.3/2.2, SWID tag id, SWHID and OmniBOR/gitoid, read from what a document states or an operator declares (identity.cpe_map), never derived from a component name, and re-emitted into each format's own field. Identity only: nothing here matches advisories or changes match_scopeDefault (operator map opt-in)external_identifiers per component, and CPE/SWID/SWHID/OmniBOR fields in CycloneDX and SPDX
Component-identity guard — structurally invalid names/versions from any parser are neutralized and warned, never emitted as inventoryDefaultmalformed_component_* warnings
Dependency graphs — deterministic edges, deduplication, workspace/git/file dependencies, legitimate cycles keptDefaultdependencies in CycloneDX, DEPENDS_ON in SPDX

Every recognized lockfile parser ships with adversarial regression fixtures covering duplicate versions, peer/optional dependencies, workspaces, git/path sources, Unicode, supported variants, schema-version drift, and malformed input. A parser cannot be added without them. Unsupported formats remain explicit warnings; they are not presented as parsed inventory.

Embedded and container builds#

CapabilityStatusOutput
Yocto image manifests and license.manifest (including under build/tmp/deploy/…)Defaultcomponents with declared licenses and build provenance
Yocto create-spdx SBOMs the build writes — the SPDX 2.2 document set, and the SPDX 3.0.1 JSON-LD image SBOM that Yocto 5.1 and later write (the only one 6.0 writes), selected by what the document is rather than by its nameDefaultfrom the SPDX 3 image SBOM: one component per recipe (pkg:yocto/<layer>/<BPN>@<PV>), the binary packages in the image as its occurrences, the CPEs the build states recorded as declared_by_build, host recipes marked build-host-only. The build's own VEX statements are counted and never interpreted; an SPDX 3 version other than 3.0.1, or a document naming no image, contributes no components and says why; elements not read are counted per type
Buildroot legal-info manifests, host/target separation preservedDefaultcomponents with source provenance
Build-host-only components — Buildroot host-manifest.csv packages and Yocto -native/-nativesdk/-cross* recipesDefaultthe component is kept (SBOMFlow observed it) and marked: CycloneDX property sbomflow:build_host_only = true, SPDX 2.3 package comment. It says the component is not in the distributed artifact — not that it is unreachable at runtime, which is what the CycloneDX scope enum means and why that enum is not used here. A package present in both the host and target manifests is not marked
Producer statements an SBOM consumer asks for — elements the producer withholds (product.sbom_withheld_fields, from the presence check's three observed-value element names, with the product.sbom_redaction_contact it requires) and where the SBOM for this version can be obtained (product.sbom_access: {uri, note})Opt-in config keyCycloneDX metadata.properties sbomflow:withheld_element / sbomflow:redaction_contact and a bom external reference on metadata.component; fixed lines in the SPDX 2.3 creationInfo.comment and the SPDX 3 SpdxDocument; two rows in the validate presence check and the SBOM line of START-HERE.md. withheld is only ever the producer's declaration — an absent value nobody declared stays an unknown. A declared element's values (licences, component hashes) are omitted from the emitted SBOMs and the VDR and kept in the evidence pack, and validate reads it as declared withheld and not present, with no finding; the two generation elements are SBOMFlow's own statements and cannot be declared withheld. The pointer is recorded text: never fetched or checked, and a credential in it is withheld. Presence only, never a statement that an SBOM meets any guidance
Zephyr west.yml (best-effort; unrecognized shapes warn)Defaultmodule components
Container image tarballs and extracted rootfs package databases — dpkg, Alpine apk, SQLite RPM — with observed os-release vendor contextDefaultOS-package components with distro/arch qualifiers
OCI image layout directories and image indexesRecognized, not parsedoci_image_index_not_parsed warning pointing to sbomflow oci-evidence, which records a layout's manifests, platforms and referrers but no components; a layout is read as an image only when release.container_images names it
OCI layout evidence import (manifests, platform variants, attached referrer artifacts)Opt-in flagOCI evidence records with verified blob digests
Container images a release declares it ships — release.container_images: a reference and digest per image, optionally a local OCI layout or image tarball of itOpt-in config keycontainer_images in release-record.json and oci-layout-evidence.json: each declared digest compared with the digests the local bytes carry (match, mismatch, not_established, or not_supplied when no local bytes are named), the referrer manifests those bytes hold listed as recorded facts, and a comparison with the previous release by reference and digest. Local files only; no registry, no signature check, never part of the gate
Package lists Go and Rust binaries embed — binary_inventory.read_embedded_package_metadata (with binary_inventory.enabled): Go build info (.go.buildinfo, __go_buildinfo, PE data) and cargo-auditable .dep-v0 in ELF, PE and thin Mach-O binariesOpt-in config keypkg:golang/... (including pkg:golang/stdlib at the version the Go vulnerability database uses) and pkg:cargo/... components, advisory-matchable, each with a binary_embedded_metadata record naming the binary, its SHA-256 and the section, and edges from the binary. What the binary declares, never verified; a malformed or over-cap region yields no component and warns (binary_buildinfo_malformed, binary_buildinfo_truncated)
Files whose digest was recorded elsewhere — covered_software:: an id, a release-tree path and the SHA-256 typed from the record that identifies the part (a module, a kernel library, a radio stack, a calibration image, any file you name)Opt-in config keycovered-software.json: each file's SHA-256 compared with the recorded digest (identical, differs, path_absent, path_not_regular_file — a symbolic link is never followed — or path_unreadable). A fact about bytes: no scheme rule is read and nothing is decided about re-certification; never part of the gate
Per-component support as people declare it — component_support.declarations: local files naming components by Package URL or CPE, each with a support level (actively_maintained, no_longer_maintained, abandoned) and an optional end dateOpt-in config keycomponent-support.json: a row for every component with the declared level, date, declaring file and its SHA-256, not_declared when nobody declared one, undetermined when a declared value cannot be read; matched by identifier equality only and never inferred
Component end of support from lifecycle snapshots saved locally — component_support.snapshots: an operator snapshot (identifier, cycle, support_ends, source_url, retrieved) or a public lifecycle API's product document (schema 1.x), read offlineOpt-in config keycomponent-support.json lifecycle_snapshot per component (maintained, eol, not_mapped, unknown) with each source's file digest and retrieval instant; support_gap against the release's security_updates promise end (else its kind-less end, never a hardware date) or a stated reason why nothing was compared; component_support_changes against a previous release; upcoming component end dates in the freshness horizon; gates only through the default-off fail_on_component_support_gap. Never fetched by the run: component-support fetch --product <name> --output <dir> is the separate, explicit network step that saves the documents
Imported supplier SBOMs — CycloneDX 1.0 through 1.7 (schema-validated at 1.6 and 1.7), SPDX 2.x, and SPDX 3.0.1 JSON-LD (software profile: packages and dependsOn relationships, a LifecycleScopedRelationship read as one with its scope), with per-version component and dependency parity (the composition core; constructs like compositions/formulation are not folded into it — a supplier's compositions statement is recorded on the import quality row as the supplier's, never adopted)Explicit inputimported components and edges, kept distinct from locally observed evidence. The spec version is discriminated, never assumed: a document from a version newer than this build models is refused rather than read as 1.7, and CycloneDX content fields SBOMFlow does not model are named in a warning rather than dropped — an import is not a lossless conversion. Not everything unread is named: the CycloneDX envelope keys (serialNumber, version, metadata.timestamp, metadata.tools), a declared component name or version that a parsed purl overrides, and every SPDX 2.x field the reader does not use are dropped with no warning; the BOM family census lists what each reader did with every field of a fixture. The SPDX 3.x side reports two distinct things rather than one. An element type SBOMFlow does not read — an ai_AIPackage, a dataset_DatasetPackage, a build-profile element, a package carrying no software_packageUrl — is counted and named, so what it said is recorded as unknown, never as empty. Separately, a document carrying a fact that qualifies or contradicts what SBOMFlow did read is reported as not fully understood: a relationship type outside dependsOn/contains/describes (SPDX 3.0.1 carries both affects and doesNotAffect, and the security_Vex* assessment relationships, so a supplier's not-affected assertion can be present and unread), a declared completeness other than complete, a startTime/endTime bound, or a NoAssertionElement target. That second one is a warning, not an info, because an unread fact there changes what a read one means. Every SBOM, VEX and VDR SBOMFlow emits is CycloneDX 1.6; the only output written at 1.7 is engineering-release-notes.cdx.json, which uses the 1.7 releaseNotes model
Dockerfile base images — per-stage FROM, digest-pinned vs floating-tag, multi-stage internal references excluded, --platform/ARG handledDefaultbase-image components with pinning provenance; base_image_unpinned warning for mutable tags
CMake declared sources — FetchContent_Declare / ExternalProject_Add in CMakeLists.txt/*.cmake (never runs CMake, never fetches, ${...} left unresolved)Defaultpkg:generic components with cmake-declared git/URL + checksum provenance
PlatformIO platformio.ini lib_deps declares (static read; ${...} interpolations never expanded, registry never queried; ranges neutralized, never presented as point versions)Defaultpkg:generic components with declared-spec provenance and match_scope: none
Arduino library.properties — a vendored library's own metadata (name=/version= mandatory; depends= kept as provenance, never fabricated into components)Defaultone exact pkg:generic component per vendored library, with declared license
Device-tree source overlaysRecognized, not parseddevice_tree_not_parsed warning
Further build-evidence importers — CMake File API, linker maps, Zephyr SPDX (both serializations: JSON document sets and the tag-value *.spdx set released west spdx writes, selected by content rather than by extension; a tag-value version or grammar this reader declines is named and contributes nothing, never silently truncated) / Twister, ESP-IDF metadata, MCUboot, update-system manifests (RAUC, SWUpdate, Mender, Uptane)Explicit inputdedicated evidence artifacts per importer

Vulnerability and exploitation context#

CapabilityStatusOutput
Offline advisory matching against a bundled, clearly-labelled non-real sample feedDefaultfindings marked with their provenance
Local vulnerability-database snapshotsExplicit inputfindings with snapshot provenance and freshness
OSV and NVD enrichmentOpt-in networkfindings with source URLs and CVSS context
CPE candidates for embedded C components (experimental)Opt-in config (scan.cpe_candidates), offlinecpe-candidates.json — components whose DECLARED CPE (an imported SBOM, a build, identity.cpe_map, or the curated vendored-header table) shares a vendor:product with a vulnerable configuration in a local NVD snapshot, with the version bounds evaluated. Candidates for a human, never findings: no name guessing, unorderable versions and platform conditions are listed rather than decided, and Deferred NVD records are counted as a gap. A human disposes of each through review --dispute-match; a prior disposition carries to the next release and to a declared family member as context, never applied. Nothing reaches the pack, the gate or the approval digest unless fail_on_unreviewed_cpe_candidates is set, and under it a candidate confirmed to apply stays blocking
Declared devices beside industrial CSAF advisories (experimental)Opt-in config (identity.devices), offline, over the run's supplier VEX CSAF documentsdevice-advisory-context.json — each advisory product whose vendor and product name EQUAL a declared device, or whose model-number pattern matches a declared model (CSAF 2.0 or 2.1 rules), with what its version statement says about the declared firmware version: evaluated for vers:semver and dotted-number vers:intdot, recorded as not evaluated with its reason otherwise. Near misses listed, never matched; every count with its denominator. Context, never findings: nothing reaches the pack, the gate, a VEX status or the approval digest
CISA KEV and FIRST EPSSExplicit input or opt-in networkknown-exploited flags and exploit-probability scores — inputs for human triage, never verdicts
CVSS v3.x base-score computation from vectorsDefaultderived severities (never invented)
Conservative reachability observations for Python, JavaScript/TypeScript, Go, Rust, C/C++Defaultreachability.json; not_observed never means safe
Reviewer-driven VEX (OpenVEX + CycloneDX VEX + CSAF VEX)Opt-in flag (audit turns it on)not_affected requires a valid justification or it is downgraded. --emit-vex writes cyclonedx-vex.json unconditionally, csaf-vex.json only when there is at least one vulnerability to state, and vex.json only when there is at least one statement — OpenVEX requires minItems: 1, and CSAF 2.0 mandatory test 6.1.27.11 requires /vulnerabilities in every CSAF VEX document while its schema requires at least one entry, so neither format has a conformant document that says nothing applies. Each absence is recorded as vex_export_has_no_statements, with the file's name as its path, rather than left to look like a truncated run, and no vulnerability is ever invented to fill one. sbomflow audit sets --emit-vex for you, so a plain audit produces the CSAF document whenever it has a finding to state
Supplier VEX ingestion (OpenVEX, CSAF or CycloneDX) as contextExplicit inputsupplier statements kept separate — they never set your status automatically. A CycloneDX VEX (a document whose vulnerabilities carry an analysis) maps its state onto a status (false_positive as not_affected) and never onto a CISA justification: the CycloneDX state, justification, response and detail travel verbatim in the statement's notes, and an affects ref that resolves to no component in the document binds nothing. The CSAF spec version is discriminated rather than assumed: a document declaring a version NEWER than the modelled CSAF 2.0 is still imported, under 2.0 rules, and says so (newer_csaf_version); one whose document.csaf_version is absent, empty, unparseable or EARLIER is malformed_csaf_version — CSAF 1.x is the pre-OASIS CVRF lineage, a different format, so calling it "newer" would state the gap backwards. The comparison is on the parsed (major, minor) pair, never on the literal string "2.1", so it does not go quiet the day another version is published
Suggested SSVC priority from the CISA decision tableOpt-in flagssvc.json — a suggestion for humans, never a gate or a status
Post-release re-evaluation of a shipped release (sbomflow recheck)Opt-in command, offlinea NEW recheck/ directory (recheck.json + recheck.md) classifying each finding newly_affected / no_longer_affected / unchanged against a newer local advisory snapshot. The frozen release's components are read from its evidence pack (source is never re-scanned) and the shipped pack is never mutated; a prior reviewer decision rides along as context and is never applied. no_longer_affected means only "not matched against the advisory data you supplied" — it is never a claim that a finding was fixed. The report says all of this to tooling as well as to you: it carries advisory: true, output_kind: advisory_recheck, and a watermark, so a script can tell an advisory projection from a pack of record; recheck_of names the release it advises on and supersedes chains successive rechecks written to the same directory, so there is always a "latest". It is never a gate input, and feeding a recheck report back in place of an evidence pack is refused. Each row also says whether its advisory was in the advisory snapshot the release recorded (release_snapshot_knowability: absent_from_release_snapshot / present_in_release_snapshot / release_snapshot_unavailable), read only by the digest the release recorded, from the local vulndb store --vulndb-dir DIR names (no default; without it every row reads release_snapshot_unavailable) — see evidence outputs

CRA-oriented evidence#

The CRA is an optional requirement regime. The rows marked Default below run on a cra run, which is the default when a configuration does not set requirement_regime; a run under requirement_regime: none assesses no requirement and writes none of them. See Use SBOMFlow without CRA assessment.

CapabilityStatusOutput
Neutral regime views (experimental)Opt-in commandregime-view.json via sbomflow regime-view <dir> --regime <id>; a READ-ONLY lens over an already-verified pack through a versioned local profile. Profiles reference canonical evidence rather than copying it, so removing a view leaves the pack untouched. Results are observed / missing / manual / needs_review / unknown, kept distinct — unknown is never treated as safe. The only shipped profile is an SBOMFlow demonstration with no legal authority; no FDA, UK PSTI or UNECE R155 mapping ships, and real regime content needs its own official-source and legal review
Customer-authored regime profiles (experimental)Opt-in command`sbomflow regime-profile initvalidateinspectexport; author your OWN local profile for a regime SBOMFlow does not package, then apply it with regime-view --regime-file, or name it under requirement_catalogs: so every analyze/audit run writes requirement-catalog-view.json — your list compared with what the run recorded, never a gate input (guide). The scaffold contains no legal asks — it is an empty kit, not a starting legal position. A local profile is structurally distinct from a packaged one and cannot shadow one, offers only selectors the engine already supports, and is data that is never evaluated. legal_review defaults to not_reviewed, is reported verbatim and is never upgraded. Writes regime-profile-validation.json` and, on export, a manifest — never evidence. SBOMFlow packages no real US, UK, medical or automotive legal profile under this feature; the kit does not parse legislation, generate asks or judge adequacy, and no output states that a product is compliant, conformant, certified or legally ready
Sealed Codex Security scan import (experimental)Opt-in commandsbomflow ingest-codex-security <scan-dir>; reads a LOCAL completed scan's scan-manifest.json + findings.json + coverage.json and verifies the manifest seal. Offline: SBOMFlow never runs the scanner, never asks for a key, never uploads code. Every record is labelled external_ai_assisted_observation and needs_review — a scanner's own validation is not human review — and severity/remediation are kept as supplied. Coverage partial/unknown stays visible; an unrecognised value degrades to unknown, never to complete; zero findings never means safe. Release binding needs an explicit compatible revision or snapshot, otherwise unbound with reasons. Source excerpts are referenced, never copied. Changes no SBOM, VEX, gate or release byte. Generic SARIF import remains available but is a thinner projection that cannot prove the sealed coverage contract
Versioned model of Regulation (EU) 2024/2847 Annex I with evidence mappingDefaultcra-coverage.json; manual-only areas are labelled, not failed
Commission guidance edition crosswalk (C(2026) 5252)Defaultcra-guidance-crosswalk.json; records which guidance edition was consulted (identifier, date, SHA-256 of the official bytes) and which Regulation-derived nodes each topic discusses. Guidance is explanatory context: statuses are limited to discussed / not_mapped / human_legal_review_required, never a conformity conclusion, and it is never a gate input
Structured manufacturer evidence inputs for still-manual areasExplicit inputhashed inputs surfaced in coverage — never marked adequate or accepted by the engine
Annex VII technical-documentation pack, including an UNSIGNED DRAFT declarationOpt-in flagtechdoc pack; the draft watermark is validator-enforced
Article 14 incident-report drafts (early warning, notification, final)Opt-in flagcra-article14-.DRAFT. with submitted: false — SBOMFlow never files, transmits, or contacts a CSIRT/ENISA

Limitation

SBOMFlow records engineering evidence and gaps. It does not determine legal
conformity, does not classify your product, and does not submit anything to
a regulator. See CRA orientation.

Release workflow#

CapabilityStatusOutput
Release gate — informational by default, blocking only under explicit --fail-on-* policies, VEX-aware, with the exact reason recordedDefaultrelease-gate.json and a stable exit-code contract
Named, versioned policy profilesOpt-in flagrecorded policy source and hash in the gate output
Versioned policy packs — per rule: why it exists, what evidence it needs to be evaluable, what informed it, an advisory severity, and whether it is enforced or documented-but-off; per pack: purpose, scope, source references and a version history with migration notesDefault for every shipped presetthe pack block in sbomflow policy show, present in every view and naming its own state so a policy nobody documented stays distinguishable from one whose documentation could not be read
Refusing a pack that misdescribes its own policy, or names a rule the engine does not haveDefault, checked on every loada pack marking a rule enforced that its policy leaves off is refused, as is a rule name that is not a real gate field — a document promising a protection nobody provides is worse than no document, because it reads well
Reporting rules a policy enforces that its pack never documentedDefaultundocumented_enforced_rules in sbomflow policy show. Reported rather than refused: the gate still blocks and nobody is misled about a protection they lack, so this is incomplete documentation of something real
Refusing a pack that claims conformity, certification or compliance with any regulation or standardDefault, checked on every loadmatched by shape — a regime token beside a claim token — not by a list of banned phrases. A denial of conformity is explicitly allowed, so the guard cannot be satisfied by deleting a disclaimer
Overriding any rule a pack documentsDefaultevery documented rule is a real policy field, so your own policy file sets it; a preset is a documented starting point, never a fixed setting
License-policy matching against your own SPDX deny/notice list — engineering context, never legal advice, a compatibility analysis, or a compliance determination; blocks only under --fail-on-denied-license, and only for a single valid SPDX expressionOpt-in policylicense_policy_matches in release-gate.json (with a fixed disclaimer; no shipped deny list)
Release records, release comparison, and drift (components, dependencies, findings, evidence, gates, support periods, declared-license changes)Default / explicit previous outputrelease-record.json, release-drift.json, local release index
Review queue with tamper-evident decisionsDefault artifacts, human actionsreviews.json + append-only, hash-chained audit-log.jsonl
Multi-role approvals with separation of duties (self-approval rejected; one reviewer covering two required roles rejected), expiry, and revocationOpt-in commandsapproval records tied to exact artifact digests
Time-boxed gap/finding waiversOpt-in commandswaiver records that keep waived items visible
Domain-aware merge of two reviewers' decision files (sbomflow review-merge)Opt-in command, offlinemerged decision file (keys unioned, identical decisions deduplicated) plus merge-conflicts.json; a key decided differently by two humans is never auto-resolved — it is omitted, reported, and the command exits 1 until --resolve <key>=ours|theirs is passed. --base gives a three-way merge; a parse failure or a cross-product merge aborts entirely
Issue exportsDefault / opt-inissues.json, issues.md, CSV and SARIF exports
Tracker sync — GitHub Issues, Jira, ServiceNow, Dependency-TrackOpt-in; dry-run by default, network only with --apply and credentialsidempotent sync plans, then deduplicated updates
Notifications — Slack, Teams, webhook, emailOpt-in --applymessages carrying counts and links, never secrets
Evidence bundle and auditor handoffDefault with audit; ZIP opt-inevidence-bundle.json/html/zip — byte-reproducible ZIP
in-toto attestation over the evidence bundle (sign-bundle --format in-toto)Opt-in flag; optional sign extraevidence-bundle.intoto.jsonl — a DSSE envelope wrapping an in-toto Statement whose subject is the bundle and its SHA-256, readable by standard in-toto verifiers. Restates the existing signature's integrity/authenticity guarantee only; never a conformity, security, or builder-trust claim
Importer/distributor sharing pack (redacted subset)Opt-in flagsharing pack with a documented allowlist
Sanitized support/debug bundle (sbomflow support-bundle)Default, offline, opt-in writetool version, capability matrix without filesystem locations, config shape (keys and types, never values), per-code warning counts, artifact base names + schema versions + hashes, phase durations — source contents, components, findings, paths, environment values, reviewer notes and warning message text are excluded by construction; every bundle is self-audited by the redaction checker and nothing is written without --yes
Local resource diagnostics (analyze --emit-metrics)Opt-in, offlinemetrics.json records best-effort peak RSS and generated bytes; unsupported RSS measurement is omitted rather than fabricated as zero, and the sanitized support bundle carries numbers only
Run completeness and crash receiptDefault, offlinerun-completion.json seals produced bytes and the canonical complete/partial state; append-only run-receipt.jsonl records stage checkpoints and retry-safe breadcrumbs without telemetry
CI templates for GitHub Actions, GitLab CI, JenkinsDocumentedcopy-paste workflows using the stable exit codes
Generated GitHub Actions workflow (sbomflow quickstart <dir> --ci github)Default, offlinewrites .github/workflows/sbomflow.yml (offline audit + gate + PR annotations + evidence upload, pinned action versions) with overwrite protection; byte-identical to the documented example
Native CI annotations from a gate verdict (sbomflow annotate)Default, offline, output-onlyGitHub Actions ::error/::warning/::notice workflow commands or a GitLab Code Quality report — blocking policies as errors, other observed issues and expiring waivers as warnings, an unenforced gate as one notice

AI supply-chain evidence (experimental)#

Every analyze/audit run writes ai-evidence.json: an inventory of the AI model artifacts observed in the release tree, recorded against the same release identity as the rest of the pack.

CapabilityStatusOutput
Model artifacts identified from bytes — GGUF and TFLite by documented magic number, SafeTensors and ONNX by documented structural invariantDefaultobserved_model_artifacts in ai-evidence.json, each with its path, size, SHA-256, and the identified_by strength of the claim
Formats whose only faithful reader executes code — anything pickle-backed, an executable graph, or an archive that can carry oneRecognized, not parsedthe artifact is recorded and hashed with ai_model_unsafe_format_not_parsed; it is never opened
Zip files, which store model archives and Java archives, Android packages, office documents and zipped manuals alikeRecognized, never openeda zip is recorded as a model only when something names it as one — a model extension (model.pt, weights.keras), a model name beneath a container or zip suffix (model.pt.zip), a file named as a model beside it, a shard index beside it whose weight_map names it (pytorch_model-00001-of-00002.bin), or a model configuration beside it that declares a model (pytorch_model.bin next to its config.json). Any other zip is not inventoried, hashed or opened, and container_coverage.zip_archives_not_named_as_models in every ai-evidence.json counts them, so the absence is stated. A raw pickle stream is recorded whatever it is named
Extension-versus-signature disagreement (a pickle wearing a .onnx name)Defaultboth facts recorded, the bytes decide the format, and ai_model_extension_signature_mismatch is raised
A file identified only by its nameDefaultkept in the inventory as identified_by: extension_only with ai_model_identified_by_extension_only, so the claim is visibly resting on a filename
A model inside a compressed or archive container — 7z, gzip, tar (ustar), xz, zstd — recognised by the container's magic numberRecognized, never openedrecorded in observed_model_artifacts as format: unsupported with container naming the kind, plus its size and SHA-256, when the name carries a model suffix beneath the container suffix (model.pt.zst), when a model extension sits over container bytes (a 7z named .safetensors, which also raises ai_model_extension_signature_mismatch), or when the archive (weights.tar.gz) sits beside a file named as a model. An archive with none of those is not inventoried. Nothing is decompressed, listed or read beyond the magic number, and container_coverage in every ai-evidence.json says so, so a model inside a container reads as unexamined, never as absent. Every container row carries ai_model_container_not_inspected, and containers_recorded counts exactly those rows
Entity graph with stable ids and typed relationships; the same weights shipped twice merge into one entity carrying both locationsDefaultgraph in ai-evidence.json
Observed-versus-declared reconciliationDefaultreconciliation in ai-evidence.json; with no declared manifest supplied it honestly reports that nothing was compared
A declared AI manifest as a run inputDefault, opt-in--ai-manifest <file> on analyze and audit reads one LOCAL JSON declaration and folds it into the same graph as the observed inventory, so the reconciliation is exercised rather than described. Offline — the flag is registered as a local input and appears in no network gate. A run without it is byte-identical
A supplied manifest that is absent, unreadable, malformed, or of an unknown schemaDefaultfour different named states in declared_manifest, plus ai_manifest_unusable. "No manifest was supplied" is a fifth fact, carried by manifest_supplied in every record
Declared agent identity and authority — owner, purpose, environment, model and prompt references, tools, MCP servers, credential category, delegated authority, data-access classes, external destinations, allowed and prohibited actions, approval requirements, autonomy level, execution limits, logging requirements, stop mechanismDefault — the declaration reaches a run through --ai-manifestagents in a declared manifest becomes an agent entity in graph, linked by uses_model / governed_by_prompt / invokes_service. environment decides an agent's identity, a declaration must state it and static configuration never does, so the declared and the discovered agent stay two entities rather than a guessed merge
Declared agent authority reconciled against the observed agent surfaceDefaultagent_authority in ai-evidence.json. A declared permission is a supplier claim recorded as supplied and never a verified fact: SBOMFlow read an account of what an agent may do and did not observe the agent doing it. Matched by name, the weaker of the two available claims and labelled as such; a declared agent and a discovered one are never merged into one entity. A tool nobody declared is not declared and never unauthorised; a declared tool no detector saw is not observed and never absent. Every count carries the number of declarations and observed agents examined to produce it
Calling an undeclared agent unauthorised, or scoring an agent's trustworthinessNever producedneither direction of disagreement is a violation, and no state in agent_authority reaches a gate
Static discovery of services, agents, tools and MCP servers — MCP server declarations, agent-framework configuration, dependency and lock files, import statements, container, deployment and infrastructure-as-code files, and environment-variable namesDefaultdiscovery in ai-evidence.json, and each discovered entity folded into graph with observed provenance so it meets a declared one through the ordinary sameness rules. Every detector publishes what it cannot see beside what it can, and an empty result reads "no supported framework was recognised", never "no agents"
Secret values in anything discovery readsNever recordedonly the variable NAME and its presence. MCP arguments are counted, never recorded; a URL keeps scheme://host and drops userinfo, path and query; every string and every key crossing into the record is screened for credential shapes, and a match is redacted with ai_discovery_secret_value_refused, which names the shape and never the text
MCP server cards (SEP-2127) committed in the release tree — identity, version, declared schema, and per remote the transport, scheme://host, protocol versions and header namesDefaultmcp_server_cards in ai-evidence.json, reconciled against the MCP servers discovery observed in configuration. Every card is recorded as a CLAIM with the path that stated it and carries verified_against_server_behaviour: false on its own entry; a server declared and not observed and one observed and not declared are two states with two sentences and two codes. candidate_files_examined sits beside cards_read, so a zero is never read without its denominator
Verifying a server card, rating one, or checking it for a misleading descriptionUnsupported by designno server is contacted, no registry queried, no endpoint dereferenced and no tool listed, so a card that describes its server inaccurately reads from the tree exactly like one that describes it correctly. A card's prose is never copied into the record; the file is digested instead. Absence of a card is recorded as absence — publishing one is optional and most servers publish none
Contacting a discovered endpoint, MCP server or providerUnsupported by designnever — the AI evidence code path contains no network transport, and a build guard enforces it; a discovered endpoint is never contacted
How much of the release tree the walk actually examinedDefaulttraversal in ai-evidence.json — complete, the budget names in bounds_hit, and the budgets that were in force, so an inventory cut short by a resource budget is reported as PARTIAL rather than as a complete one. The count of entries walked is deliberately not recorded: it depends on what happened to be on disk, and the pack is byte-identical on a rerun
Symlinks are not followed out of the scan rootDefaulta skipped entry is reported as ai_model_symlink_skipped, so a crafted tree cannot make SBOMFlow read and hash arbitrary host files
The same inventory as a CycloneDX 1.6 ML-BOMOpt-in flag (--emit-mlbom)cyclonedx-mlbom.json, validated against the vendored CycloneDX schema and BOM-Linked to cyclonedx-sbom.json rather than copied into it
The same inventory in the SPDX 3.0.1 AI and Dataset profilesOpt-in flag (--emit-aibom)spdx3-aibom.json, validated against the vendored SPDX 3.0.1 schema; with --emit-spdx3 it imports that document's product element by IRI instead of restating it
Facts neither standard can carry (identification strength, container format, conflicts, entity ids, file locations)Both AI-BOMscarried through each standard's own documented extension mechanism under the sbomflow: namespace, and published as an information-loss table — never presented as a standard field
AI-aware release controls — a model's bytes, a material declared fact, an observed-versus-declared conflict, an artifact recorded but never parsed, plus two honesty controls for "nothing was compared" and "the walk was cut short"Opt-in, policy file only (fail_on_ai_*: true)ai_control_status in release-gate.json on every run, and a GATE_AI_* reason code per blocking control. Off by default: an AI observation is informational until an operator turns its knob on, and the policy digest is unchanged until then
Telling "an enforced AI control found nothing" from "an enforced AI control could not run"Default wherever an AI control is enforcedGATE_AI_CONTROL_UNEVALUATED, its own reason code — an enforced control whose input was absent, or that raised, blocks rather than passing. Every control is evaluated independently, so one that fails never stops the others
AI conflicts, unverified entities and control outcomes in the reviewer console and explainDefault, offlinean AI evidence section in reviewer.html and an AI evidence: block in sbomflow explain <dir> --gate — the existing surfaces, not a separate dashboard. Every status is carried in words (review priority, "human review required", enforced vs informational), never by colour alone
An empty AI inventory in the reviewer surfacesDefaultone of four NAMED empty states — no record, no scan, nothing comparable, or compared-and-agreed — each with its own wording. Only the last is reassuring, and the other three never render it; explain withholds "no AI item is awaiting a human" wherever nothing was actually known
Hostile text from a scanned tree in the reviewer surfacesDefaultterminal escapes and control characters escaped and spreadsheet formula triggers neutralised at the source, so every surface inherits the treatment; HTML escaping stays at the HTML renderer. AI evidence has no CSV export today — the neutralisation is applied so a future one inherits tested behaviour, not because a CSV exists
AI release drift — a model's bytes, a declared supplier, licence, base model, dataset provenance or pinned digest, an external provider, an agent tool or permission, the lineage edges between entities, and observed-versus-declared disagreements that appeared or stopped appearingExplicit previous output (--previous-output)ai_changes in release-drift.json, rendered in release-drift.html; every record carries a stable id, before/after where disclosure is sound, the evidence source, a review-priority materiality and a plain-language explanation
Telling an AI drift of "nothing changed" apart from one of "nothing was compared"Default wherever drift is builtai_changes.state, .reason and .no_change_meaning; a previous release carrying no AI record, an unreadable one, and a run with no scan each get their own named state, and sbomflow validate fails a pack whose block cannot answer the question
An AI drift comparison where one side's tree walk was cut short by a traversal budgetDefault wherever drift is builtai_changes.inventory_completeness marks the comparison PARTIAL and every inventory row repeats it, so a truncated scan in one release cannot read as removals in the next; a record predating the traversal block is kept as null, never as "complete"
AI drift for agents, services, datasets and promptsExplicit previous output (--previous-output) — the comparison is kind-generic; static discovery supplies observed services and agents, and the dataset reader supplies both declared datasets and byte-identified data containers. Prompts remain declared-only, and the declared manifest reaches a run through --ai-manifest, so that part of the class is populated exactly when a run supplied oneai_changes in release-drift.json
Dataset declarations — identity, provenance chain, licence verbatim, transformations, and the Croissant-RAI collection, processing, annotation, use-case, limitation and distribution-restriction fieldsDefault, offline (a Croissant declaration already on disk under the scanned tree)datasets in ai-evidence.json, plus dataset entities in graph. Every value is attributed to the file and key that stated it, and every recognised field carries one of three states: present, absent (a claim not made, never a failing) or unreadable (stated and unreadable — never folded into absence)
A dataset that is referenced but not presentDefault wherever a declaration existsreferenced_not_present, its own state, with the reference recorded as the reference it is. Nothing is fetched and no URL is resolved. A declaration naming no data at all is no_distribution_declared, a different fact
Any reading of what a dataset licence permitsNever producedthe licence text is recorded exactly as written, with its source. Whether a use is within it is a legal question about the party asking, and it is answered nowhere in this tool
Data containers with no declaration beside them, identified from bytesDefault, offlineobserved_data_artifacts — parquet, arrow/feather and avro by signature, tfrecord by name alone, each carrying the identification strength a model artifact already does. Narrow by design: delimited-text and line-JSON files are not enumerated (by name they are indistinguishable from configuration and logs), and neither are the extensions the model reader already claims (.npy, .npz, .h5, .hdf5) — one file recorded as both a model and a dataset would put two contradicting entities in the graph
Opening a dataset for its contentUnsupported by designa bounded byte prefix identifies the container and the file is hashed; its rows, columns and records are never read
Evaluation evidence — an evaluation's target and its digest, the evaluator, the tool and version, the dataset, the environment, execution time, measurements, result, raw-output location and hash, stated limitations, validity period and human interpretationExplicit input (*.evaluation.json records already on disk under the scanned tree)evaluations in ai-evidence.json, plus evaluation entities and evaluates edges in graph. Every field is recorded as supplied and none is re-derived; provenance is declared, because SBOMFlow read an account of an evaluation rather than watching one happen
Running an evaluation, benchmark, probe or red-team suiteUnsupported by designSBOMFlow starts nothing, loads no model, executes nothing and contacts nothing. An engine that both produced a result and vouched for it would be the only witness to its own claim
Telling an evaluation that still describes the release apart from one that has quietly stoppedDefault wherever evaluation records existexpiry is derived, not asserted: each record binds itself to the digests of the model, prompt, toolset and environment it ran against, and those are re-checked every run. Seven named standings — current, target_not_in_release, binding_changed, unverifiable, unbound, past_validity, not_evaluated — and only current means it still applies. A record that declared no bindings can never be contradicted, so it is unbound rather than current
An evaluation result that says "we could not tell"Defaultinconclusive (it ran and did not decide) and unsupported (it could not be applied at all, so nothing was measured) are first-class results, never folded into pass or fail
Reducing a set of evaluations to one figureNever producedcounts by result, by standing and by raw-result state sit side by side and are never divided by one another
Declared vs deployed reconciliation — what the build declared against what an environment was observed running, per environment, with the collector and the instantExplicit input (*.runtime-evidence.json documents already on disk under the scanned tree)runtime_reconciliation in ai-evidence.json. Six named states, and only matches is agreement; an observation whose kind the AI graph never enumerated — an ordinary library, say — is observed_not_comparable rather than undeclared, because this comparison does not read the build's own SBOM; supplying no evidence lists every declared entity as no_runtime_evidence rather than omitting it
Monitoring, polling or querying a fleet, orchestrator, registry or endpointNever performedSBOMFlow contacts nothing. Runtime facts arrive as a file or they do not arrive, and the block states is_monitoring: false on every run
Telling a point-in-time observation apart from a live viewDefault wherever runtime evidence existsan observation without an environment, a collector or a readable instant is refused; one past the freshness horizon is reported stale and kept; per-environment coverage is complete, partial or unstated — three different things — and environments that never reported are unknown, never clean
Any assessment of a model's behaviour, quality, accuracy, bias, robustness or safety; any observation or control of a running model or agentUnsupported by designnever produced and never inferred — every fact here comes from bytes read under a size bound, or from an evaluation record recorded verbatim as somebody else's claim
Versioned evidence maps from published AI documentation sources onto the evidence a run actually produced — one binding source (the EU AI Act's Annex IV documentation elements) and four voluntary guidance sources (NIST AI RMF 1.0, the NIST Generative AI Profile, the UK NCSC secure-AI-development guidelines, and the OWASP Top 10 for LLM Applications)Defaultai-evidence-map.json, written on every run. Every mapped reference carries the source name, its official identifier and URL, its version or effective date, the exact article/annex/control reference, and the SHA-256 of the pinned extract the mapping was derived from
Separating who supplies each piece of evidence — an SBOMFlow observation, an operator declaration, a test result, or a human review — and naming what is outside the tool entirelyDefaulta provided_by and a state per expectation in ai-evidence-map.json; a declaration or review expectation can never read as observed, and references the map does not map stay listed as unmapped so the denominator is visible
Marking a mapped reference not applicablePlanned — the data model, the builder parameter and the refusal to accept a record without a rationale and an author all exist; no CLI or configuration surface supplies one yet, so no run marks anything not applicablehuman_records_supplied says so in the artifact, and the note states that the absence of a not-applicable mark never means a reference was found to apply
Any statement that a documentation duty is met, adequate, conformant or certified; any percentage, score or level derived from a mapUnsupported by designnever produced — no state in the vocabulary can express it, and the build fails closed if such wording or such a number reaches the artifact

Four boundaries hold, and they are the point of the artifact:

  • Nothing is loaded, deserialised or executed. No model runtime is imported. Every fact is derived from bytes read under a size bound, so no field describes a model's behaviour, quality or safety.
  • The artifact is written on every run, including the run that found nothing. A product with no AI in it gets a record saying so, because a file that appears only when something is found makes absence invisible. ai_scan_performed separates "no scan ran" from "the scan recognised nothing", and neither means the product contains no AI: unsupported formats, models fetched at runtime and models behind an external service are all outside what the scan can see.
  • It reaches no verdict. No score, no percentage, no conformity, safety or certification claim; a conflict is recorded for a human and never resolved.
  • Recording an agent's authority does not constrain the agent. The agent section is an inventory of what an operator declared. SBOMFlow does not observe an agent running, does not contact any declared service, MCP server or destination, and does not verify that a stated limit, approval requirement or stop mechanism exists or works. Inventorying agents does not mitigate runtime prompt injection.

Assurance passport (experimental)#

The sbomflow passport command group turns "prove your product security to procurement" from a per-deal scramble into one handoff the recipient can check themselves.

CapabilityStatusOutput
Issue a self-contained assurance passport per release (passport issue)Experimental, offlinepassport.zip — the redaction-audited customer sharing pack + a manifest naming the product/release, the issuer, a SHA-256 for every included file, and a counts-only summary of the recorded human release decisions (gate result, approvals, reviewed suppressions, waivers — never names or finding detail)
Optional Ed25519 signature over the manifestOpt-in --sign-key (the sign extra)passport.sig.json; a passport issued without a key is honestly labelled UNSIGNED — never silently treated as signed
Recipient-side offline verification (passport verify)Experimental, offlineevery attested hash re-computed from the zip or directory, the signature verified when a public key is supplied, and an explicit report of what was and was not checked; any mismatch, and any member the manifest does not attest, exits with the integrity code
Verification runs in both directionspassport verify, zip and directory alikeevery member the manifest lists must be found and match, and every member the passport contains must be listed. A file present in the passport and named by nothing in passport.json is reported as an unlisted_member under unlisted_members, counted in members.unlisted, and fails verification (exit 4, E044), in the zip and in a directory alike; so does a directory walk that stopped before it finished. A directory therefore verifies only when it holds just the passport's own members: to check a passport in the analysed output directory passport issue wrote into, verify the passport.zip beside it. Only the exact passport.json and passport.sig.json, and an exact release-record.json the manifest declares by digest, are exempt; passport.zip is the container and never a member; a name the zip repeats with different contents or with a copy that cannot be read, and names that differ only in letter case or Unicode form, are refused as ambiguous (identical repeated copies that can all be read count as one member); and a directory that cannot be listed leaves the walk unfinished. A passport re-zipped with operating-system metadata entries (for example __MACOSX/) therefore fails too: verify the zip as issued. A verifier driven by the manifest alone can only ever confirm the manifest's own claims
The passport names the release it was issued from, by digestOn every passport; checked by passport verify when the record is present, and a declared record that is present but cannot be read fails the run (exit 4)release_record_sha256 in the manifest is the SHA-256 of the release-record.json the passport was composed from. product{name, version, release_id} are manufacturer-chosen strings that can be reused, so without this a passport re-issued against a re-analysed release was indistinguishable from the first. Verification reports release_record: verified / failed / not_present / unrecorded / unchecked — three of the five mean not checked and say so, because passport.zip carries no record and a passport issued before this key existed is not thereby suspect. A link, never a verdict: nothing about the release's engineering or its conformity follows from two digests agreeing
Five member outcomes, reported as fivepassport verifymembers carries verified / changed / missing / unreadable / unlisted, each with its denominator (listed, found, examined) — because "verified" without "how many of how many" is not a verdict. A directory or a FIFO where a file is listed is unreadable, never missing, and examined_complete says whether the walk that produced the unlisted count finished
Every member read is bounded by the bound, not by the filepassport verifya member is hashed on a streamed, capped read through the shared limits contract, so the verifier's peak memory is the chunk and not the member's size; a member over max_file_bytes is refused with the limit named; a member whose manifest path is absolute or traverses, or which resolves outside the passport directory, or which is a symlink, is refused and never read — so a received passport cannot be used as a file-existence oracle for the recipient's own disk
AI evidence posture in the passportExperimental, offline, on every passporta counts-only ai_evidence block: models observed, artifacts recorded but never parsed, files identified by name alone, entities, open observed-versus-declared conflicts, whether the AI evidence was compared against the previous release and how many changes were material, and which AI gate controls the manufacturer enforced. No model path, digest, filename, supplier, licence, dataset, prompt reference or entity id ever enters a passport — those stay with the manufacturer
Telling a passport's 0 models apart from "nobody looked"On every passportscan_performed, inventory_complete and a note beside the count; a release carrying no AI evidence record at all says exactly that instead of reporting an empty inventory
The passport's own manifest is redaction-audited before it is writtenOn every passportthe same fail-closed sharing-surface audit the customer pack uses; a manifest that would leak an absolute path, an internal URL or a credential-shaped value refuses to issue and nothing is written
A redacted AI evidence pack, field by fieldOn every passport that has an AI evidence recordai-evidence-shared.json — every field of the release's internal AI evidence record resolved against a named redaction rule into one of three states: retained (disclosed), redacted (deliberately withheld, with the kind named) or unassessable (this build could not evaluate the field, so it was withheld and the omission counted). The audit row for every one of them travels in the pack
Reading the pack: withheld is never the same as absentOn every such packa field in evidence was observed and disclosed; a redacted row was observed and withheld; an unassessable row was observed and withheld because it could not be evaluated; a field with no row was not observed at all. A pack with three withheld fields therefore reads differently from one with three unobserved fields
Recipient-side verification of the redaction recordpassport verifythe pack's disclosed field paths are recomputed and every one must carry an audit row: a disclosure explained by no row is reported as an unexplained disclosure, and redaction: verified / failed / absent is printed beside the signature state. absent means the passport carries no such pack — never that its redaction checked out
Nothing withheld is recorded anywhereOn every such packno withheld value appears in the audit in any form, including hashed or truncated — a truncated digest against a small domain is reversible, so it is not a redaction. An audit row carries a schema path, a rule, a category and a count, and the path itself is spelled only in this build's own vocabulary so an unrecognised key name cannot ride into the audit as a label

An unassessable field is the state that matters. A redaction which fails silently is worse than no redaction, because the operator ships the export believing it is safe. So a field this build does not recognise — a section a later release added, or a known field carrying a type its rule was not written for — is withheld rather than let through, a redaction_field_unassessable warning says so, and the pack is honestly incomplete by exactly that many fields. The omission is never an absence of the thing.

A passport is an externally-received artifact, so its zip is opened defensively: every member is read under a hard decompression bound, and a member whose header lies about its size (or is an outright zip bomb) is refused with a visible warning naming the member and the limit rather than being allowed to exhaust memory during verification. A member with a suspicious (absolute or path-traversing) name is skipped and surfaced the same way — never silently. A well-formed passport verifies exactly as it did before, byte for byte.

A passport proves issuance, integrity, and that human decisions were recorded — verified by the recipient, offline. It is never a conformity claim, a certification, or a security guarantee.

Passport manifests read the canonical approval_status.approval_count emitted by the release gate. Legacy approved_count input remains readable, but a file carrying disagreeing canonical and legacy counts is refused. Issued passports remain immutable historical artifacts: re-run passport issue when a corrected approval summary is needed; SBOMFlow never edits or silently replaces a signed passport.

Runtime and test evidence custody (experimental)#

A trace, a core dump, a coverage tracefile or a static-analysis report is evidence about a release that no SBOM describes. ingest-preserved-artifact and the sbomflow custody group keep such a file with a record of what it is, who captured it and under which release, and prove later that the bytes have not changed. It is experimental: the record shapes may change without notice. The guide is Keeping runtime evidence in custody.

Nothing here decides anything. Every record carries affects_release_gate: false, no preserved artifact sets or suppresses a release gate, and acceptance is a named human decision recorded separately. A digest proves byte identity against the record; it does not prove who produced the file or that any capture came from a given image.

CapabilityStatusOutput
Preserve one file with its own record — declared release, scenario, tool, operator and capture date (ingest-preserved-artifact)Experimental, offlinepreserved-artifacts.json rows keyed by a review key over the artifact's SHA-256
Standalone custody sets separate from a run (custody init, add, list, show)Experimental, offlinecustody-set.json plus an append-only, hash-chained audit-log.jsonl
Re-hash every record and located original, and tell a removed row from a rewritten one (custody verify)Experimental, offlineper-row record and original states, with the seal recomputed over the rows
Locate an original by content rather than by name (--originals)Experimental, offlinea verified original, or missing/unreadable/mismatch with the reason as a fact about the bytes
Package a set for a customer or auditor, and compare two sets or exports (custody export, custody compare)Experimental, offlinecustody-export.json with a member manifest; a comparison that names what moved
A named reviewer's decision about one artifact, bound to its bytes by review key (custody review)Experimental, human actionpreserved-artifact-reviews.json; an observed state is never a review state
Corroborate a declared firmware image against the scanned tree by contentExperimental, offlinethe image is recorded as belonging to the release; it is not evidence the capture came from that image
Read identity an open-format original declares about itself, under a cited published specification — or, for a format defined only by open-source code (a minidump's constants, a TraceRecorder streaming trace's header, a ThreadX trace buffer's header, a Perfetto trace's first packets), under that code pinned at one commit with its digestExperimental, offlinefile_declared_identity on the row: each value with origin extracted, the citation (a source-code basis says basis: source code at <commit>), what was withheld and why
Compare a report's declared source revision with the one the release declaresExperimental, offlineconsistent, inconsistent or not_declared — an observation about two statements, never a verdict on which is right
Hold an LCOV tracefile's declared source paths against the release's own sourcesExperimental, offlinecounted: how many declared paths are among the source paths this run's own scan observed (observed_source_paths in preserved-artifacts.json), beside both totals, with the paths not among them listed; not_established with its reason when the set is not complete or the read is not part of a release run. Correspondence only: no figure is computed from these counts or from its hit or found counts, and its VER: source versions are compared like a report's revision
A proprietary trace, dump or export format, or one whose specification is paid or licensedRecognized, not parsedthe file is kept, hashed and recorded; its own claims about itself are deliberately not read
An event, sample or packet body inside a preserved fileNot read at allno finding is derived from a preserved file's content; the readers above stop at the header

Post-market case workflow (experimental)#

The sbomflow case command group records what a product-security team did when a new advisory threatened a shipped product. It is experimental: the record shapes may change without notice, and every surface is local, offline, and human-decided.

CapabilityStatusOutput
Open a PSIRT case with an append-only, hash-chained event journal (case open)Experimental, offlinecase-journal.jsonl — tamper-evident; every consequential event names a human actor
Intake a local advisory/report file with full source provenance and duplicate detection (case intake)Experimental, offlineprovenanced report records; the same advisory under a CVE or GHSA alias is ONE identity — a re-import is a detected duplicate, never re-created and never auto-merged
Correlate advisories against the evidence store into exposure candidates (case correlate)Experimental, offlineexposure-candidates.json + reviewable assessments — computed from frozen release records by the real matcher; a candidate is never an "affected" assertion, and an unorderable version stays a flagged low-confidence candidate
Record why each candidate was proposed and what its match did not settleExperimental, offlineevery row carries the identity that matched, the matcher path, the stored release record and evidence-pack digest it came from, and a named not_established list — an unorderable version range and an identity with no package coordinate are weak for different reasons and are said so, never folded into a confidence word
State what the candidate list did not cover (case correlate, case show)Experimental, offlinea scope statement naming every stored release that could not be examined and why (not copied, absent, unreadable, malformed, not an evidence pack, no usable component); zero candidates over an unexamined release never renders like zero candidates over a clean store, and a record that states no scope reads as not_recorded, never as complete
Scope exposure candidates by declared release variant/channel (case correlate --variant) and aggregate operator-supplied fielded-population records from psirt-case.json in the case directoryExperimental, offlinecandidate rows retain variant, channel, and build id; per-variant rollups state population completeness as complete, partial, or unknown — missing inventory never means zero or safe, an inventory over the case-file entry cap is reported as truncated and partial, and a case file that is present and unreadable — an unreadable path, a link that resolves to nothing, or records the loader refused — is refused ([E015]/[E016]) rather than recorded as no inventory supplied
Assign the case and record human triage outcomes (case assign, case triage)Experimental, offline, human actionsevidence-backed assessments — not_affected requires a valid CISA justification or the outcome is downgraded to needs_info, loudly
Deterministic case state machine with guarded transitions (case transition, case reopen, case show)Experimental, offlinean unassigned case, unreviewed candidates, or an open needs_info assessment block advancement/closure; material advisory deltas only prompt a named human to reopen and re-correlate, never auto-reopen; case show re-verifies the hash chain
Verify remediation against a frozen fixing release (case remediate)Experimental, offlineactor-attributed remediation event with finding_key, fixing-release identity, and verified_absent / removal_unverified; unverified removal and uncovered affected cohorts block closure unless a named actor records a substantive override reason
Record a time-boxed risk acceptance (case accept-risk)Experimental, offline, human actionappend-only decision with evidence, reason, expiry, and guarded renewal; expiry is surfaced and never auto-extended
Draft, approve, and record externally sent communications (case communicate)Experimental, offline, human actioncommunications.json; drafts cannot be marked sent, approval is exactly once, and a sent record requires the external channel reference — SBOMFlow sends nothing
Record disclosure events and export an advisory draft (case disclose, case export-advisory)Experimental, offlinehash-chained timeline events and case-advisory.DRAFT.json; the CSAF document is watermarked UNSIGNED DRAFT, records submitted: false, and is never filed or published
Record a reviewer's advisory status and write a local CSAF directory (case export-advisory --document-status, case advisory-tree)Experimental, offlinecase-advisory.<status>.json with an advisory_status_recorded journal event for its exact sha256, and a directory laid out as CSAF 2.0 section 7.1 describes (final TLP:WHITE documents only); generated locally for the manufacturer to review and publish, never signed, hosted, uploaded or published
Read the coordinated-disclosure record for a case (case show)Experimental, offlineone row per disclosure stage in disclosure_record, each recorded_occurred, recorded_did_not_occur (a named human said so, with their reason, via case disclose --not-occurred), or not_recorded. not_recorded means only that nobody recorded it — never "not required", never satisfied, never late. No elapsed time, remaining time, or deadline is computed: the applicable windows are the manufacturer's legal determination. A recorded time is played back exactly as written with how it reads beside it (readable, absent, unreadable, interpreter_divergent) and is never repaired into a plausible instant; rows keep journal order, never timestamp order
Propose family propagation (case propose-propagation)Experimental, offline, proposal onlypropagation-proposals.json with applied: false; a proposal changes no product or case decision

No case record concludes a legal reporting obligation, nothing is disclosed or filed by SBOMFlow, and none of this is a conformity claim.

Canonical estate-wide advisory re-evaluation#

Use case correlate when a new advisory must be checked against every indexed release in the local evidence store. It is the canonical estate-wide path:

  1. Open a case and intake the local advisory artifact.
  2. Run sbomflow case correlate with that advisory and the evidence-store path.
  3. Review exposure-candidates.json and the case journal before recording any human triage outcome.

The command is offline and observation-only. It matches the advisory against frozen release records and emits candidates; it never promotes a candidate to affected, invents an exposure decision, or auto-pulls advisory data. Use sbomflow recheck only for the narrower operation of re-matching one frozen output directory against a supplied snapshot.

Newly KEV-listed identifiers across stored releases#

CapabilityStatusOutput
Join the identifiers a newer KEV snapshot adds to every stored release (store exploitation-delta)Experimental, offlinea delta document at --output: the two snapshots (read from the local vulndb store --vulndb-dir names — none is guessed — by id and content digest, each with its catalogue's own dateReleased), added_ids and removed_ids, and one exposure row per added identifier and stored release whose frozen evidence records it as a finding id, an alias or a recorded CPE candidate, each with the release's support state at the newer catalogue's dateReleased — past-support releases are listed, never filtered
State what the delta did not readExperimental, offlineexamined_releases and unexaminable_releases are the denominators; a release that could not be read is named with its reason (the same reasons case correlate uses) and never counted as a release with zero exposure

The delta records no time at which anyone became aware of anything, opens no case, and changes no finding, VEX status, decision or gate. It is not a reporting determination: a named human decides what follows from it, and the document refuses wording that would say otherwise. Two snapshots named the wrong way round (by their catalogues' dateReleased) are refused with exploitation_delta_snapshot_order.

Provenance and integrity#

CapabilityStatusOutput
Operator signature trust policy — accepted signer identities and labels, issuers, validity periods, required subject digests, signature thresholds, offline trust roots, and explicit distrust decisionsExplicit input (a local policy file)a trust policy the authority layer evaluates; strict loading refuses an unknown field, a malformed digest, an unreadable validity window or an unexplained distrust, each naming its JSON pointer
Supplier evidence package lifecycle — a versioned package format (supplier identity, scope, artifact checklist, validity date, supersedes reference) with quarantine → validate → human acceptShipped, not reachablea per-package state from a closed set, each carrying the action it asks of a human — in the library, tested, and not yet read by any command, flag or configuration key (see below). Nothing is accepted automatically: validated means well-formed, complete and current — never approved — and only a recorded human decision makes a package's evidence usable
An accepted supplier package whose facts later changeDefaultexpiry, being superseded, and failing its own checklist each outrank a recorded acceptance, so a package that expired after it was accepted stops reading as current rather than staying green
A supplier resending the same evidence under a new package idDefaultreported as duplicate naming the original, rather than silently ingested twice or silently dropped; the content digest deliberately excludes the id and the received date so a resend is detectable
The original supplier document behind any normalisationDefaultoriginal_sha256 is recorded beside the normalised digest, so a reviewer told "your document became this" can prove what actually arrived
Telling apart the ways a valid signature is still not authorizedDefault wherever a policy is supplieda distinct named outcome per cause — signer_unknown, signer_distrusted, signer_expired, signer_not_yet_valid, issuer_mismatch, subject_digest_unexpected, threshold_not_met, threshold_unsatisfiable — each carrying the action it asks of a human. They previously shared one signer_not_authorized, which cannot tell "add the signer" from "re-sign" from "treat this as suspect"
A policy that can never be satisfiedDefaultreported as threshold_unsatisfiable — a policy defect, not evidence against the signer; this build writes one signature per envelope, so a threshold above that is named rather than read as distrust
SHA-256 for every scanned file, and source hashes on every contributing observationDefaultartifact-manifest.json
Symlink boundary — paths resolving outside the scan root are refused with a warningDefaultsymlink_outside_root warning
Resource bounds on untrusted input (size, depth, archive members, decompression)Defaultresource_limit_exceeded warnings instead of unbounded reads
Deterministic outputs under a fixed --as-ofDefaultbyte-identical artifacts across repeat runs
Structural validation of every output directory, including cross-artifact consistency and audit-chain verificationDefault commandsbomflow validate (exit 4 on failure)
Official JSON-Schema validation of generated SBOMsOptional extra (jsonschema) or external CycloneDX CLIvalidation notes/errors
Local build-attestation (DSSE) verificationOptional extraprovenance verification records
Optional local analyzers — syft, diffoscope, cosign, firmware extractionExternal tooladapter evidence with sanitized, bounded execution
Sigstore/cosign bundle structure recorded offline — certificate, signature kind, Rekor entries, inclusion-proof presenceDefault command (sbomflow cosign-verify; no external tool required for this part)bundle block in cosign-verification.json; presence is recorded, never verified

Shipped, but not reachable#

Five pieces of the release are library foundations. Their code is in the release, they are tested, and their warning codes appear in the published warning catalogue — and no operator path reaches any of them. They are listed here so that a catalogue entry is never mistaken for availability:

  • AI feature traceability
  • upstream AI lifecycle events
  • the framework-adapter contract
  • training-run receipts
  • the supplier evidence package lifecycle (listed in the table above as shipped, not reachable)

SBOM concordance shipped this way in v0.5.0. From v0.5.5 it is reachable: sbomflow sbom-set compare explains where two or more SBOMs for the same build agree and differ.

One nuance is worth keeping. The framework-adapter matrix is a generated document you can read; the adapter registry behind it is not wired into a run, and the coverage states on that page are not emitted by any command. One adapter is defined. That page says so in its own words, and this page does not upgrade it.

Dataset evidence, prompt artifacts and redaction-audited AI evidence sharing are planned — nothing is built. AI runtime assurance is design-partner gated and incomplete; it is not what this product does today.

What SBOMFlow deliberately does not do#

  • It does not claim or certify legal conformity, and it never says "compliant".
  • It does not sign, file, transmit, or submit regulatory reports.
  • It does not turn machine observations, supplier statements, KEV/EPSS, or reachability into approvals, VEX statuses, or release decisions.
  • It does not upload your source, firmware, or evidence anywhere by default — there is no telemetry.
  • It does not guess: missing versions, licenses, or origins stay missing and are surfaced, not invented.
  • It does not parse every proprietary format; recognized-but-unsupported inputs are hashed and warned so you can route them to review.
  • It does not run, execute, load, evaluate or score a model, and it does not observe the runtime behaviour of a model or an agent.
  • It does not certify an AI system or decide whether one is safe or compliant.
  • It does not resolve a declared-versus-observed conflict automatically. The conflict is surfaced for a person; nothing picks a winner.

Next: what a run produces, or how we test all of this.