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.

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 under integrations/. 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
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

The lockfile parser contract is regression-tested by a versioned 76-case corpus: at least three adversarial fixtures for each recognized lockfile name, 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
Buildroot legal-info manifests, host/target separation preservedDefaultcomponents with source provenance
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 the opt-in OCI evidence path
OCI layout evidence import (manifests, platform variants, attached referrer artifacts)Opt-in flagOCI evidence records with verified blob digests
Imported supplier SBOMs — CycloneDX 1.4 / 1.5 / 1.6 / 1.7, SPDX 2.x, and SPDX 3.0.1 JSON-LD (software profile: packages and dependsOn relationships), with per-version component and dependency parity (the composition core; constructs like compositions/formulation are not folded into it)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 1.7 fields SBOMFlow does not model are named in a warning rather than dropped — an import is not a lossless conversion. 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/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
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)Opt-in flagVEX statements; not_affected requires a valid justification or it is downgraded
Supplier VEX ingestion (OpenVEX or CSAF) as contextExplicit inputsupplier statements kept separate — they never set your status automatically
Suggested SSVC priority from the CISA decision tableOpt-in flagssvc.json — a suggestion for humans, never a gate or a status

CRA-oriented evidence#

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. 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
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

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), 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
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
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, told apart by the exception the bounded read raised rather than by an existence check, 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 passes one screen at that serialisation boundary — not at each field, so a field added later is covered without its author knowing the screen exists — 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 package imports no network transport at all, and the architecture guard enforces that for the whole area
How much of the release tree the walk actually examinedDefaulttraversal in ai-evidence.jsoncomplete, 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 (ai/review.py), 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_artifactsparquet, 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. Five named states, and only matches is agreement; 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

Three 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 exits with the integrity code
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.

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 recordsExperimental, 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
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
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.

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 TrustPolicy 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 acceptExplicit input (packages on disk)a per-package state from a closed set, each carrying the action it asks of a human. 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 causesigner_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

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.

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