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. |
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 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.
| 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 |
| 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 |
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#
| Capability | Status | Output |
|---|---|---|
Yocto image manifests and license.manifest (including under build/tmp/deploy/…) | Default | components with declared licenses and build provenance |
Buildroot legal-info manifests, host/target separation preserved | Default | components with source provenance |
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 the opt-in OCI evidence path |
| OCI layout evidence import (manifests, platform variants, attached referrer artifacts) | Opt-in flag | OCI 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 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 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 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/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 |
| 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) | Opt-in flag | VEX statements; not_affected requires a valid justification or it is downgraded |
| Supplier VEX ingestion (OpenVEX or CSAF) as context | Explicit input | supplier statements kept separate — they never set your status automatically |
| Suggested SSVC priority from the CISA decision table | Opt-in flag | ssvc.json — a suggestion for humans, never a gate or a status |
CRA-oriented evidence#
| 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. 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 | |||
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 |
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), 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 |
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 |
| 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, 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 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 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 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 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 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 (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 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. 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 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 |
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_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 exits with the integrity code |
| 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.
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 | 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 |
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 |
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.
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 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 accept | Explicit 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 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 |
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.