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#
| Status | Meaning |
|---|---|
| Default | Runs on every analyze/audit with no flags and no network. |
| Explicit input | Runs offline when you point SBOMFlow at a local file (for example a supplier SBOM or a vulnerability snapshot). |
| Opt-in flag | Off until you pass the documented flag; still offline unless stated. |
| Opt-in network | Contacts a service only under an explicit flag such as --use-osv. Never on by default. |
| Optional extra | Needs an optional Python extra (for example PyYAML or jsonschema). Baseline operation never requires it. |
| External tool | Uses a locally installed third-party tool when present; SBOMFlow works without it and says so. |
| Recognized, not parsed | The input is detected, hashed, and surfaced with a stable warning instead of being interpreted. |
| Shipped, not reachable | The 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 tounreviewed; 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.
| Capability | Status | Output |
|---|---|---|
Source manifests — requirements.txt, package.json, Cargo.toml, go.mod, Dockerfiles, firmware manifests | Default | components 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.snapshot | Default | resolved 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.toml | Recognized, not parsed | stable 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 version | Default | the 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 components | Default | recorded 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 requirements | Default | unpinned_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 preserved | Default | purls 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_scope | Default (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 inventory | Default | malformed_component_* warnings |
| Dependency graphs — deterministic edges, deduplication, workspace/git/file dependencies, legitimate cycles kept | Default | dependencies 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#
| Capability | Status | Output |
|---|---|---|
Yocto image manifests and license.manifest (including under build/tmp/deploy/…) | Default | components 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 name | Default | from 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 preserved | Default | components with source provenance |
Build-host-only components — Buildroot host-manifest.csv packages and Yocto -native/-nativesdk/-cross* recipes | Default | the 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 key | CycloneDX 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) | Default | module components |
Container image tarballs and extracted rootfs package databases — dpkg, Alpine apk, SQLite RPM — with observed os-release vendor context | Default | OS-package components with distro/arch qualifiers |
| OCI image layout directories and image indexes | Recognized, not parsed | oci_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 flag | OCI 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 it | Opt-in config key | container_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 binaries | Opt-in config key | pkg: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 key | covered-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 date | Opt-in config key | component-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 offline | Opt-in config key | component-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 input | imported 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 handled | Default | base-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) | Default | pkg: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) | Default | pkg: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) | Default | one exact pkg:generic component per vendored library, with declared license |
| Device-tree source overlays | Recognized, not parsed | device_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 input | dedicated evidence artifacts per importer |
Vulnerability and exploitation context#
| Capability | Status | Output |
|---|---|---|
| Offline advisory matching against a bundled, clearly-labelled non-real sample feed | Default | findings marked with their provenance |
| Local vulnerability-database snapshots | Explicit input | findings with snapshot provenance and freshness |
| OSV and NVD enrichment | Opt-in network | findings with source URLs and CVSS context |
| CPE candidates for embedded C components (experimental) | Opt-in config (scan.cpe_candidates), offline | cpe-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 documents | device-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 EPSS | Explicit input or opt-in network | known-exploited flags and exploit-probability scores — inputs for human triage, never verdicts |
| CVSS v3.x base-score computation from vectors | Default | derived severities (never invented) |
| Conservative reachability observations for Python, JavaScript/TypeScript, Go, Rust, C/C++ | Default | reachability.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 context | Explicit input | supplier 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 table | Opt-in flag | ssvc.json — a suggestion for humans, never a gate or a status |
Post-release re-evaluation of a shipped release (sbomflow recheck) | Opt-in command, offline | a 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.
| Capability | Status | Output | |||
|---|---|---|---|---|---|
| Neutral regime views (experimental) | Opt-in command | regime-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 init | validate | inspect | export; 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 command | sbomflow 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 mapping | Default | cra-coverage.json; manual-only areas are labelled, not failed | |||
| Commission guidance edition crosswalk (C(2026) 5252) | Default | cra-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 areas | Explicit input | hashed inputs surfaced in coverage — never marked adequate or accepted by the engine | |||
| Annex VII technical-documentation pack, including an UNSIGNED DRAFT declaration | Opt-in flag | techdoc pack; the draft watermark is validator-enforced | |||
| Article 14 incident-report drafts (early warning, notification, final) | Opt-in flag | cra-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#
| Capability | Status | Output |
|---|---|---|
Release gate — informational by default, blocking only under explicit --fail-on-* policies, VEX-aware, with the exact reason recorded | Default | release-gate.json and a stable exit-code contract |
| Named, versioned policy profiles | Opt-in flag | recorded 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 notes | Default for every shipped preset | the 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 have | Default, checked on every load | a 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 documented | Default | undocumented_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 standard | Default, checked on every load | matched 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 documents | Default | every 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 expression | Opt-in policy | license_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 output | release-record.json, release-drift.json, local release index |
| Review queue with tamper-evident decisions | Default artifacts, human actions | reviews.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 revocation | Opt-in commands | approval records tied to exact artifact digests |
| Time-boxed gap/finding waivers | Opt-in commands | waiver records that keep waived items visible |
Domain-aware merge of two reviewers' decision files (sbomflow review-merge) | Opt-in command, offline | merged 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 exports | Default / opt-in | issues.json, issues.md, CSV and SARIF exports |
| Tracker sync — GitHub Issues, Jira, ServiceNow, Dependency-Track | Opt-in; dry-run by default, network only with --apply and credentials | idempotent sync plans, then deduplicated updates |
| Notifications — Slack, Teams, webhook, email | Opt-in --apply | messages carrying counts and links, never secrets |
| Evidence bundle and auditor handoff | Default with audit; ZIP opt-in | evidence-bundle.json/html/zip — byte-reproducible ZIP |
in-toto attestation over the evidence bundle (sign-bundle --format in-toto) | Opt-in flag; optional sign extra | evidence-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 flag | sharing pack with a documented allowlist |
Sanitized support/debug bundle (sbomflow support-bundle) | Default, offline, opt-in write | tool 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, offline | metrics.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 receipt | Default, offline | run-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, Jenkins | Documented | copy-paste workflows using the stable exit codes |
Generated GitHub Actions workflow (sbomflow quickstart <dir> --ci github) | Default, offline | writes .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-only | GitHub 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.
| Capability | Status | Output |
|---|---|---|
| Model artifacts identified from bytes — GGUF and TFLite by documented magic number, SafeTensors and ONNX by documented structural invariant | Default | observed_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 one | Recognized, not parsed | the 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 alike | Recognized, never opened | a 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) | Default | both facts recorded, the bytes decide the format, and ai_model_extension_signature_mismatch is raised |
| A file identified only by its name | Default | kept 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 number | Recognized, never opened | recorded 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 locations | Default | graph in ai-evidence.json |
| Observed-versus-declared reconciliation | Default | reconciliation in ai-evidence.json; with no declared manifest supplied it honestly reports that nothing was compared |
| A declared AI manifest as a run input | Default, 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 schema | Default | four 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 mechanism | Default — the declaration reaches a run through --ai-manifest | agents 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 surface | Default | agent_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 trustworthiness | Never produced | neither 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 names | Default | discovery 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 reads | Never recorded | only 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 names | Default | mcp_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 description | Unsupported by design | no 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 provider | Unsupported by design | never — 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 examined | Default | traversal 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 root | Default | a 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-BOM | Opt-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 profiles | Opt-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-BOMs | carried 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 enforced | GATE_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 explain | Default, offline | an 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 surfaces | Default | one 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 surfaces | Default | terminal 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 appearing | Explicit 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 built | ai_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 budget | Default wherever drift is built | ai_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 prompts | Explicit 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 one | ai_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 fields | Default, 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 present | Default wherever a declaration exists | referenced_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 permits | Never produced | the 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 bytes | Default, offline | observed_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 content | Unsupported by design | a 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 interpretation | Explicit 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 suite | Unsupported by design | SBOMFlow 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 stopped | Default wherever evaluation records exist | expiry 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" | Default | inconclusive (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 figure | Never produced | counts 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 instant | Explicit 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 endpoint | Never performed | SBOMFlow 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 view | Default wherever runtime evidence exists | an 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 agent | Unsupported by design | never 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) | Default | ai-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 entirely | Default | a 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 applicable | Planned — 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 applicable | human_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 map | Unsupported by design | never 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_performedseparates "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.
| Capability | Status | Output |
|---|---|---|
Issue a self-contained assurance passport per release (passport issue) | Experimental, offline | passport.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 manifest | Opt-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, offline | every 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 directions | passport verify, zip and directory alike | every 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 digest | On 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 five | passport verify | members 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 file | passport verify | a 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 passport | Experimental, offline, on every passport | a 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 passport | scan_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 written | On every passport | the 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 field | On every passport that has an AI evidence record | ai-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 absent | On every such pack | a 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 record | passport verify | the 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 anywhere | On every such pack | no 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.
| Capability | Status | Output |
|---|---|---|
Preserve one file with its own record — declared release, scenario, tool, operator and capture date (ingest-preserved-artifact) | Experimental, offline | preserved-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, offline | custody-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, offline | per-row record and original states, with the seal recomputed over the rows |
Locate an original by content rather than by name (--originals) | Experimental, offline | a 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, offline | custody-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 action | preserved-artifact-reviews.json; an observed state is never a review state |
| Corroborate a declared firmware image against the scanned tree by content | Experimental, offline | the 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 digest | Experimental, offline | file_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 declares | Experimental, offline | consistent, 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 sources | Experimental, offline | counted: 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 licensed | Recognized, not parsed | the file is kept, hashed and recorded; its own claims about itself are deliberately not read |
| An event, sample or packet body inside a preserved file | Not read at all | no 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.
| Capability | Status | Output |
|---|---|---|
Open a PSIRT case with an append-only, hash-chained event journal (case open) | Experimental, offline | case-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, offline | provenanced 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, offline | exposure-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 settle | Experimental, offline | every 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, offline | a 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 directory | Experimental, offline | candidate 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 actions | evidence-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, offline | an 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, offline | actor-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 action | append-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 action | communications.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, offline | hash-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, offline | case-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, offline | one 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 only | propagation-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:
- Open a case and intake the local advisory artifact.
- Run
sbomflow case correlatewith that advisory and the evidence-store path. - Review
exposure-candidates.jsonand 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#
| Capability | Status | Output |
|---|---|---|
Join the identifiers a newer KEV snapshot adds to every stored release (store exploitation-delta) | Experimental, offline | a 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 read | Experimental, offline | examined_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#
| Capability | Status | Output |
|---|---|---|
| Operator signature trust policy — accepted signer identities and labels, issuers, validity periods, required subject digests, signature thresholds, offline trust roots, and explicit distrust decisions | Explicit 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 accept | Shipped, not reachable | a 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 change | Default | expiry, 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 id | Default | reported 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 normalisation | Default | original_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 authorized | Default wherever a policy is supplied | a 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 satisfied | Default | reported 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 observation | Default | artifact-manifest.json |
| Symlink boundary — paths resolving outside the scan root are refused with a warning | Default | symlink_outside_root warning |
| Resource bounds on untrusted input (size, depth, archive members, decompression) | Default | resource_limit_exceeded warnings instead of unbounded reads |
Deterministic outputs under a fixed --as-of | Default | byte-identical artifacts across repeat runs |
| Structural validation of every output directory, including cross-artifact consistency and audit-chain verification | Default command | sbomflow validate (exit 4 on failure) |
| Official JSON-Schema validation of generated SBOMs | Optional extra (jsonschema) or external CycloneDX CLI | validation notes/errors |
| Local build-attestation (DSSE) verification | Optional extra | provenance verification records |
| Optional local analyzers — syft, diffoscope, cosign, firmware extraction | External tool | adapter evidence with sanitized, bounded execution |
| Sigstore/cosign bundle structure recorded offline — certificate, signature kind, Rekor entries, inclusion-proof presence | Default 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.