Operator CI runbook
Everything a platform engineer needs to run SBOMFlow in continuous integration — end to end, offline by default, and driven entirely by exit codes so a pipeline can act on the result without parsing output. GitHub Actions is shown first (with a native composite action and PR annotations); GitLab and Jenkins are covered too, and any CI that can run a Python CLI can run SBOMFlow.
Everything here uses shipped, offline behaviour. A default run makes zero network requests; enabling a real vulnerability source is an explicit opt-in (see Offline vs network in CI).
The 60-second version#
Add one job that installs SBOMFlow, runs an offline audit with the gate you want to enforce, annotates the PR, and uploads the evidence bundle:
python -m pip install .
sbomflow audit . --output evidence --zip --fail-on-vulnerabilities --severity-threshold high
sbomflow annotate evidence --format github # or: --format gitlab- The
auditstep exits non-zero (1) if the gate you chose blocks — that is what fails the build. With no--fail-on-*flag the gate is informational and the step exits0. annotateis output-only and offline — it turnsrelease-gate.json+issues.jsoninto native CI annotations and should run even when the audit step failed, so reviewers see why on the PR.
Ready-to-copy examples ship with your access: github-actions-sbomflow-audit.yml, gitlab-ci-sbomflow-audit.yml, and Jenkinsfile-sbomflow-audit.
GitHub Actions, step by step#
name: SBOMFlow Offline Release Gate
on:
workflow_dispatch:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
sbomflow-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install SBOMFlow
run: python -m pip install .
- name: Run offline release gate
run: sbomflow audit . --output evidence --zip --fail-on-gaps --fail-on-vulnerabilities --severity-threshold high
- name: Annotate the pull request with the gate verdict
if: always()
run: sbomflow annotate evidence --format github
- name: Upload reviewer evidence bundle
uses: actions/upload-artifact@v7
if: always()
with:
name: sbomflow-evidence-bundle
path: evidence/evidence-bundle.*What each step does:
- checkout / setup-python — standard. SBOMFlow supports CPython 3.11–3.14 and needs no third-party runtime dependencies.
- install —
pip install .from the repo, or install the published wheel. - audit — the deterministic pipeline.
--zipalso writesevidence/evidence-bundle.zip;--fail-on-*selects the enforced gate;--severity-thresholdsets the level at/above which findings count. - annotate (
if: always()) — runs even after a blocked gate so the reason appears inline on the PR. Offline and output-only. - upload-artifact (
if: always()) — keeps the reviewer bundle regardless of the gate result; you never lose the evidence.
Supply-chain hygiene: the example pins actions to major version tags for readability. For production, pin third-party actions to a full commit SHA.
Native composite action#
A composite GitHub Action (action.yml) also ships with your access, with typed inputs (target, output, use-osv, fail-on-gaps, fail-on-vulnerabilities, fail-on-unreviewed, severity-threshold, …). Use it instead of hand-writing steps when you want a smaller, declarative job.
GitLab CI#
Use gitlab-ci-sbomflow-audit.yml. The audit step is identical; for merge-request feedback, emit a Code Quality report:
sbomflow audit . --output evidence --zip --fail-on-vulnerabilities --severity-threshold high
sbomflow annotate evidence --format gitlab > gl-code-quality.jsonThen publish gl-code-quality.json as a codequality report artifact — GitLab renders the findings directly on the merge request.
Jenkins#
Use Jenkinsfile-sbomflow-audit. Run the same audit command in a stage; a non-zero exit fails the stage (that is the gate). Archive evidence/evidence-bundle.* as build artifacts. Jenkins has no native SBOMFlow annotation format — rely on the exit code and the archived bundle.
Choosing a gate policy#
The gate is informational until you enable a policy, and every enforced block records exactly which finding/gap IDs are responsible. Pick the --fail-on-* flags that match your risk bar:
| Flag | Blocks the build when… |
|---|---|
--fail-on-vulnerabilities | any advisory finding at/above the threshold |
--fail-on-kev | a finding is on the CISA Known Exploited Vulnerabilities list |
--fail-on-gaps | a required evidence item is missing at/above the threshold |
--fail-on-unreviewed | observed evidence has no human review yet |
--fail-on-new-vulnerabilities / --fail-on-new-kev / --fail-on-new-critical-or-high | a new issue appeared vs the previous release |
--fail-on-evidence-regression | a previously-resolved finding reappeared |
--fail-on-reachable-vulnerabilities / --fail-on-new-reachable-vulnerabilities | a finding has reachability context |
--fail-on-denied-license | a component matches a denied-license policy |
--fail-on-support-period-missing | the declared support period is absent |
--fail-on-missing-approvals | required sign-off has not been recorded |
fail_on_approval_binding_mismatch (policy file only) | a recorded sign-off was granted against a different evidence pack or gate policy, or records no binding at all |
--severity-threshold {low,medium,high,critical} sets the level for the severity-based flags. Reviewed VEX (fixed / valid not_affected) and unexpired waivers suppress a block — machine observations alone never do. A finding whose severity could not be determined at all is not below your threshold: it is a finding that could not be compared to it, and it blocks separately (see per-finding data below).
What each policy needs before its verdict means anything#
A gate that counts KEV-flagged findings without ever loading the KEV catalogue finds none — and a naive gate would call that a pass. It is not: it is an unasked question. Every enforced policy declares the data it depends on, and if that data is absent the policy cannot pass. It becomes indeterminate: no verdict (policy_outcomes[].outcome = not_evaluated), a policy_data_unavailable violation (GATE_DATA_UNAVAILABLE) naming the policy and the missing input, and exit 1. Run sbomflow explain <dir> --gate to see exactly which input was missing.
| Flag | Requires | Passes vacuously without it? |
|---|---|---|
--fail-on-vulnerabilities | a real advisory source | was: "no findings ≥ threshold" — now blocks |
--fail-on-kev | a real advisory source + the CISA KEV catalogue | was: "nothing known-exploited" — now blocks |
--epss-threshold | a real advisory source + the FIRST EPSS model | was: "nothing scores above X" — now blocks |
--fail-on-reachable-vulnerabilities | a real advisory source + reachability | was: "nothing reachable" — now blocks |
--fail-on-new-vulnerabilities / --fail-on-new-critical-or-high | a real advisory source + a previous release | now blocks |
--fail-on-new-kev | a real advisory source, the KEV catalogue + a previous release | now blocks |
--fail-on-new-reachable-vulnerabilities | a real advisory source, reachability + a previous release | now blocks |
--fail-on-denied-license | a non-empty deny list + at least one component declaring a license | was: "nothing matched" — now blocks |
--fail-on-missing-approvals | at least one required_approval_role or a min_approvals quorum | was: "no sign-off missing" — now blocks |
--fail-on-evidence-regression / --fail-on-support-period-missing | a previous release | already blocked (GATE_DRIFT_UNAVAILABLE) |
--fail-on-gaps / --fail-on-unreviewed | nothing beyond the local scan, which always runs | no — these were always safe |
Retrying a decision from a harness: --idempotency-key is per output directory#
--idempotency-key makes a retried review or approve converge on ONE operation instead of recording a second. Convergence is scoped to a single output directory. The key maps to a target-independent operation id, but the journal that remembers "this operation already ran" lives beside the evidence pack it wrote — so the same key replayed against a different output directory is a separate operation, not a retry, and no convergence guarantee applies.
That is the right scope for a retry (a decision belongs to the release it was recorded against), but it is worth knowing before an automation harness reuses one stable key — say a build number — across every product in an estate. Each output directory needs its own key space, or the key buys nothing across them.
A "real advisory source" means OSV, NVD, an --osv-snapshot, or a vulndb store. The advisory feed SBOMFlow bundles by default is a demo fixture: every id it emits is labelled CVE-SAMPLE-. It is illustrative data, not intelligence about your product, so a green* vulnerability gate over it would assert something the run never checked. Configure a real source before enforcing any vulnerability policy.
…and what each policy needs per finding#
The rule above is about a source that never loaded. The same rule applies one level down, to a source that loaded and then told you nothing usable about a particular finding.
An unknown severity is not a low severity. The scale runs none < low < medium < high < critical. none is a determined answer — zero impact. unknown is the absence of an answer: no source gave a rankable band (no CVSS vector, no advisory severity). They are not the same, and SBOMFlow does not rank them the same. A finding the engine could not rank cannot be reported as sitting below your threshold, so with --fail-on-vulnerabilities (or --fail-on-gaps) enforced it blocks, under its own reason code GATE_SEVERITY_UNDETERMINED — never silently counted as "clean".
An undated EPSS score is not a low EPSS score. FIRST re-fits the EPSS model periodically and the score distribution moves with it, so a probability is only interpretable alongside the score_date it was produced under. A finding carrying a score with no model date blocks an enforced --epss-threshold with GATE_EPSS_UNINTERPRETABLE. (The daily FIRST CSV carries its own score_date and model_version; SBOMFlow stamps both onto every finding it scores. The model version is provenance/context and does not itself change a gate decision.)
| Situation | Behaviour |
|---|---|
Finding with severity: unknown, --severity-threshold low | blocks (GATE_SEVERITY_UNDETERMINED) |
| Finding with a severity band SBOMFlow does not recognise | blocks (GATE_SEVERITY_UNDETERMINED) |
Finding with severity: null (or its string forms "None"/"null"/"NULL"/empty) | blocks (GATE_SEVERITY_UNDETERMINED) — a null is not a band; it reads as unknown, exactly like an absent severity |
NEW (or severity-changed) drift finding with an undetermined severity, --fail-on-new-critical-or-high | blocks (GATE_SEVERITY_UNDETERMINED) — the drift high band is a threshold too: a new finding that cannot be ranked cannot be certified below it. A recorded VEX review or an unexpired waiver clears it like any other drift blocker |
Stored pack whose finding row has no severity key at all, replayed by gate verify / gate simulate | the pack loads with that severity read as unknown (the same read the review queue always performed), and an enforced threshold policy blocks the finding as undetermined — a recorded, per-finding answer instead of no answer |
Finding with an EPSS score but no score_date | blocks (GATE_EPSS_UNINTERPRETABLE) |
Finding with severity: none | passed — none is a real answer |
No --fail-on-* flag at all | informational, exit 0 |
Several of these situations were not blocked before 0.5.0; re-run any gate that was evaluated on 0.4.x.
Every run — enforced or not — reports undetermined_severity_vulnerability_ids, undetermined_severity_gap_ids, and uninterpretable_epss_vulnerability_ids in release-gate.json, so "how many findings have no determinable severity?" is always answerable. Clear a block the same way you clear any other: supply the missing data (an NVD/OSV snapshot with a CVSS vector; an EPSS snapshot recording its score_date), record a human VEX review, or waive the item with a reason and an expiry. Never by ignoring it.
None of this requires the network. A pinned offline snapshot satisfies every policy above — --kev-file, --epss-file, --nvd-file, --osv-snapshot, or a vulndb store. Only an absent or failed source blocks. A stale snapshot does not, by default: it was genuinely consulted, so its age is reported separately (the stale_vulnerability_data warning, promotable with --strict) rather than being confused with never having looked.
Enforcing a freshness SLA (opt-in)#
By default a stale snapshot still satisfies the gate — the right call for offline pinning, but it means a green gate can quietly mean "evaluated against months-old data". If you run to a freshness service level, turn that SLA into a gate with an opt-in setting in your gate policy file:
# policy.yaml
name: freshness-sla
fail_on_kev: true
fail_on_stale_vulnerability_data: true # off by default
max_snapshot_age_days: 30 # optional uniform maximum, in daysWith it on, every vulnerability source this run actually consulted is held to the SLA. A source older than the maximum blocks under the reason code GATE_STALE_VULNERABILITY_DATA; the block names each source, its recorded as-of date, its computed age, and the maximum it was judged by. Omit max_snapshot_age_days to judge each source by its documented per-kind window instead (advisory/NVD 30d, KEV/EPSS 7d) — the bare knob simply promotes the stale_vulnerability_data warning to a gate.
- Unknown age blocks too. A source that records no data date (a loose snapshot of unknown vintage; NVD, which records no retrieval date) can never be shown to be within the SLA, and unknown is never fresh — so it blocks. Supply a dated snapshot (or
vulndb-resolved data) if you enforce this. - The block names which input denied the age. All four causes block identically, but they call for different repairs, so the clause says which one applies: no date recorded, a recorded date that cannot be read as a date, a date later than the run, or a run instant of the run's own that cannot be read. It is the source row's
age_undetermined_reasonverbatim, so the gate andvuln-source-health.jsonnever name different causes — and a source that did record a date is not told it recorded none. - A date the run has not reached is an unknown age, not a fresh one. A snapshot dated after the run — a skewed CI clock, a mis-recorded
retrieved_at— yields no age at all, so it blocks on the same "cannot be shown to be within the maximum" clause rather than passing as the youngest possible data. The source row recordsage_undetermined_reason: date_after_run_instant, and the run raises thevulnerability_data_date_after_runwarning naming the source and the offending date. Fix the clock or the recorded date; SBOMFlow never rewrites the value it was given. - Still offline. Clearing a block means a newer pinned snapshot, raising
max_snapshot_age_days, or dropping the policy — never the network. - Not a compliance statement. It is an engineering policy knob: "the intelligence behind this gate is no older than N days", nothing more.
The knob enforces on the primary gate path: sbomflow analyze --policy policy.yaml (and --policy-profile <preset>) blocks a real release run over a stale consulted source with exit 1, exactly like every other enforced policy. Rehearse it read-only against a stored or current release with sbomflow gate simulate --policy policy.yaml, and inspect exactly what a policy enforces (and how the knob changes its content hash) with sbomflow policy show policy.yaml. The setting is honoured wherever a policy file or preset is resolved.
This applies to the shipped --policy-profile presets too. cra-important, cra-critical and strict-ci all enable vulnerability, KEV and EPSS policies, so they need a real advisory source and the matching catalogues loaded. Pick a profile and point the run at its data; otherwise the gate will tell you — loudly — that it could not evaluate what the profile promised. Cross-check any run against vuln-source-health.json, which reports exactly which sources loaded.
A gate run under a packaged preset also records which exact preset bytes it ran under: release-gate.json gains an additive, experimental policy_model block (model_family/model_version/model_content_digest) resolvable through the material-model registry, so a historical gate can be replayed against the precise preset content it was evaluated with — an unknown digest refuses rather than guessing. This is replay provenance only; it never changes a decision. Runs whose policy was composed ad hoc (or a preset overlaid with extra --fail-on-* flags) omit the block and read as historical_model_unbound — a binding is recorded only when it was verified, never invented after the fact.
Every gate run — preset-bound or not — also records the full policy body it enforced: release-gate.json carries policy_identity, the canonical mapping policy_hash is computed over. That is what makes an ad-hoc or flag-composed release auditable later: sbomflow store verify-gates can replay it with no --policy and no copy of the original file, because the release carries its own policy and its own integrity check over it. A body that does not reproduce the recorded policy_hash is reported unverifiable rather than replayed. Provenance only — it changes no decision.
A decision file the replay cannot read stops the replay. Both store verify-gates and the gate simulation re-apply the reviewer decisions stored with each release — vulnerability_reviews.json, waivers.json, approvals.json. Those reads tell three situations apart, not two: the file is absent (the release recorded no such decisions — ordinary, and reported as nothing at all); the file is present and could not be read (a permission this run does not hold on it or on a directory above it, a link with no target, a directory where the file belongs); or the file was read and rejected as malformed. Only the first is silent. The other two report that release as unverifiable / unsimulatable, name the file, and carry the decision_input_unreadable code for the access-shaped cases. The reason they stop the replay rather than degrade it: a replay run against fewer decisions than you supplied produces an ordinary-looking verdict with nothing on it saying which of your decisions were missing. Repair the access (or the file) and re-run — nothing about the file's contents is guessed in the meantime.
verified is about the gate, and a second column says whether the result underneath it still reproduces. store verify-gates answers one question exactly: did this release's recorded gate outcome come from the policy it names, over the pack on file? It says nothing about the advisory bytes the vulnerability result was computed from — those live in a vulndb store, outside the release, and a retention pass can remove them. So each release also carries a separate vulnerability_replay state: replayable when every source it recorded still resolves in the examined store by snapshot id and content digest; unreplayable when it does not, naming which fact — source_bytes_unavailable (a set was recorded and the bytes are gone; restore the snapshot) or no_source_set_recorded (the run resolved no store, so nothing was ever addressable; re-run through one); and unknown when the question was not answered, which covers a release written before source sets were recorded, an unreadable record, and a store this run was not given or could not read. A release can be verified and unreplayable at once — that combination is why the column is separate. It changes no verdict and no exit code, and the examined/reproduced counts are printed beside it so a run that examined nothing does not read like a clean store.
Pass --vulndb-dir DIR to sbomflow store verify-gates to name the store to examine. Without it, a release that recorded a source set reports source_store_not_examined and stays unknown, because the question was never asked. A release that recorded no set still reports unreplayable with no_source_set_recorded — that answer needs no store, and the flag does not change it. No store is guessed from the working directory: a guess would make the answer depend on where the command was run, and "nothing was examined" must never read as "a store was examined and held nothing".
Rehearse before enforcing. Add --gate-dry-run to see exactly what would block: the gate is evaluated and reported in full, release-gate.json is marked dry_run: true (its exit_code shows the would-be result), and the process always exits 0. Wire it into CI first, watch a few runs, then drop --gate-dry-run to let it fail the build.
Exit codes in CI#
Act on the number, not the log text:
| Code | Meaning | Typical CI response |
|---|---|---|
0 | success, or an informational / dry-run gate | pass |
1 | an enforced gate blocked — a policy decision, not a crash | fail the build; read release-gate.json |
2 | usage / input / IO / config error — see the [Ennn] code | fix the invocation; check the error reference |
3 | init refused / bundle verification failed | investigate before proceeding |
4 | structural validation failure (sbomflow validate) | treat as an integrity incident |
5 | strict warnings-as-errors matched (--strict) | resolve the warnings or drop --strict |
Full detail: exit codes. A blocked gate (1) and a crash (2+) are deliberately different codes so a pipeline can distinguish "policy said no" from "the tool could not run."
Artifact retention & release-to-release drift#
Always upload the evidence bundle (evidence/evidence-bundle.*) — it is the portable reviewer/auditor deliverable and your record of what shipped. To compare against the previous release, restore its evidence/ directory from your release storage and pass it back in:
sbomflow audit . --output evidence --zip \
--previous-output previous-evidence \
--fail-on-evidence-regression --fail-on-new-kevThis blocks only on new problems or regressions since the last release — ideal once a baseline exists.
Suppressing a drift block#
A drift vulnerability blocker — a new finding, a newly known-exploited finding, one that became source-referenced, a new/escalated critical-or-high, or a new finding whose severity could not be ranked — is cleared exactly like the main-path block for that same finding: a reviewed VEX statement (fixed / valid not_affected), or an unexpired reviewer waiver with a reason and an expiry. A waiver may target the exact finding_key so waiving a CVE on one component never suppresses the same CVE on another. Every suppression stays visible in release-gate.json (suppressed_vulnerabilities / waiver_status.applied, with the drift policy named) — the finding is accepted on the record, never silently dropped. The two drift blockers that are not findings — an evidence regression (--fail-on-evidence-regression) and a missing declared support period (--fail-on-support-period-missing) — are suppressible by neither VEX nor a waiver; resolve them by supplying the evidence or the support-period metadata.
When a waiver expires#
expires_at accepts a full ISO-8601 timestamp (2026-01-02T09:00:00Z) or a bare date (2026-01-02). One day-end rule governs waiver and reviewer sign-off expiry alike, so the two can never disagree:
- A bare date is valid through the whole of that day —
expires_at: 2026-01-02stays in force until2026-01-02T23:59:59Zand lapses at the start of2026-01-03.2026-01-02Zreads identically. - A full timestamp is valid through that exact instant and lapses strictly after it.
- An unreadable
expires_atwaives nothing. The row is reported asinvalid_expiryinrelease-gate.json, the item it named stays blocking, and the run keeps its non-zero exit. This covers an obviously broken value and the less obvious near-miss alike: the ISO end-of-day spelling is honoured only when it is a true end of day (…T24:00:00Z, or a zero fraction on the seconds), so…T24:00:59Zand…T24:00:00.500Zread as unreadable rather than being rounded into the next day. The abbreviated spellings…T24Z,…T2400Zand…T240000Zread as unreadable too, and a fraction hung on an hour or a minute (…T00.5Z,…T00:00.5Z) does as well — in both cases because only some supported Python versions parse them, and a waiver must not depend on which one runs your CI. A waiver requires a bound somebody can check.
A refused row is also named on the surfaces a human reads, not only in the artifact: the analyze run summary gains a waivers line counting it by its recorded state, and sbomflow explain <output-dir> --gate lists each refused record key beside the violations. Without that, a CI log showed gate BLOCKED (enforced) — gate exit 1 and the blocking gap IDs, and read exactly like a run in which no exception had been recorded at all. A row with no readable identity, and a waivers file that did not load, are reported as their own states rather than folded into the same sentence, because a target you can repair and a target nobody can name are different problems. See Troubleshooting for the full table.
Behaviour change (evidence packs that recordexpires_at). Before this release a bare-date waiver lapsed at the start of the date (midnight) — a day earlier than a bare-date sign-off, which was always valid through the whole day. The two now match. A bare-date waiver written for "today" is valid for the rest of today instead of already lapsed. If you relied on midnight expiry, write an explicit…T00:00:00Ztime. Renewal is unchanged: a fresh reason and a strictly-later expiry are still required (measured by the same end-of-day rule).
Reproducibility in CI#
Pass a fixed --as-of timestamp for byte-reproducible artifacts across re-runs (operational timing is kept in a separate section so it never affects the reproducible output). Re-running the same commit + inputs + --as-of yields identical evidence, which you can confirm with sbomflow validate or sbomflow compare-releases.
--as-of pins the evidence clock, not the advisory data: a pipeline that refreshes its snapshot will produce findings that post-date an older pin, which is expected. The contract is stated once, in What --as-of pins.
Offline vs network in CI#
- Default: fully offline. No network egress is required; air-gapped runners work as-is.
- Real advisories need an explicit opt-in:
--use-osv(queries the OSV API) or a local snapshot file (--nvd-file,--kev-file,--epss-file, or a pinnedvulndbsnapshot). Prefer snapshot files on restricted runners so the build stays deterministic and offline. - Behind a proxy / TLS gateway, enabled lookups honour standard
HTTPS_PROXY/SSL_CERT_FILEenvironment variables; a failure is surfaced as a warning and never corrupts already-written evidence. See Security & privacy for the full per-command network table.
Failure modes in CI — what each means and what to do#
| Symptom in the pipeline | Cause | Action |
|---|---|---|
audit step exits 1 | an enforced gate blocked | expected when a policy matched; read release-gate.json / run sbomflow explain evidence --gate; suppress legitimately via reviewed VEX or a waiver |
audit step exits 2 with [Ennn] | usage / input / config error | fix per the error reference (E001 bad path, E003 bad --output, E004 disk/permission, E010 bad config) |
step exits 4 | validate found a hash/chain/count mismatch | integrity incident — regenerate from source, do not edit artifacts |
step exits 5 | --strict turned a warning into a failure | inspect scan-warnings.json; resolve or drop --strict |
| annotate shows nothing | no findings, or wrong output dir | confirm the audit wrote release-gate.json/issues.json to the path you pass to annotate |
| results differ between runs | no pinned --as-of | pass a fixed --as-of |
More symptoms and fixes: Troubleshooting.
Running in a container#
No published image is hosted yet — build one locally from the source tree provided with your evaluation access (see Installation & access), using its own Dockerfile:
cd sbomflow # the source tree provided with your access
make image # builds sbomflow:local
docker run --rm sbomflow:local --versionThe image is minimal and non-root: a builder stage compiles the wheel from source (there is no public package-index release, so nothing is fetched by name), and the final stage installs only that wheel — never a copy of the source tree. The base image is always pinned by digest, never :latest, so a build never silently drifts onto an unreviewed base layer; use the pinned digest yourself if you build your own image from python:3.13-slim, and never substitute :latest in a pipeline.
A container run is exactly as offline as a bare-metal run — mount your product directory read-only and an output directory, and it never needs network access:
docker run --rm --network none \
-v "$(pwd)/my-product:/product:ro" \
-v "$(pwd)/out:/out" \
sbomflow:local analyze /product --output /out \
--product-name "REPLACE-ME" --product-version "REPLACE-ME"See also#
- CLI reference — every command and flag.
- Exit codes · Error reference · Warning catalog.
- Security & privacy — the exact network behaviour of every command.
- Troubleshooting — symptom → fix for the whole product.