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:

bash
python -m pip install .
sbomflow audit . --output evidence --zip --fail-on-vulnerabilities --severity-threshold high
sbomflow annotate evidence --format github   # or: --format gitlab
  • The audit step 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 exits 0.
  • annotate is output-only and offline — it turns release-gate.json + issues.json into 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#

yaml
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.
  • installpip install . from the repo, or install the published wheel.
  • audit — the deterministic pipeline. --zip also writes evidence/evidence-bundle.zip; --fail-on-* selects the enforced gate; --severity-threshold sets 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:

bash
sbomflow audit . --output evidence --zip --fail-on-vulnerabilities --severity-threshold high
sbomflow annotate evidence --format gitlab > gl-code-quality.json

Then 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:

FlagBlocks the build when…
--fail-on-vulnerabilitiesany advisory finding at/above the threshold
--fail-on-keva finding is on the CISA Known Exploited Vulnerabilities list
--fail-on-gapsa required evidence item is missing at/above the threshold
--fail-on-unreviewedobserved evidence has no human review yet
--fail-on-new-vulnerabilities / --fail-on-new-kev / --fail-on-new-critical-or-higha new issue appeared vs the previous release
--fail-on-evidence-regressiona previously-resolved finding reappeared
--fail-on-reachable-vulnerabilities / --fail-on-new-reachable-vulnerabilitiesa finding has reachability context
--fail-on-denied-licensea component matches a denied-license policy
--fail-on-support-period-missingthe declared support period is absent
--fail-on-missing-approvalsrequired 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.

FlagRequiresPasses vacuously without it?
--fail-on-vulnerabilitiesa real advisory sourcewas: "no findings ≥ threshold" — now blocks
--fail-on-keva real advisory source + the CISA KEV cataloguewas: "nothing known-exploited" — now blocks
--epss-thresholda real advisory source + the FIRST EPSS modelwas: "nothing scores above X" — now blocks
--fail-on-reachable-vulnerabilitiesa real advisory source + reachabilitywas: "nothing reachable" — now blocks
--fail-on-new-vulnerabilities / --fail-on-new-critical-or-higha real advisory source + a previous releasenow blocks
--fail-on-new-keva real advisory source, the KEV catalogue + a previous releasenow blocks
--fail-on-new-reachable-vulnerabilitiesa real advisory source, reachability + a previous releasenow blocks
--fail-on-denied-licensea non-empty deny list + at least one component declaring a licensewas: "nothing matched" — now blocks
--fail-on-missing-approvalsat least one required_approval_role or a min_approvals quorumwas: "no sign-off missing" — now blocks
--fail-on-evidence-regression / --fail-on-support-period-missinga previous releasealready blocked (GATE_DRIFT_UNAVAILABLE)
--fail-on-gaps / --fail-on-unreviewednothing beyond the local scan, which always runsno — 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.)

SituationBehaviour
Finding with severity: unknown, --severity-threshold lowblocks (GATE_SEVERITY_UNDETERMINED)
Finding with a severity band SBOMFlow does not recogniseblocks (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-highblocks (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 simulatethe 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_dateblocks (GATE_EPSS_UNINTERPRETABLE)
Finding with severity: nonepassed — none is a real answer
No --fail-on-* flag at allinformational, 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:

yaml
# 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 days

With 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_reason verbatim, so the gate and vuln-source-health.json never 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 records age_undetermined_reason: date_after_run_instant, and the run raises the vulnerability_data_date_after_run warning 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:

CodeMeaningTypical CI response
0success, or an informational / dry-run gatepass
1an enforced gate blocked — a policy decision, not a crashfail the build; read release-gate.json
2usage / input / IO / config error — see the [Ennn] codefix the invocation; check the error reference
3init refused / bundle verification failedinvestigate before proceeding
4structural validation failure (sbomflow validate)treat as an integrity incident
5strict 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 &amp; 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:

bash
sbomflow audit . --output evidence --zip \
  --previous-output previous-evidence \
  --fail-on-evidence-regression --fail-on-new-kev

This 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 dayexpires_at: 2026-01-02 stays in force until 2026-01-02T23:59:59Z and lapses at the start of 2026-01-03. 2026-01-02Z reads identically.
  • A full timestamp is valid through that exact instant and lapses strictly after it.
  • An unreadable expires_at waives nothing. The row is reported as invalid_expiry in release-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:59Z and …T24:00:00.500Z read as unreadable rather than being rounded into the next day. The abbreviated spellings …T24Z, …T2400Z and …T240000Z read 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 record expires_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:00Z time. 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 pinned vulndb snapshot). 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_FILE environment variables; a failure is surfaced as a warning and never corrupts already-written evidence. See Security &amp; privacy for the full per-command network table.

Failure modes in CI — what each means and what to do#

Symptom in the pipelineCauseAction
audit step exits 1an enforced gate blockedexpected 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 errorfix per the error reference (E001 bad path, E003 bad --output, E004 disk/permission, E010 bad config)
step exits 4validate found a hash/chain/count mismatchintegrity incident — regenerate from source, do not edit artifacts
step exits 5--strict turned a warning into a failureinspect scan-warnings.json; resolve or drop --strict
annotate shows nothingno findings, or wrong output dirconfirm the audit wrote release-gate.json/issues.json to the path you pass to annotate
results differ between runsno pinned --as-ofpass 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:

bash
cd sbomflow                # the source tree provided with your access
make image                 # builds sbomflow:local
docker run --rm sbomflow:local --version

The 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:

bash
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#