Skip to content

SOUP: dependency discovery, SBOM per build, and daily monitoring of the deployed version - #51

Open
grafele wants to merge 62 commits into
mainfrom
actions/dev-195-soup-discovery
Open

SOUP: dependency discovery, SBOM per build, and daily monitoring of the deployed version#51
grafele wants to merge 62 commits into
mainfrom
actions/dev-195-soup-discovery

Conversation

@grafele

@grafele grafele commented Aug 5, 2026

Copy link
Copy Markdown

Third-party components — SBOM, vulnerability monitoring and the Work Instruction that governs it.
Covers DEV-190, DEV-191, DEV-192, DEV-195, DEV-196, DEV-197.

Counterpart PR: QuickBirdEng/workflows#54 adds the three reusable workflows that call
these actions. That one should merge first — a product caller referencing
QuickBirdEng/workflows/.github/workflows/soup-sbom.yml@main fails until the file is on main.

What this does

Four points in time, each with its own record:

Trigger Question Record
#0–#2 Pull request Is every vulnerability of this version fixed or dispositioned? SOUP record, blocks the change if not
#3 Tagged build What is in this build, and is it assessed? The inventory, attached to the release
#4 Daily Is the version in production affected today? A dated record, written even when nothing is found
#7 Quarterly or annually What did not happen? Reconciliation report

Stage #4 assesses what is actually deployed, resolved from the GitHub deployment record — not the
newest tag and not the newest release, because neither states what is running.

Three things it deliberately refuses to do

  • Scan an artefact nobody classified. Discovery finds candidates; every one must appear in
    .soup-scope.yml as included or excluded, each with a reason. An unclassified candidate stops the
    run, because "not listed" is otherwise indistinguishable from "deliberately excluded".
  • Report success on an empty result. A run that could not complete says so. This was a real
    defect twice: a select() inside a jq object constructor made the whole construction empty, and
    the action wrote a 0-byte record while reporting all-clear.
  • Guess a required parameter. tier, cra_scope and maintenance_interval come from the
    customer SLA and have no safe default. cra_scope defaults to unknown rather than false,
    because an unnecessary report costs less than an omitted one.

Where the rules live

Not in this repository. The Work Instruction and its two annexes live in the QMS, which is where a
controlled document has a review workflow and a single version. policy-defaults.yml here is its
machine-readable counterpart: the document is what a person reads and what an auditor is shown, this is
what the tooling applies. A change to one is a change to the other and belongs in the same review.
Section references in comments (WI §5, Annex B B.1.1) point at the document.

Two places where BSI TR-03161 is stricter than the process default, enforced rather than documented:
O.TrdP_2 requires the newest version or the one preceding it, so a product in TR-03161 scope must
state a patch limit; O.TrdP_8 is a prohibition, so accepting obsolescence with a recorded reason is
not available to it. A configuration that violates either is refused by validate-policy.sh.

Nothing here is product-specific

No ticket number and no product name in any comment, README or example — tests excepted, where a
regression test names what it reproduces. Measured facts stay, because they are the reason the values
are what they are; only the attribution goes.

Per-product files are not in this repository either: the six scope and policy drafts and the one
deploy-workflow patch go to the product repositories, which is where the WI says a product's
configuration lives. examples/ holds three generic templates instead — a policy, a policy showing a
justified relaxation, and a scope declaration whose entries are the cases worth showing: an image in
scope in one artefact and out of scope in another, our own release-versioned image, a builder stage,
infrastructure.

Measured against real repositories

Everything below is from actual runs, not from examples. On one backend product:

  • 522 findings resolve through 3 actions. 492 of them are RPM packages inside a single image; none
    is in code written here. Attaching the deadline to the finding produced 522 escalations for 3
    decisions, which is why timeframes attach to the remediation unit instead.
  • 0 of 23 Critical findings were listed in KEV. A shared 72-hour deadline was applying the urgency
    of observed exploitation to findings that carried none. KEV is now its own track; the other bands
    got timeframes that can be met.
  • Excluding OS packages changed "currency unknown" from 343 of 386 to 43. The image is the SOUP;
    its packages move when it does.
  • Maintenance windows: two products hold, one has missed 1, one has missed 3, one cannot be
    determined because its deployment workflow declares no environment.

Seven defects in my own implementation were found by running it rather than by reading it. They are
listed in Annex A §A.10 and each has a regression test.

Tests

soup-discovery/tests/run-tests.sh — 139 cases, 5 of which hit live feeds and are skipped without
TEST_NETWORK=1. Most are regressions for defects found while building this.

Suggested reading order

  1. WI-SOUP-Vulnerability-Management.html §1–§4 — what the process is and what each project decides.
  2. soup-discovery/policy-defaults.yml — the same decisions in machine-readable form, with the reason
    for each value in comments.
  3. patches/README.md — which file goes into which repository, and the order to wire a product.
  4. soup-discovery/scripts/classify-findings.py and group-remediation.py — the two places where a
    wrong rule would be hardest to notice.

Not in this PR

  • No product is wired. The callers are staged under patches/product-repo/ as templates. Nothing
    runs on a schedule yet, which also means no timeframe is running: both clocks start on the date a
    run first reports a finding.
  • The scope declarations are drafts derived from reading the repositories, and they are not in this
    PR — they belong in the product repositories. Each needs confirmation by its project before the
    product is released under this document.
  • Object store credentials are not distributed. Without them a run keeps its records as 90-day
    workflow artefacts and the backstop cannot reconcile a longer period.
  • The retention period is a QMS determination and is not yet stated. It decides whether the object
    store alone is sufficient, since it has no object lock.

One thing worth a second opinion

The maintenance window model replaced a version that derived the deadline from each product's
observed release rhythm. That did not work: three of four products had a rhythm that had already
lapsed, so a finding was born overdue — one product's would have been 109 days late on the day it was
found. A product now declares a commitment instead, and the deadline is the next window on that grid.
The commitment is capped by the SLA tier and the cap cannot be waived. WI §6.2 and Annex A §A.3.

Stefan Kofler and others added 30 commits August 2, 2026 18:25
Closes the discovery gaps that block DEV-190. Today's tooling covers npm, Dart and Gradle;
Gradle only at the *declared* level, and Go, Maven, Terraform, Python and container images
not at all.

Two defects motivate this. Transitive JVM dependencies are invisible, which is where most
CVEs live. And BOM/platform-managed versions come out empty: the existing regex misses
implementation(platform("com.azure:azure-sdk-bom:1.2.31")), so four Azure dependencies are
emitted with no version at all - not a valid configuration item under IEC 62304 8.1.2, and
not CVE-matchable. Scanning the resolved runtime closure fixes both: 16 declared -> 84
resolved components, and the four Azure entries carry real versions.

installDist rather than gradle.lockfile, because the lockfile presupposes dependencyLocking
and would make this depend on a per-project change. Discovery prefers a lockfile where one
exists.

The part worth reviewing most carefully is the scope gate. Discovery is deliberately
exhaustive and makes no scope decisions; resolve-scope.sh applies a per-repo
.soup-scope.yml and fails on any candidate that is unclassified, matched at equal
specificity by both lists, or excluded without a reason. Exclusions typically outnumber
inclusions - blind discovery would put test harnesses, docs tooling, internal dashboards
and Docker build stages into a medical device's SBOM. A new go.mod appearing makes the run
fail by name, which is what WI-006-03 asks for and what nothing enforces today.

Verified across the portfolio, not just one repo. Tuning on apellis alone produced tooling
that failed on Flutter and TypeScript projects: flutter_native_splash.yaml uses image: for
asset paths (mindnet reported assets/logo/logo.png as a container image); Android
build.gradle files were routed to installDist, a task they do not have; the same image
referenced from prod/staging/dev manifests produced duplicate ids. All fixed and re-checked
against apellis, mindnet, osteocoach and kontina-backend.

Output is CycloneDX 1.6 - syft cannot emit 1.7 yet. 1.7 is backward compatible and its main
gain here, the CSAF-aligned VEX vocabulary, is a content choice already applied. Recorded as
a decision on DEV-190 with a re-check when syft catches up.

Normalisation and gating matter as much as discovery: raw syft output carries 81 scan-path
artefacts, 1886 speculative CPEs and zero hashes. Normalised: 84 components, 0 guessed CPEs,
79 hashes (harvested off the file entries before dropping them, since Maven jars carry only
a SHA-1 elsewhere), and byte-identical across two runs.

Also included, for DEV-190's release bundle: scan-vulns.sh (OSV, joined on purl and never
on CPE), merge-enrichment.sh (KEV property, EPSS rating, feed provenance) and
merge-assessment.sh (SOUP requirements as properties, approval as an annotation, VEX as
analysis). Run end to end on kontina-backend: 633 components, 521 vulnerabilities, gate
passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects, all found by running the pipeline against mindnet rather than by reading it.
mindnet is the most varied repo in the portfolio - Flutter app, web monorepo, three Keycloak
Java extensions, Terraform, 25 container references - which is why it is the pilot.

1. SOUP approval is not version-specific. The matcher joined on metadata.input_version, the
   exact version checked at approval time. A record for family "1.x.x" checked at 1.0.1 then
   failed to match a component shipping 1.0.4, producing three wrong answers at once: no
   requirement properties on the component, no approval evidence, and the record reported as
   orphaned. Now joins on the version family, with both values preserved as
   quickbird:soup:approved-family and quickbird:soup:checked-version so "approved for 1.x.x,
   shipping 1.0.4" stays visible. A 2.0.0 component still does not match a 1.x.x record - a
   new major is a new family and needs a fresh approval.

2. FROM --platform=linux/amd64 nginx:mainline-alpine left the flag inside the image
   reference, so the scan failed and the candidate landed in the gap list for the wrong
   reason.

3. Maven was never built. Discovery pointed at target/, which had never existed, so all
   three Keycloak extensions were gaps.

4. mvn package alone is not enough, and this is the one worth reading twice. Without a
   shade plugin target/ holds only the artifact jar: scanning it found 2 components for an
   extension that has 8 runtime dependencies, among them protobuf-java 3.25.5 and five netty
   jars - precisely the libraries that carry CVEs. copy-dependencies -DincludeScope=runtime
   materialises the resolved closure, 2 components -> 11. includeScope=runtime deliberately
   drops `provided` deps: the Keycloak SPI jars are supplied by the Keycloak runtime and
   already appear in that image's BOM, so counting them here would double-count them.

Pilot result on mindnet: 34 candidates -> 22 in scope, 12 out, 0 unclassified. 20 of 22
targets scanned; the 2 gaps are legitimate and named in the BOM (the Android APK needs a
build artifact, and qbsdocker/qb-gid-server is a private registry needing credentials).
9225 component entries consolidated to 6757 - 2468 duplicates removed, mostly base-image
layers shared across the 20 targets. Gate passes: no unversioned component, no scan paths
as names, no speculative CPEs.

The three Keycloak extensions are components the current org tooling does not see at all.

Also adds examples/mindnet.soup-scope.yml. The scope calls in it are proposals from reading
the repo, marked DRAFT - they need the project's confirmation before that file is committed
to mindnet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
check-fix-or-vex.sh queries OSV for the exact version being approved and requires a
disposition for every finding. soup-fix-or-vex wraps it as a composite action so the
approval workflow can call it the same way it calls everything else.

Uses a read loop rather than mapfile: mapfile is bash 4+, macOS ships bash 3.2, and the
failure mode there was "command not found" followed by an exit code that looked like
success — the worst possible result for a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CycloneDX file is the authoritative artifact; this is what a person reads — a release
reviewer, an auditor, a notified body, or a customer asking what is in the product. It
renders only what the bundle says, so the two cannot drift.

reportlab, matching the existing generate-soup-pdf.py rather than adding a second PDF
toolchain. Optional and guarded: a missing python dependency warns and skips rather than
failing a run that already produced the bundle.

One choice runs through the layout: what is missing is as prominent as what is present.
The completeness marker and the named gaps are on page one, above the component counts,
because a bundle that is 95% complete and silent about the rest looks exactly like a
complete one. The same applies inside the document — "521 have no assessment yet" is
stated plainly, a truncated table says it was truncated for readability rather than
because the rest were assessed, and a KEV lookup that could not be performed is called out
so that the absence of a KEV flag is not read as evidence of absence.

Sections: cover with completeness and feed provenance (KEV catalogVersion, EPSS model
version — neither is reconstructable later); inventory by ecosystem with license and hash
coverage; SOUP assessment with approval family, approver, date and requirement results;
vulnerabilities sorted so KEV and unassessed findings come first; full component inventory.

Verified against three real bundles: kontina-backend (634 components, 521 vulnerabilities
with real KEV/EPSS provenance, 28 pages), mindnet (6757 components, incomplete with two
named gaps, 180 pages) and a fixture carrying a complete SOUP assessment with VEX.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VEX authorship: drafted by a developer, countersigned by a SOUP approver. Reachability is a
question about the code, so the person who works in it writes the claim; authority to accept
it stays with the approvers.

The useful consequence is that no new mechanism enforces this. A VEX statement lives in a
.soups record, so adding one is a SOUP-file change, which already triggers
soup-approval-verification-workflow, which already refuses any approval not coming from
vars.SOUP_APPROVERS. A developer cannot merge their own not_affected. The gate validates that
a statement exists and is well-formed; the review validates that someone with authority
agreed. A script cannot judge whether "not reachable from our code" is true, and pretending
otherwise would be the more dangerous design.

Also updates the mindnet scope example: Countly is out. It is an external service, not part
of the medical device - its api, frontend and mongo manifests are one deployment and go
together. Recorded with the distinction that matters: this is a scope call about the SBOM,
not about the product documentation. An external service still belongs in the Software Tools
section of the SBOM QMS record and remains subject to the usual data-protection assessment;
it is simply not a component of the shipped software. mindnet is now 19 in scope, 15 out,
0 unclassified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e asset

Two halves of the deployed-version pointer: publishing puts the bundle where it can be
found (sbom-<tag>.cdx.json on the GitHub release, plus the PDF), and resolve-deployed.sh
answers "what is running, and is there an SBOM for it".

The resolver reads GitHub deployments rather than the newest tag, and this turned out to
matter more than expected. Deployments already exist across the portfolio - mindnet,
osteocoach, alvie, dermafy and apellis all record them - so the pointer is largely
instrumented already. But what they record is not what "latest tag" would say.

The script therefore reports three distinct negative results rather than collapsing them:

  - no deployment recorded          -> we do not know what is running
  - deployed ref is not a tag       -> branch build, no release, no SBOM
  - deployed tag has no SBOM asset  -> released before this pipeline existed

Reporting "unknown" is the correct answer to a question that cannot be answered. Falling
back to the newest tag would produce a confident wrong one, and for mindnet today it would
be wrong: Production's most recent deployment is a branch, not v1.0.15.

Measured across the portfolio:

  mindnet          Production=branch, Staging=v1.0.15-qa4 (no SBOM asset yet)
  osteocoach       Staging=v1.1.0-qa8, no Production environment recorded
  alvie            Staging=v1.0.8-qa36, no Production environment recorded
  dermafy          Staging=v1.1.0-qa13, no Production environment recorded
  apellis          development only, ref=main
  kontina-backend  no deployments at all

Only mindnet records a Production environment. For the mobile products the store release
is not a GitHub deployment, so what users actually run is not derivable from GitHub -
that is a gap in the convention, not in this script, and it is written up on DEV-196.

publish-to-release fails loudly when the token is missing rather than skipping quietly:
a bundle that was not attached leaves the deployed version with no resolvable SBOM, which
is exactly the failure this ticket exists to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…artifact

For the apps there is no deployment record because the release goes straight to the stores.
The answer is already in the release assets: builds are named -android-production /
-ios-production, and the newest release carrying one is what users are running. Staging,
study and develop flavours are not live.

Derived from what the release pipeline already produces, so no new instrumentation - with
the caveat that the asset naming is now load-bearing and renaming it would silently break
the resolution.

A product with no production artifact is not an error. It means the product is not live to
users yet, which for a study-phase product is the correct state, and the resolver says so
rather than reporting nothing.

Fixed while testing: `gh api --jq` takes only an expression and does not forward --arg to
jq. Passing one silently dropped the pattern, and every repo came back "not live" - a wrong
answer that looks like a legitimate one. Piped to jq instead.

Measured:

  mindnet     app v1.0.15 (2026-07-21); backend on a branch, not a tag
  alvie       app v1.0.4  (2025-06-23)
  osteocoach  not live to users - study/staging flavours only
  dermafy     not live to users - study/staging flavours only

mindnet is the concrete case behind "multiple concurrent live versions": the app and the
backend are separately live and are currently on different things. Neither production
release carries an SBOM asset yet, which is expected - the publish step does not exist on
main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… version

One question: does anything we currently run contain a vulnerability known to be actively
exploited. From 11 September 2026 that question carries a 24-hour reporting clock for
CRA-scoped products, and a KEV finding is unconditionally Track 1 in the classification, so
acting on one needs no severity logic. Grading, deadlines and the finding lifecycle are
DEV-191 and are deliberately absent.

Chains the pieces that already exist: resolve-deployed -> fetch the release SBOM ->
scan-vulns -> KEV enrichment -> VEX suppression -> dated record -> alert.

Three properties worth keeping through review, because each is a way the thing could
quietly lie:

- all_clear requires that the answer was actually established. No KEV findings AND nothing
  unscannable AND no CVE whose KEV membership is unknown. A feed that could not be read
  yields "incomplete", never "clear", and the alert for that case says in as many words
  that absence of a finding is not evidence of absence.
- A record is written on every run, including clean ones. That is the point: it is what
  distinguishes "we checked and found nothing" from "nobody looked", and only the second is
  a finding at an audit.
- cra-scope defaults to "unknown" and the alert says so. Defaulting to "false" would be the
  one wrong answer that carries a legal consequence.

MONITOR_LOCAL_SBOM allows the alerting path to be exercised before any release carries an
SBOM asset. It bypasses deployed-version resolution, so every record it produces is stamped
synthetic:true — a test run must never be mistakable for evidence.

Verified both paths. Against mindnet as it stands today: "incomplete", four named reasons,
correctly not all-clear, because no release carries an SBOM asset yet and Production is on
a branch. Synthetically against log4j-core 2.14.1: CVE-2021-44228 and CVE-2021-45046 found,
both flagged for known ransomware use, EPSS ~1.0, with the 24-hour framing.

Includes the reusable workflow for QuickBirdEng/workflows under patches/. Callers add their
own schedule rather than inheriting a fixed one — a shared cron would put every product on
the same minute against a single shared feed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…loyed

Corrects a wrong conclusion in the previous commit, which reported mindnet Production as
running a branch. It is running v1.0.15.

The record I read - ref=temp-disable-cmsContentMetadata-transfer, state success - was
created by the "Staging to Production Content Migration Workflow", whose only job migrates
the Strapi database. It ships no application code. It was dispatched from that branch, and
because the workflow declares environment: Production, GitHub created a deployment record
carrying the branch as its ref and auto-marked the real v1.0.15 deployment inactive. I read
that auto_inactive as corroboration; it is a side effect of any new record in the same
environment.

The general property, which the resolver would otherwise have got wrong on every run for
every repo that has such a workflow: *any* workflow declaring an environment creates a
deployment record - data migrations, smoke tests, anything naming an environment for
permissions or protection rules. The environment label says nothing about whether code was
deployed.

An application deployment is now one whose ref is a tag, which is how releases are actually
versioned here. Non-tag records go to non_release_deployments: visible, never treated as
the live version.

  mindnet after the fix:
    app      v1.0.15 (2026-07-21)
    backend  Production v1.0.15, Staging v1.0.15-qa4
    ignored  Production <- content-migration branch, Development <- main (x6)

Also fixes a performance bug introduced with the tag check: it made one API call per
record, which for apellis meant a hundred requests for a hundred deployments of `main` -
slow enough to time out, and quota spent on every scheduled run. The tag list is now
fetched once and matched locally. All five repos resolve in 1-6s.

The tag-is-not-a-deploy concern stays valid as a property of the convention: a release can
be cut and not rolled out, a hotfix can land after a tag. It was simply not what was
happening here, and non_release_deployments is what makes a real instance visible rather
than the resolver silently picking a wrong ref in either direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rpart

Until now the deadlines, EPSS thresholds and currency limits existed only in
classification-draft.md, which says "configurable per project" in five places while nothing
read a configuration. The only file the tooling knew was .soup-scope.yml, which decides
what goes into the SBOM, not what happens to a finding.

policy-defaults.yml is §2.1 and §3 in machine-readable form and ships with the tooling. It
is the same content as the document, so a change to one is a change to the process and
belongs in the same review. .soup-policy.yml per repo overrides parts of it.

Two rules make this a policy rather than a settings file:

Required fields have no safe default. cra_scope and release_cadence cannot be guessed -
assuming "not in CRA scope" is the one wrong answer carrying a legal consequence, and a
missing cadence would produce a Track 3 deadline that only looks like one. Missing fails
the run; nothing quietly appears.

A loosened value needs a stated reason. Overriding a deadline to be longer, or an EPSS
threshold to be higher so that fewer findings escalate, is allowed - products genuinely
differ - but only with a reason recorded next to it. Tightening never needs one. Without
that asymmetry "configurable per project" is just another way of saying the process is
advisory. examples/onprem.soup-policy.yml is the case it exists for: a customer-operated
installation where we control the release and the operator controls the deploy, so a 21-day
"fix live" deadline would mark nearly every finding as breached and a breach signal that
always fires carries no information.

Wired into the KEV monitor, which now takes cra_scope from the policy instead of from a
workflow argument - versioned in the repo and validated, rather than retyped per call. An
invalid policy stops the run rather than falling back to defaults: the defaults might be
exactly what the project meant to override.

Fixed along the way: the required-field check used jq's `//`, which treats false as absent,
so `cra_scope: false` - the value most products will set - was reported as missing.

Verified: valid minimal policy passes; missing fields fail with all of them named; a
relaxed deadline fails without a reason and warns with one; tightening passes silently; a
raised EPSS threshold fails without a reason; an invalid policy stops the monitor; and the
monitor's alert carries the 24-hour framing when the policy says the product is in CRA
scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ile building this

4600 lines of bash and jq with no tests was the part of this I trusted least, and with
reason: eight defects surfaced during development, every one of them a case where the
tooling produced a confident wrong answer rather than an error. A scan that crashes gets
fixed; a scan that silently reports the wrong component set gets believed.

Each of those is now a test:

  jq's // treats false as absent          cra_scope: false read as missing
  gh api --jq does not forward --arg      every repo reported "not live"
  mapfile is bash 4+                      gate exited 0 on macOS, looking like success
  . inside any() is the marker, not rule  path scope rules never matched
  . after `.c |` is the component         approval evidence silently dropped
  match on input_version, not family      1.0.4 found no record for a 1.x.x approval
  metadata.component.name is a scan path  two runs over identical content differed
  same CVE from two advisories            28 duplicate vulnerabilities, double-counted

Offline by default; the two network cases need TEST_NETWORK=1, so the suite runs in a hook
or in CI without depending on OSV or CISA being up.

The suite was then checked by reverting each fix and confirming the matching test fails.
Four of five mutations were caught. The fifth was not, and that was the useful part: the
consolidation test asserts that components survive the merge, not that the loss *guard*
works. Probing that gap found a real defect - an input component whose bom-ref collides
with a generated quickbird:artifact:* ref produces two components sharing one ref. Nothing
was lost, every check passed, and the document was invalid CycloneDX, because bom-ref must
be unique. Consolidation now checks that invariant explicitly, and the new test is
mutation-verified too.

A passing suite written against the code it tests proves little. The mutation pass is what
makes these worth keeping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.soup-policy.yml validated its deadlines and thresholds and nothing read them. This turns a
vulnerability plus its CVSS, KEV, EPSS and VEX into a track and two dated deadlines, so
tracks, epss and alerts.threshold are now applied rather than merely checked.

Kept separate from scanning and enrichment on purpose: the scan says what is there, the
enrichment says what is known about it, and this says what we have to do about it. Only the
last is policy, and only the last changes when the process changes.

Implements CVSS 3.1 from the specification rather than reusing
action-scripts/cvss-3-1-severity.sh, which diverges in two ways: it omits the scope-changed
impact correction (the -0.029 offset and the -3.25*(ISC-0.02)^15 term) and it does not apply
Roundup, which the spec requires to round *up* to one decimal. Both push scores downward.
Exhaustively comparing all 2592 possible base vectors, **164 of them land in a lower
severity band** than the specification gives - AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:L is 7.0
(High) and reads there as 6.92 (Medium). At a band boundary that is a different track and a
different deadline. The existing script is used elsewhere, so this is reported rather than
changed here, but the two must not disagree.

Two rules that would fail silently, both tested and mutation-checked:

- Latching (§2.2). EPSS is recomputed daily and decays. Without it a Track 1 finding
  quietly becomes Track 2 a week later, the deadline moves outward, and the audit trail
  shows a deadline that was never breached because it kept receding. A track may only move
  up; the clock survives a latch and restarts only on a genuine escalation.
- kev "unknown" is not kev false. A catalog that could not be read is not evidence of
  absence, so it classifies as Immediate and says why, rather than being folded into clear.

Against the real kontina-backend findings: 521 classified - 23 immediate, 288 expedited,
196 planned, 14 monitor, 311 alerting at the default threshold. Rule 9 fires 126 times,
which is worth knowing on its own: a quarter of the advisories carry no CVSS at all and are
triaged as Expedited on the principle that an unknown is not a low.

Suite now 46 tests. Mutation-checked: removing latching, treating kev unknown as absent,
and replacing Roundup with round() each make the matching test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
merge-enrichment.sh and classify-findings.py had no caller. Both were only ever run by
hand while being developed, so in CI the KEV and EPSS data would never have reached the
BOM and no finding would ever have been given a track or a deadline. This is the same
failure the policy commit was about - a thing that validates and is never read - repeated
twice while fixing it.

The cause was that the release path and the monitor each chained their own subset of the
same five steps. assess-bom.sh is now that chain, and both go through it:

  scan-vulns        CVEs for the components, joined on purl
  enrich            KEV membership and EPSS, with feed provenance
  merge-enrichment  both onto the vulnerabilities, provenance into metadata
  merge-assessment  SOUP requirements, approval annotations, VEX analysis
  classify          track and two dated deadlines per finding

The monitor no longer re-derives KEV findings itself. It reads them out of the classified
output, because the classifier has already applied VEX suppression and the kev tri-state
rule, and a second implementation of the same decision is a second thing that can drift.

Latching needs memory, so the monitor now persists the classified findings and feeds them
back on the next run. Without that every run restarts every clock and no finding ever
latches - the deadlines would look correct and mean nothing.

The release bundle only gets an assessment when a policy exists. No policy means no
deadlines: inventing defaults there would produce dates nobody agreed to, so it warns and
ships the component list alone.

Verified against real data. kontina-backend, 633 components: 521 vulnerabilities, 23
immediate / 288 expedited / 196 planned / 14 monitor, PDF rendered from the assessed
bundle, 37s for the whole chain. Two consecutive monitor runs keep every clock - no
first_seen moved - and the onprem example's justified 60-day relaxation is what the
deadline is computed from, not the 21-day default.

46 tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    open                         the running version is vulnerable, nothing staged
    fix-ready-release-pending    a fix is in main; the running version still has it
    deployed                     the running version contains the fix

The distinction the ticket rests on: a merged fix satisfies **mitigation**, because
exposure is now bounded by a known release date, but it does not satisfy **remediation** -
users run the vulnerable build until it ships. Collapsing the middle state into "fixed" is
how a finding gets closed while the thing it affects is still live, so the two clocks are
tracked separately and only a deploy stops the second one.

This needs two inputs, which is why it could not simply be folded into the classifier: the
deployed SBOM alone cannot tell "nobody fixed it" from "it is fixed and waiting to ship" -
both look identical from the running version. Only the comparison against main separates
them. Without a main comparison every finding reads as open, and the tool says so rather
than quietly making the middle state unreachable.

Release-required is deliberately narrow: Track 1 only, fix staged, not yet live. It is the
single mechanism by which vulnerability management can force an out-of-band release, and
widening it would make it ignorable.

The failure mode guarded hardest is resolution-by-accident. A finding that disappears from
the results is only resolved if the scan actually completed; if it did not, the state is
`unknown`, not `deployed`. Absence of a finding is not evidence that it is fixed, and a
network error must never close a live vulnerability. Mutation-checked, along with the rule
that fix-ready does not satisfy remediation.

Wired into the monitor rather than left as another orphaned script: the lifecycle runs
after classification, its state is persisted alongside the classified findings so
transitions and clocks survive between runs, and release-required findings appear in the
Slack alert with the reason they carry.

Verified end to end: log4j fixed in main but not deployed produces
fix-ready-release-pending, mitigation satisfied, remediation not, and three
release-required entries in the alert. 52 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ation

The three pieces left after the lifecycle. Each guards a way the system could look healthy
while not being it.

**Escalation (§3.3).** Four levels, so the alert changes before the date rather than after
it: ok, approaching, breached, undecided. The one that matters is `undecided`. §3.3 does
not say "escalate on breach" and stop - it requires a recorded decision, a revised date or
a risk acceptance, within five working days. Without somewhere to put that, a breach
escalates once and becomes background noise.

Decisions live in .soup-decisions.yml beside .soup-scope.yml and .soup-policy.yml, for the
same reason VEX statements live in the SOUP records: accepting a missed deadline on a
medical device should arrive as a reviewable change, not as a Slack reply. A decision that
has itself expired counts as undecided - it reads as handled while protecting nothing,
which is worse than none. Working days, not calendar days, or a Friday breach escalates on
Monday before anyone has had a working day to respond. An unreadable decisions file stops
the run rather than being ignored, because ignoring it would escalate every breach that is
in fact already handled.

**Currency (§6).** 0 major / 1 minor behind, per-SOUP justification honoured. Report signal
only - being out of date does not wake anyone up, it becomes urgent when it coincides with
a CVE. Only components carrying a SOUP record are checked: transitives move when their
parent moves, so flagging them produces a list nobody can act on. A registry that cannot be
reached yields "unknown", never "current".

Switched Maven from search.maven.org to repo1's maven-metadata.xml: the search API took
30-45s and timed out on two of three attempts, which would have left every Maven component
permanently unknown. repo1 answers in under a second. Verified against the live registries
across npm, PyPI, Maven and pub - okhttp 4.12.0 -> 5.4.0 flagged as a major behind,
requests 2.31.0 -> 2.34.2 as three minors, express justified by its SOUP reason.

**Backstop (§6.3).** Everything else reports what it found; this reports what it did not.
A product that stopped being scanned emits no alerts at all, and that is indistinguishable
from a product with nothing wrong - which is the one failure a daily alert can never
surface. It reconciles the evidence store for coverage, gaps between runs, undecided
breaches and expired decisions, and exits non-zero when action is required, because a
backstop that always passes is not a control. Synthetic runs are excluded: a test is not
evidence that a product was monitored.

The cadence-vs-reality check §3.4 asks for is *not* implemented - it needs the release
history rather than the evidence store. It is listed under `not_checked` in the output
rather than shipped as an empty array that reads like a clean result.

All three wired into the monitor; escalations and release-required entries appear in the
Slack alert. 70 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two different questions with two different answers, and the currency check only asked the
first one.

  behind  we are not keeping up. The answer is an upgrade.
  stale   *upstream* is not keeping up - no release in the staleness window. There is
          nothing to upgrade to, so the answer is replace, fork, or accept with a reason.

The combination that matters is current *and* stale: we are on the last version there will
ever be. That reports as `upstream-stale-and-we-are-current` with an explicit note that no
upgrade exists, because "left-pad is 0 versions behind" reads as healthy and is not.

The window defaults to 12 months to match what the SOUP records already use in grq-3 ("Is
maintained and support is available", 12-month analysis period), rather than inventing a
second definition of maintained. WI-006-33 already discourages taking on a SOUP that is no
longer maintained; this makes the same judgement continuously instead of once at approval.

Costs no extra requests: every registry already returns a publish date alongside the
version.

One correction that decides whether this works at all. npm's abbreviated document carries
`modified`, which changes on *any* metadata edit - a deprecation flag, an ownership change,
a re-signed tarball. Measured against it:

    request    modified 2026-07-17, last actual release 2020-02-11
    left-pad   modified 2024-04-16, last actual release 2018-04-09

Staleness read off `modified` would have let exactly the abandoned packages through as
fresh - the failure pointing in the worst direction. It now reads the publish time of the
latest version from the full document, which costs more bytes and answers the right
question. Verified: request 2364 days, left-pad 3037 days, both flagged with no upgrade
available; okhttp and requests still plain upgrades with active upstreams.

A missing or unparsable publish date yields "not stale-checked", never "recently released".

71 offline tests, and the four network tests pass too - including one that asserts the
abandoned-package age is over 1000 days, so a regression back to `modified` would fail it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two .pyc files were pushed. The test harness imports classify-findings.py and
check-currency.py to check their internals against reference values, which makes Python
write bytecode next to them, and the copy step picked it up.

Harmless in itself, but a compiled artifact tracked next to its source is the kind of thing
that later diverges from it and confuses a reader about which one runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previously listed as not implemented, on the grounds that the backstop reads the evidence
store while release history lives in GitHub. That was a boundary I drew and then treated as
a constraint. resolve-deployed already queries the same API, and the run record already
carries the repo name, so the only thing actually missing was the declared cadence - one
field. Adding it and the comparison closes the gap.

Track 3/4 deadlines are derived from the cadence, so a cadence that no longer holds does
not merely look untidy: every deadline derived from it is fiction, and the escalation built
on those deadlines escalates nothing.

**It counts production releases, not releases.** This is what makes the check worth having
rather than reassuring. alvie published six releases in ninety days and reads as a product
on a healthy monthly cadence - and its last *production* release was 405 days ago. The
others went to staging and to study builds. A Track 3/4 deadline is a remediation deadline,
and remediation is only satisfied on deploy, so the only cadence that can carry one is the
cadence at which things actually reach users. Counting all releases flipped alvie from
"broken" to "holds", which is the wrong answer stated confidently.

Production releases are identified the same way resolve-deployed.sh identifies them, by a
`-production` artifact, and the count falls back to all releases where a repo has no such
artifacts at all - a backend deployed from a plain tag has no marker and would otherwise
read as never releasing. When it falls back it says so in `counted`, because that count may
overstate how often the product reaches users.

Measured against the real repos:

  alvie       BROKEN        declared monthly, last production release 405d ago
  apellis     BROKEN        declared monthly, the repo has no releases at all
  mindnet     holds         2 production releases in 90d, last 12d ago
  osteocoach  holds*        counting all releases - study-phase, no production artifacts

Tolerance is 1.5x the declared interval: a cycle that slips by a third is normal, one that
has slipped by half has stopped being a cycle. An unreadable release history and an
unmeasurable cadence word both yield "unknown", never "holds".

73 offline tests; a network test pins the alvie case so a regression to counting all
releases fails rather than passing quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h ones are records

An SBOM only produced at the release tag means a new dependency is discovered at the
release tag. WI-006-03 wants its arrival to be a review event, which needs an earlier
component list to compare against. Every staging build is already tagged, so this needs
no new trigger.

The risk it introduces is that a staging document renders identically to a release one and
will eventually be forwarded as evidence. So the tier is stamped into the document:

- consolidate.sh writes quickbird:sbom:tier (defaults to branch, never release by accident)
- the PDF states it above everything else on page one
- publishing refuses to attach a non-release bundle to a release
- monitor-kev.sh refuses a non-release SBOM and reports the version as not scanned

That last rule is the point. A staging SBOM accepted by the monitor would create a dated
scan record for a version nobody deployed, making an unmonitored product look monitored.

Also fixes release publishing to attach the assessed bundle when one exists rather than
the bare component list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Helm template never carries the image version — values.yaml does. Discovery only read
the template line, so every image deployed via a chart came out as unresolvable, and a
scope file's only option was to exclude it. That loses exactly the coverage worth having:
third-party images are pinned in values.yaml, and they are the ones no build candidate
covers and no other tooling watches.

Dermafy, before and after:

  deployed-templated-statefulset-epa      ->  deployed-epa4all-rest-service-v1.2.4
  deployed-templated-statefulset-epa      ->  deployed-wireguard-1.0.20210914
  deployed-templated-deployment-redis     ->  deployed-redis-7-alpine
  deployed-templated-deployment-gid       ->  deployed-qb-gid-server-0.1.10

Three fixes were needed to get there:

- the reference was truncated at the first space, so `{{ .Values.x }}` became `{{` and
  every chart image collapsed onto one filename-derived id
- `.*\.Values\.` is greedy, so `| default .Values.version` won over the real key and
  resolved every image to the chart's default version: 1.0.0 — a plausible wrong answer
  that would have put v1.2.4 in an SBOM as 1.0.0
- an empty `tag:` that falls back to the release version is our own image. It stays
  unresolvable (DEV-196), but the note now names the repository, which is what lets a
  scope rule state which build candidate covers it

Verified: the mindnet and kontina-backend scope files still classify every candidate
(19/15 and 6/7, zero unclassified), so no committed scope decision is invalidated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… findings they surfaced

Every candidate in all four repos now has a recorded scope decision (11/9, 12/9, 12/14,
13/14 in/out, zero unclassified). Writing them turned up two things the tooling had wrong.

1. "Production release" has three signals in a GitHub repo and they disagree.

   alvie   tag_pattern       v1.0.7       2025-10-01
           prerelease_flag   v1.0.8-qa30  2026-05-04   (a -qa tag marked a full release)
           production_asset  v1.0.4       2025-06-23   (last -production artifact)

   315 days apart on one repo, and dermafy disagrees by 134. The backstop was using the
   asset heuristic alone, which is where my earlier "alvie last released 405 days ago"
   figure came from — that was an artifact of the heuristic, not a measurement.

   Track 3 remediation is "next release", so this choice sets a deadline. It is now
   configured per product (production_release.detect_by) and a disagreement is *reported*
   on every run rather than silently resolved: a disagreement means one of the three is
   unmaintained, and until someone says which, the cadence is not trustworthy.

2. apellis has no tags and no releases at all — it deploys every merge by git SHA via
   `helm --set`. Declaring it monthly would manufacture a deadline out of releases that
   never happen, and validate-policy rightly refuses an empty cadence. So
   `release_cadence: continuous` is now a first-class value, measured against the deploy
   history with a ceiling (max_deploy_gap, default 30d) instead of a cycle.

   Measuring it immediately found that none of apellis's 100 recorded deployments is to a
   production environment — every one is development. The status is therefore `unknown`,
   not `holds`: counting development deploys as evidence that remediation reaches users is
   the same mistake as counting QA releases as production ones.

Also in the scope drafts, flagged rather than decided: dermafy's Ansible web/keycloak roles
appear superseded by the Helm chart but no workflow proves it; dermafy's Superset stack
(apache/superset:4.0.2-dev, redis:7, postgres:15) is excluded from the *device* SBOM but
needs its own monitoring entry rather than falling between two files; osteocoach's
qbsdocker/epa-service has no version anywhere in the repo and no build candidate covering
it, so it is genuinely uncovered rather than covered elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erive the tier from the tag shape

Two fixes, one of them to a claim rather than to code.

The tier was derived from `github.ref_type == 'tag'`, which is true for v1.0.15-qa4 exactly
as for v1.0.15. So every tagged build was marked `release` and the "refuse to attach a
non-release bundle" guard never fired for staging. The separation existed in the
documentation and not in the code.

The tier now follows the tag shape, via production_release.tag_pattern — the same signal
§3.4 uses to decide which releases are production ones, so a project that redefines one
redefines both. The logic moved out of an inline action.yml expression into
scripts/resolve-tier.sh so it is testable; the expression it replaces was wrong and nothing
caught it.

Staging bundles are now attached to their own prerelease as sbom-<tag>.cdx.json, not kept
only as a 90-day workflow artifact. An expired artifact cannot be pulled when someone needs
to know what a build contained, which was the point of producing it. What keeps a staging
document from being mistaken for the record is the tier inside it and the banner on the PDF
cover, not where the file is stored — and the asset name carries the tag, so the two are not
confusable by name either. Only a `branch` bundle is refused: it has no version identity, so
nothing could resolve it back to a build.

The monitor's tier refusal was also too blunt and would have broken the products that need
monitoring most. What it requires is the document describing the version actually deployed,
and resolve-deployed.sh already establishes that — so the version identity is the guarantee.
If a product deploys a pre-release tag to production (dermafy's and alvie's release flags
suggest some do), its staging-tier bundle is the correct document for what is running. Now
only `branch` is refused, and the tier is written into the evidence record (`sbom_tier`) so
no reader has to assume which kind of document a scan rested on.

90 tests, 5 of them against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewing the classification draft against what is actually built found three places where
the document described behaviour that did not exist.

A breached deadline on a finding that happened not to be in KEV produced no notification at
all. The escalation block and §3.2's release-required signal both sat inside the
`verdict == kev-findings` branch, so they reached the run record and the workflow log and
stopped there — §3.3 step 1 ("escalated in the project's Slack channel") was a process step
that silently did not happen. The four alert blocks are now independent, and a breach is not
subject to the alert threshold: the threshold decides which *new* findings justify
interrupting someone, and a missed deadline is past that question.

Alert composition moved to scripts/compose-alert.sh. Same reason as resolve-tier.sh: this is
branching where a mistake is invisible, because the run still succeeds and simply says
nothing. Four tests, including that a quiet run stays quiet and that a KEV finding plus a
breach produce one message containing both.

§6 did not mention upstream staleness at all, though it is implemented — a component whose
own latest release is over 12 months old is unmaintained, which is a different finding from
being behind because there is nothing to upgrade to. Documented with the measurement caveat
that produced a wrong answer in testing (npm's `modified` for `request` reads 2026-07-17; its
last actual release was 2020-02-11).

§3.4 stood before §3.2 and §3.3; §5.1 and §5.2 were at heading level 2 and hung outside §5.

§3.4 now also covers what we have since learned: that "actual release history" is itself a
per-project declaration, and that a product may have no releases at all.

§9 records the 2026-08-03 decisions and four open items that the review surfaced rather than
resolved — including that Track 3/4 remediation has no computed deadline (210 of 521 findings
on a real product), which is blocked on whether a declared cadence or the tier ceiling
governs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ck 3/4 finally get a date

Track 3 remediation is "next regular release". Deriving that date from each product's observed
release rhythm does not work, and the portfolio shows why — "last release + interval" lands in
the past on three of four products:

  Mindnet     2026-07-21  ->  2026-08-21   in 18 days
  Osteocoach  2026-05-07  ->  2026-06-07   57 days overdue
  Alvie       2025-10-01  ->  2026-01-01   214 days overdue
  Dermafy     2025-10-15  ->  2026-04-16   109 days overdue

A finding discovered today at Dermafy would have been 109 days overdue on the day it was
found. That is a counter, not a control. And "discovery + interval" is worse in a different
way: it makes the security deadline a function of how slowly a product releases.

So a product now declares a commitment — a maintenance release at least every N days — and the
deadline is the next window on that grid. Three properties matter:

- the deadline is SHARED, so a missed window is one breach about a release rather than one per
  finding. On Kontina: 1 recorded decision instead of 196. This is the property that removes
  the rubber-stamp problem the previous model would have created on every slow-releasing
  product.
- a missed window advances the grid from its own due date, not from whenever a release
  eventually happens — otherwise not releasing buys time (§2.2's receding deadline).
- an early release resets the grid.

A finding lands in the first window at least its own mitigation period away, because a
remediation deadline earlier than the mitigation deadline is incoherent. Osteocoach's window
is in two days; a finding found today targets 2026-11-03, one found on 2026-06-01 targets
2026-08-05.

The tier now caps the COMMITMENT (Basic 90d, Extended 60d) instead of each finding's deadline.
That is where planned_remediation_ceiling went, and the move is what makes the model humane:
one product-level finding when a product cannot maintain its tier, instead of hundreds of
per-CVE acceptances. The cap is the one override that cannot be waived with a reason — it
caught the on-prem example immediately (Extended tier, 90d declared).

Result on real data: 0 of 521 Kontina findings now lack a remediation date, down from 210.
Track 3 and Track 4 share the same window, as intended.

Onboarding no longer imports history as breaches: windows that elapsed before a product was
monitored are recorded as history. Alvie and Dermafy have three each.

release_cadence is superseded and warns when present; it survives only as `continuous`, which
now means "the evidence of a maintenance event is the deploy history, not the release list" —
that is how Apellis, with zero tags and zero releases, still gets checked.

New: scripts/maintenance-windows.py (one implementation, loaded by the classifier rather than
duplicated) and tests/maintenance-window-logic.py, whose cases are the real release dates
because those are what broke the previous model. 96 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ngs are 2 actions

Stefan's point was that the mitigation times will realistically be much larger than 72h/20d.
Measuring why, rather than adjusting the numbers, gave a different answer: the numbers were
attached to the wrong thing.

kontina-backend, per-finding model: 23 mitigations due in 72 hours, 288 more in 20 days.
Grouped by the action that resolves them:

    521 findings  ->  2 actions
      422  inside Oviva's ePA REST service image
       99  inside linuxserver/wireguard:1.0.20210914
        0  in code QuickBird writes

Nobody mitigates 492 RPM CVEs; someone bumps one image and they close together. Same error
§3.4 removed from Track 3, same repair: findings resolved by one action form a remediation
unit, the unit inherits the worst track among its members and the EARLIEST of their deadlines,
so grouping can never move a deadline outward — only the number of decisions changes.

Getting the third-party case wrong first time is worth recording: grouping per package inside
an image we do not build produced ten separate "upgrade golang.org/x/crypto" items inside
someone else's WireGuard image, none of which anyone here can perform. Inside such an image
there is exactly one lever.

Also fixed, and it changes a claim I made earlier today: scan-vulns.sh never extracted fix
versions at all — affects[] carried only {id, ref}. So "0 of 521 findings have a fix version"
was my omission, not a property of the data. Measured now: 514 of 521 have a published fix,
6 have none, 1 undetermined. quickbird:vuln:fix records the tri-state per finding, with the
versions in affects[].versions per CycloneDX 1.6.

Two supporting changes:

- the rollup lives in properties, not as a custom key on the vulnerability object. A custom
  key is not valid CycloneDX, and the dedup step rebuilds vulnerabilities from a known field
  list — which silently dropped it for every finding that came from more than one advisory.
- consolidate.sh stamps quickbird:component:artifact on every component. Without it, "which
  action fixes this" is unanswerable and all 521 findings look like separate work.

102 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes agreed after measuring why the old deadlines could not be met.

1. KEV is its own track. "Actively exploited" is a state of the world; "CVSS 9.8" is a property
   of the vulnerability, and they had shared a 72-hour clock. Of kontina-backend's 23 Critical
   findings, none was in KEV — the clock was justified by a risk not present in any of them,
   which made the whole tier unmeetable and therefore ignorable.

       KEV        72h / 30d     unchanged mitigation: it is the one case where speed rests on
                                an observation, and from 2026-09-11 the CRA puts a 24-hour
                                reporting obligation beside it
       Critical   14d / 30d     was 72h / 21d
       High       30d / window  was 20d / 40d
       Medium     — / window    mitigation clock removed
       Low        — / window

   Track 3's mitigation clock stood at 30d across 196 findings on one product and meant
   "write a document". A control that only produces paper costs the attention the Critical
   findings need.

2. Escalation now happens per remediation action, not per finding. Without this the §3.5
   grouping achieved nothing: on kontina-backend with every deadline elapsed, 507 escalations
   become 2. Each unit names its member findings, so nothing is hidden — what disappears is
   505 lines demanding what was all the same decision.

3. waiting-on-vendor. Both kontina units are "bump or replace a third-party image", so the fix
   is on someone else's release schedule and a 30-day deadline breaches with certainty without
   anyone having done anything wrong. Four states, two of which are breaches:

       no-vendor-request       the deadline is counted against work nobody started
       waiting-on-vendor       dated request, live follow-up — NOT a breach
       vendor-overdue          follow-up elapsed; decide whether to REPLACE the image
       vendor-request-undated  a note, not a control

   The second needed care: a dated request with a live follow-up date IS the decision on
   record, including when the members are `undecided`. Requiring a further risk acceptance
   would ask someone to accept a risk they have already acted on and cannot remove. The
   follow-up date is what stops it being a parking space.

   My first cut got that wrong — `undecided` outranks `breached`, so the conversion never
   fired and a handled unit still reported as needing a decision.

Also: release-required (§3.2) now covers the KEV track. Omitting it would have excluded the
actively-exploited case from the one mechanism that can force an out-of-band release.

109 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion to back it up

Decided 2026-08-03. .soup-scope.yml, .soup-policy.yml and .soup-decisions.yml all stay in the
product repo; CODEOWNERS keeps the two QMS determinations in the policy file from being changed
in a feature PR.

What that leaves open is worth naming rather than hiding: validate-policy.sh can check that
`tier` and `cra_scope` are valid values, not that they are the ones QM determined, because it
has no second source. The review is the control — and CODEOWNERS is decoration unless branch
protection requires code-owner review, which is a per-repo setting.

So the backstop gets a compensating detection. It now reports when tier or cra_scope changed
between runs (`determination_drift`), and drift alone makes the verdict action-required. Both
fields move a real obligation:

  tier: Extended -> Basic      maintenance cap 60d -> 90d, backstop quarterly -> annual
  cra_scope: true -> false     the KEV alert stops saying a 24-hour reporting clock is running

A change is not necessarily wrong. Going unnoticed is.

Also adds examples/CODEOWNERS.snippet, which spells out what each owned file protects and why
.soup-scope.yml is deliberately NOT owned by QM — routing it through QM would make the scope
drift instead of keeping it current.

The monitored-product list has no per-repo home by construction: a product that was never
scanned cannot report itself. It belongs in the scheduled workflow that runs the backstop.

111 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I was about to invent a rule for assigning tiers. It is not a determination made here: the tier
follows from the customer's SLA, and the Basic/Extended vocabulary in §7 comes from the same
place (GDG-004-01). Assigning a tier is a lookup against the contract.

That has one consequence worth handling. The value in .soup-policy.yml is a *copy* of a
contractual fact, and a copy with no stated origin cannot be checked against what it copies. So
the file now carries `tier_source` naming the contract or service level it is taken from, and
validate-policy.sh warns when it is missing — a warning rather than an error, because a missing
reference must not block a monitoring run.

Neither this nor yesterday's drift detection prevents the copy drifting from the contract. Both
make it visible, which is the most the tooling can do when the authority lives in a document it
cannot read.

Also recorded in §9: the SLA is used twice for two different things, and they should not be
confused. It sets how intensively a product is maintained (the tier, §7), and separately its TTR
table governs first-party defect response in service hours (§3). Vulnerability tracks are
calendar time and a different obligation.

112 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…reference, not three

Clarified: all three are agreed with the customer in the SLA and then written into the project
config. None is determined in this process. I had started to invent an assignment rule for the
tier and was about to do the same for cra_scope; both were the wrong shape of question.

  tier                  how intensively the product is maintained (§7)
  cra_scope             whether the CRA 24-hour reporting obligation applies (§7)
  maintenance_interval  the commitment every Track 3/4 deadline hangs on (§3.4)

So `.soup-policy.yml` holds copies of contractual facts, and the file now carries a single
`sla_reference` naming the contract and its version. One contract, one reference — the
`tier_source` field from an hour ago is replaced rather than joined by two siblings, because
three references to one document are three things that can disagree.

validate-policy.sh warns when it is missing: a warning, not an error, because a missing
reference must not stop a run from producing the evidence that a product was looked at.

Drift detection extended to maintenance_interval and sla_reference. A changed interval moves
every Track 3/4 deadline, which is at least as consequential as a changed tier, and a changed
reference means the contract itself moved.

Neither check prevents the copy drifting from the SLA. Both make it visible, which is the most
the tooling can do when the authority lives in a document it cannot read.

Also recorded: the SLA is used for two different kinds of thing and they should not be confused
— these three values, and separately its TTR table, which governs first-party defect response in
service hours. Vulnerability tracks are calendar time and a different obligation.

113 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It should not have been added. My reasoning was "a copy should name its source", but applied to
a YAML file the field is a liability: the SLA gets amended, the line in the repo stays, and then
it asserts a provenance that no longer holds. That is the same failure this document argues
against everywhere else — a stale record that looks current is worse than none — and I built it
anyway.

It also added nothing. The control chosen for this file is the CODEOWNERS review, and a reviewer
who approves an SLA-derived value already knows the contract. The detection that catches an
unauthorised change is the backstop reporting tier / cra_scope / maintenance_interval moving
between runs, and that works without any provenance string. The format warning I added on top
would have fired on formatting rather than substance, which is how people learn to ignore
warnings.

What stays is the part that costs nothing: a comment in policy-defaults.yml and in each project
file saying that these three values come from the SLA and are not to be reasoned out here.
Guidance for whoever fills the file in, not a field anyone has to maintain.

112 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
grafele and others added 8 commits August 4, 2026 17:24
…round the components

Stefan questioned the scope sentence of the Work Instruction: it should be about keeping third-party
libraries under observation and handling their vulnerabilities, not about vulnerabilities as such.
That is right, and following it exposed a gap.

Framed around vulnerabilities, two findings have nowhere to live: a component several major versions
behind, and a component whose upstream stopped releasing. Neither produces a CVE. Both are
properties of the component. check-currency.py implements both tests, has 8 tests of its own, and was
never wired into any procedure. It now runs as stage 6 of assess-bom.sh, so both the tagged build and
the daily check perform it.

First run against kontina-backend surfaced findings that no CVE scan reports:

  wsdl4j 1.6.3                        no upstream release for 3332 days
  wildfly-client-config 1.0.1.Final   2902 days
  xsdlib 2022.7                       1469 days
  python-jose 3.5.0                    432 days

It also showed the check was unusable as delivered: 386 of 625 components came back as "currency
unknown", and 343 of those were rpm, deb and apk packages. No registry answers for a distro package,
and under §5.1 the base image is the SOUP and its packages are updated with the image, so they are
not individually subject to this policy. They are now excluded unconditionally. The report goes from
386 unknown to 43, which leaves the 55 real findings visible:

  checked 282 · beyond policy 55 · obsolete 91 · obsolete with no upgrade 80 · unknown 43

The Work Instruction now states the components as the subject and lists the four observed properties
with the section that handles each. New §11 covers currency and obsolescence, including the caveat
that npm's `modified` timestamp is not a release date (for `request` it reads 2026-07-17 against a
last release of 2020-02-11) and the one entry that needs a justification rather than an upgrade
(`listenablefuture 9999.0-empty-to-avoid-conflict-with-guava`, an intentionally empty placeholder).

Also in this commit: run-pipeline.sh now removes the raw syft output on the failure paths as well.
`continue` skipped the cleanup, so a failed scan left unnormalised files containing absolute paths.

131 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ate bug that hid the digest

Stefan asked whether the age is still being looked at. It was not. Excluding rpm, deb and apk from
the currency check removed the only signal that a base image was old, and no check replaced it:
container images carry no purl, so check-currency.py skipped them, and none of its registries covers
containers.

That mattered most for the case with real exposure. §5.1 makes the image the SOUP, so the currency
policy belongs on the image rather than on the packages inside it. A third test now applies the same
12-month window to the build date of the image, read from the OCI label
org.opencontainers.image.created, falling back to `created` in the image config. No extra network
call: the date comes from the scan that already runs.

Measured on kontina-backend, one image breaches:

  deployed-wireguard-1.0.20210914   built 2025-07-24
  deployed-epa4all-rest-service     built 2026-05-08
  docker-production-image-12        built 2026-07-14
  deployed-redis-8.8.1-alpine       built 2026-07-24

Which corrects a claim I have repeated all session. I described that WireGuard image as dating from
2021. Its build date is 2025-07-24: linuxserver rebuilds it regularly, so it receives current OS
packages, and the 2021 in the tag is the version of the WireGuard software inside it. Image build age
and packaged software version are separate findings. The first is testable; the second is not
testable generically, because no registry maps a vendor tag scheme to an upstream release, so it
stays a judgement for the project. Corrected in the WI, in §6 of the classification draft, and in the
Alvie and Dermafy scope files.

Building this surfaced a worse bug. consolidate.sh rebuilt each artifact component from scratch with
only bom-ref, type, name and version, dropping the subject's hashes and properties. So the image
digest added earlier never reached the consolidated bundle — the document that gets published as the
release asset — while the per-target files still had it. Checking those files is why the loss stayed
invisible. Both hashes and properties are now carried across, with a regression test.

Also added: --now to check-currency.py. Every other script in the pipeline has it, because an age
check without a fixed clock cannot be tested deterministically and the expected values drift.

135 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… in the WI

A Work Instruction is a generic procedure. Naming products in it dates the document immediately and
forces a revision of a controlled record whenever a figure changes. Roughly 40 product references
were removed: product names, tag numbers, component names, image names and every measured count.

The measurements were doing real work, so they are not deleted. They move to Annex A, Validation
measurements, which records what was measured and keys each figure to the WI section it supports:

  A.2  the three release signals disagreeing by 315 days, against the deployment record
  A.3  maintenance windows per product, and the deadlines the previous model produced
  A.4  522 findings resolving through 3 actions, none of them in code written here
  A.5  currency and obsolescence, before and after excluding OS packages, plus image build ages
  A.6  discovery candidates and scope decisions per product
  A.7  product-specific open items with their tickets
  A.8  SOUP record field usage across all 39 records
  A.9  the defects found during implementation, each of which was silent

In the WI, the product-specific passages became statements of the rule. §15.2 replaces the list of
per-product open questions with the five classes of decision the process requires and cannot make
itself. The two diagrams that carried real dates and counts now show the shape without the numbers.

Also corrected in the WI: §9.3.1 had never been reached by the earlier prose pass, so it still
carried both a product name and the old phrasing.

135 tests, 5 against live feeds. Both documents validate as HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the mobile builds already use

The evidence store was the one blocking gap: dated run records existed only as workflow artifacts
with 90-day retention, so an annual backstop could not reconcile its own review period and the
records were not controlled.

Rather than introduce a new store, this uses the one already in place for the Android and iOS build
files: the quickbird-artifacts DigitalOcean space in fra1, written with the same third-party action
that upload-artifact-qb wraps.

  kev-monitor      <repo>/soup-evidence/<year>/YYYY-MM-DD-<product>.json
  soup-discovery   <repo>/soup-sbom/<tier>/<ref>/sbom-<ref>.cdx.json  (+ .pdf)
  backstop         <repo>/soup-backstop/<year>/backstop-report.json

Both uploads are conditional on the credentials being present, so a product without them keeps
working and the backstop reports the coverage it cannot see rather than treating a missing history as
clean. The workflow artifact stays as the convenient copy for a specific run; the store is the
durable one.

The year and tier segments are there so a reconciliation syncs one prefix instead of the whole space,
which also holds the mobile build artifacts. The tier segment keeps candidate documents, which may
become release evidence, apart from staging and branch documents, which cannot.

patches/soup-backstop.yml is new: the reusable workflow that syncs the history back out and runs the
reconciliation. The product list lives in that workflow on purpose, because a product that was never
scanned cannot report its own absence.

Two things found while writing this:

- I pinned the upload action to `@v2` without checking. That tag does not exist: the action publishes
  only exact versions. Pinned to v2.0.146, its newest release, which is from 2024-02-12 and would
  itself be an obsolescence finding under §11 if it were a product dependency. The existing
  upload-artifact-qb action uses `@latest` for the same upload, which is worth revisiting separately.
- My first draft embedded a Python heredoc inside a YAML `run:` block. A quoted heredoc does not
  strip leading spaces, so the indented body would have failed with IndentationError. Replaced by the
  existing action, which removes the custom uploader and a pip install with it.

§7 and §12 of the Work Instruction now state which location serves which purpose, and §15.1 replaces
the blocking storage item with the two things that remain open: distributing the credentials, and the
retention period, which is a QMS determination and decides whether the store alone suffices given
that it provides no object locking.

135 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and the per-project parameters

Read WI-006-04-03 and its siblings to get the format right: short prose, everything in tables,
responsibilities by RACI role rather than by name, definitions delegated to the glossary, a "required
information for the next stage" column, a change log, the standard footer. The previous draft was an
essay by comparison.

New §3 derives the process from the obligations, requirement by requirement, which is what was
missing. Both BSI guidelines turn out to map almost one to one:

  BSI TR-03161, Prüfaspekt (4) Drittanbieter-Software
    O.TrdP_1  central and complete list of dependencies      -> the inventory, with named gaps
    O.TrdP_2  newest version or the one preceding it          -> currency parameters
    O.TrdP_3  analyse ALL known vulnerabilities for effect    -> daily observation + VEX
    O.TrdP_4  security concept defining tolerated use         -> the tracks and timeframes
    O.TrdP_5  check the source before use                     -> grq-5
    O.TrdP_8  unmaintained software MUST NOT be used          -> obsolescence check

  BSI TR-03185 Sicherer Software-Lebenszyklus
    PROD.DEV.G.6/G.7   no outdated, avoid unmaintained       -> currency and obsolescence
    PROD.DEV.L.2       provenance, e.g. as SBOM              -> the inventory
    PROD.FIX.A.1       time windows, considering public
                       knowledge, existing exploits, and
                       measures instead of a patch           -> KEV, EPSS, and the two clocks
    PROD.FIX.A.4       notice vulnerabilities in third-party
                       libraries, check susceptibility       -> daily observation + VEX
    PROD.FIX.A.7       severity via a scoring system          -> the classification
    PROD.FIX.A.8       defined handling, residual risk level  -> risk acceptance, window as deferral

TR-03161 is stricter than the process default in two places, so `regulatory_scope` is now a parameter
that tightens checks rather than a field that documents an intention:

  O.TrdP_2 requires newest-or-preceding, while the default tolerates unlimited patch drift. A product
  in TR-03161 scope must state a patch limit; a configuration that does not is refused.

  O.TrdP_8 states a prohibition, so accepting obsolescence with a recorded reason is not available for
  such a product, and a configuration that permits it is refused.

And one requirement this process cannot answer, now stated as a deviation: O.TrdP_4 requires the
application to refuse to operate — part 3, the backend to be deactivated — once the grace period has
elapsed. This process defines the grace period. The behaviour has to exist as a software requirement
per product.

New §4 lists every parameter a project defines, in six groups, each with its meaning, who decides it
and where the value comes from. That was the second thing missing.

The previous document became Annex B, Technical implementation: the stages of each run, the files
produced, the rules the tooling applies. Where it and the WI differ, the WI governs. Annex A keeps the
measurements. The WI now contains no product name, no file name and no script name.

Sources for §3: BSI TR-03185 and BSI TR-03161 parts 1 and 3, read directly.

139 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the identifiers

Moving all of it out went too far. The WI now carries the technical shape at process level and Annex B
only what it alone holds.

Back in the WI:

  §1.2  when observation happens — the four points in time, what each asks, and which record it
        produces. With the dependency drawn: stage #3 produces the document stage #4 reads, stage #4
        produces the records stage #7 reconciles.
  §6.2  the maintenance window grid, drawn. A finding reported between two windows falls due in the
        second one, and a missed window does not move the grid.
  §7.1  the steps inside the two automated stages, in order, because the order is what makes them
        auditable: an unlisted artefact stops the run before anything is scanned, an image is recorded
        by its digest rather than the tag that was requested, and the deployed version comes from the
        deployment record rather than from the newest tag.
  §7.2  which build produces evidence — candidate, staging, branch, and what each may be used as.

Still no file name, script name or configuration key in the WI: those are Annex B's job and repeating
them in a controlled document means two places to update.

Annex B went from 1195 to 356 lines. Nine of its sixteen sections had become duplicates of WI sections
after the restructure — classification, deadlines, what carries the deadline, currency, escalation,
roles, the overview, the limitations, the review items. Two documents stating the same rule is how they
drift apart, so those are gone and what remains is the reference: which script does what, which files a
run produces, the three files a project owns, the configuration keys with their defaults, the object
store prefixes, the `quickbird:` properties recorded in the document, and the implementation limits.

Annex A's cross-references pointed at the old numbering and are remapped to the WI.

139 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…as an input

Answering "when does which action run" turned up that the answer was: none of them. No repository in
the org calls soup-discovery or kev-monitor, and the manual path was broken in two of three cases.

  soup-kev-monitor.yml declared workflow_call AND workflow_dispatch in the same file. Inputs then
  come from whichever trigger fired, so a manual run got product='' and runs-on='', and
  fromJSON('') fails the job before a step runs. The dispatch belongs in the product caller — which
  is how soup-version-check.yml has always been wired.

  soup-backstop.yml had no manual trigger at all, while Annex B claimed "manual or scheduled".

  Nothing called soup-discovery from anywhere.

So the files are split by destination, because a workflow in the wrong repository does not run:

  patches/workflows-repo/   -> QuickBirdEng/workflows/.github/workflows/, workflow_call only
  patches/product-repo/     -> <product>/.github/workflows/, and these own the triggers
  patches/README.md           which file goes where, and the order to wire a product in

New soup-sbom.yml calls soup-discovery, which had no caller. Its trigger is workflow_run on the
product's release workflow, not push:tags — the release workflow is what pushes the images of the
tag, and the pipeline scans an image by pulling it, so starting alongside it would record every one
of our own images as a gap.

That exposed a defect in soup-discovery: it derived the tag from github.ref_name. Under workflow_run
the ref is the default branch, so the bundle would have been attached to a release named "main" and
stored under soup-sbom/branch/main — and resolve-deployed.sh, which looks for sbom-<tag>.cdx.json on
the release, would never find it again. The tag is now an input, resolved once, and used for the tier,
the release asset and the object key. Defaulting to github.ref_name keeps a tag-triggered run working
unchanged.

Annex B B.1 stated triggers that were not true; it now states the three-repository split, why a
reusable workflow must not declare workflow_dispatch, and why the SBOM workflow waits for the release.
WI §9 gains the deviation: nothing is scheduled yet, so no timeframe is actually running.

139 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-inflicted and it would have been the first thing a reviewer saw. The local working directory
holds only the new SOUP tooling, not a clone of this repository, and the branch was being built by
`rsync -a --delete` from it — so every push deleted every action the working directory did not
contain: flutter-coverage, js-supply-chain-check, all trufflehog-*, setup-*, publish-*, the existing
soup-* actions, plus .github and .gitignore. 108155 deletions across 636 files.

Restored from main. The branch now only adds: soup-discovery, kev-monitor, kev-epss-enrichment,
soup-fix-or-vex, patches, and the process documents. main's .gitignore is kept, with the __pycache__
rule appended rather than replacing the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@grafele
grafele requested a review from nasirky August 5, 2026 14:04
grafele added a commit to QuickBirdEng/workflows that referenced this pull request Aug 5, 2026
Counterpart to QuickBirdEng/actions#51, which adds the actions these call. Each product repository
gets a thin caller that owns the trigger — the same shape as soup-version-check.yml.

  soup-sbom.yml          produce the inventory of a tagged build and assess it
  soup-kev-monitor.yml   daily: is the version in production affected today
  soup-backstop.yml      periodic reconciliation of what did not happen

`on: workflow_call` only, deliberately. A reusable workflow that also declares workflow_dispatch
takes its inputs from the dispatch form, so `product` arrives empty and `runs-on` arrives as an empty
string — fromJSON('') then fails the job before any step runs. The manual trigger belongs in the
caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
grafele and others added 2 commits August 5, 2026 16:25
Two changes, both about what belongs in shared tooling.

The Work Instruction, its two annexes and the working draft are gone from the repository. A controlled
document belongs in the QMS, where it has a review workflow and a single version; a copy in a code repo
is a second version that drifts. The 99 section references in comments pointed at the draft's
numbering, so they are remapped to the document that governs — §3.4 -> WI §6.2, §3.5 -> WI §6.3,
§3.6 -> WI §4.5, §5.1 -> Annex B B.1.1, and so on. Verified afterwards that no bare § reference is
left except IEC 62304 §8.1.2, which is a different document.

Every ticket number and every product name is out of the comments, out of the READMEs and out of the
examples. Tests keep theirs — a regression test names what it reproduces. The measured facts stay,
because they are the reason the values are what they are; only the attribution goes:

  "0 of kontina-backend's 23 Critical findings were in KEV"
    -> "On one backend product, 0 of 23 Critical findings were in KEV"

Product-specific files moved out of the repository entirely, because that is where the WI already says
they live:

  six per-product scope and policy drafts   -> the product repositories
  the deploy-workflow patch for one product -> that product's repository

`examples/` now holds three generic templates instead: a policy, a policy showing a justified
relaxation, and a scope declaration whose entries are the cases worth showing — an image that is in
scope in one artefact and out of scope in another, our own release-versioned image, a builder stage,
infrastructure.

soup-discovery/README.md was largely a validation report against named repositories. That content is in
Annex A. It is now a usage document: how to call it, what the scope gate refuses, what a run produces,
how it exits, and what it cannot do.

139 tests, 5 against live feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review verdict was: a dependable assessment core, and three provable critical defects in
exactly the layer with no tests — the seam to the outside world. All fixed, each with a
regression test, 154 passing.

Critical:

  resolve-deployed.sh passed --arg through `gh api --jq`, which takes one expression and
  forwards nothing. gh refused the call, `|| echo ""` ate the refusal, and every deployed tag
  reported "no SBOM asset" — the daily monitor could never have fetched a real inventory. The
  comment at the top of the same file warns about exactly this flag. The lookup now pipes to
  jq, and a fake `gh` in the test suite makes the whole resolution path testable at all.

  monitor-kev.sh filtered targets on /prod/ while the backstop counts prod|study. A
  Study-only product fell out of the targets AND out of the unscannable list: the record said
  all_clear with nothing scanned. The filter now matches the backstop, and all_clear
  additionally requires that at least one target was actually scanned.

  soup-sbom.yml (reusable) never passed release-tag to the action, so the entire
  workflow_run path — the normal trigger — produced branch-tier bundles that were never
  attached to a release. One line, and the one the review almost had to find twice.

High:

  check-currency.py validated the patch limit and never measured it: the comparison looked at
  major and minor only, so the TR-03161 O.TrdP_2 tightening existed in config validation and
  nowhere else. All three levels now compare, with `unlimited` handled safely.

  scan-vulns.sh exited 0 with an incomplete list when an OSV chunk failed, and dropped a
  finding entirely when its advisory could not be fetched. Now: retries on every OSV call,
  querybatch pagination for >1000-vuln packages (kernel RPMs are real), an unfetchable
  advisory is carried unscored (rule 9) instead of dropped, and incompleteness is exit 1 —
  the monitor turns that into an `incomplete` verdict, which is the honest answer.

  monitor-kev.sh kept one state file for all targets, so a product with an app and a backend
  restarted the second target's clocks on every run. State, lifecycle and escalation are now
  per target, merged into one product view for the record and the alert.

  merge-assessment.sh applied a VEX statement keyed on the CVE alone, so a not_affected
  recorded for package A suppressed the same CVE on package B. The analysis is now applied
  only when every affected component is covered by its own record; partial coverage becomes a
  named property instead of a suppression. Justification codes are validated against the
  CycloneDX vocabulary here and in the fix-or-VEX gate — an invalid code was a working mute
  button.

  resolve-deployed.sh read the first 100 deployment records client-side; a rarely-deploying
  environment fell off the page and vanished without a trace. Environments are now enumerated
  server-side and queried per environment, the same access pattern the backstop already uses.

Medium, in brief: group-remediation compared the kev field `is True` against data that is
always a string, so units never listed their KEV members (the fixture had encoded the bug —
it used a boolean the production data never has); a CVSS-4-only advisory fell to rule 9
regardless of severity and now routes through the database severity band; `onboarded`
compared as an instant, so a scan on the onboarding day missed the baseline for the whole
backlog — dates compare now; the alert hardcoded "remediation 21 d" from a superseded policy
revision and referenced record fields nothing wrote; discover.sh ids collided across equal
basenames and the second scan source silently vanished — ids are path-based now, and the six
per-product scope drafts were re-verified against their live repositories (five needed only
renames; apellis surfaced one genuinely new candidate, the digest-pinned GPU inference image,
now classified); validate-policy refuses unknown keys, because a typo in an override
otherwise does nothing silently.

Hardening: action inputs reach scripts through env instead of ${{ }} interpolation in run
blocks; a failed Slack post fails the step instead of leaving an annotation nobody reads;
reportlab installs on PEP-668 runners; docker and python3 join the prerequisite check; the
yq variant is verified once in validate-policy (everything here needs mikefarah v4); scope
includes need reasons like excludes always did.

One decision surfaced to the document rather than the code: WI §1 claimed all timeframes are
calendar time while §7 #6 says five working days. The code counts working days, with reasons;
the WI now states the exception explicitly (v0.4).

154 tests, 5 against live feeds. Every fix above has one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
grafele added a commit to QuickBirdEng/workflows that referenced this pull request Aug 5, 2026
…published without it

Counterpart to the code-review fixes in QuickBirdEng/actions#51. The action learned a
release-tag input because github.ref_name is the default branch under workflow_run, but this
workflow never passed it: the whole normal trigger path produced branch-tier bundles that
were never attached to a release and were stored under the branch prefix. `version` is
documented as the release tag, so it is passed through as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@grafele

grafele commented Aug 5, 2026

Copy link
Copy Markdown
Author

A full code review of this branch ran on 2026-08-05; everything it found is fixed in 7e03df7, each finding with a regression test. Summary for review focus:

Three critical defects, all in the layer that had no tests (the seam to deployment records and release assets):

  1. resolve-deployed.sh passed --arg through gh api --jq, which takes one expression and forwards nothing — gh refused the call, || echo "" swallowed it, and every deployed tag reported "no SBOM asset". The daily monitor could never have resolved a real inventory. Now piped to jq, and a fake gh in the test suite makes this path testable at all.
  2. monitor-kev.sh filtered targets on /prod/ while the backstop counts prod|study. A Study-only product fell out of both the targets and the unscannable list — the record said all_clear with nothing scanned. Filter aligned; all_clear now also requires that at least one target was scanned.
  3. The reusable soup-sbom.yml never passed release-tag to the action, so the entire workflow_run path (the normal trigger) produced branch-tier bundles that were never attached to a release. Fixed in SOUP: three reusable workflows for third-party component monitoring workflows#54 (85227d3).

High: the TR-03161 patch limit was validated but never measured (check-currency.py compared major/minor only); scan-vulns.sh exited 0 on incomplete OSV results and dropped findings whose advisory fetch failed (now: retries, querybatch pagination for >1000-vuln packages, unfetchable advisories carried unscored, incompleteness is exit 1); the monitor kept one state file for all targets, restarting the second target's clocks daily (state/lifecycle/escalation now per target); a VEX not_affected recorded for package A suppressed the same CVE on package B (analysis now applies only when every affected component is covered by its own record), and justification codes are validated against the CycloneDX vocabulary — an invalid code was a working mute button.

Also fixed: units never listed their KEV members (string compared with is True — the old test fixture had encoded the bug); CVSS-4-only advisories fell to "unscored" regardless of severity; the onboarding baseline missed the whole backlog when the first scan ran on the onboarding day; stale hardcoded deadlines in the Slack alert; discovery id collisions across equal basenames (ids are path-based now — the six per-product scope drafts were re-verified against their live repos; apellis surfaced one genuinely new candidate, its digest-pinned GPU inference image); unknown policy keys are refused; action inputs reach scripts via env instead of ${{ }} interpolation; a failed Slack post fails the step.

Suite: 135 → 154 tests, 5 against live feeds, shellcheck and pyflakes clean.

grafele and others added 5 commits August 5, 2026 20:26
The pipeline supports SOUP_POLICY_FILE and nothing in CI ever set it. The action had no
policy input, so the assessment stage — vulnerabilities, classification, currency, units —
silently skipped on every CI run, leaving a components-only bundle where the WI requires an
assessed one (§7 stage #3 step 6). The local runs all passed the policy by hand, which is
why 154 tests and a full review missed it: the gap was in the wiring between the action and
the pipeline, visible only on a real runner.

New `policy-file` input, default `.soup-policy.yml`, resolved against the repo root. Absent
file keeps the existing warning-and-skip behaviour, which is right for a product that has
not adopted a policy yet.

Also from the same run: the private-repo asset download. The monitor fetched release assets
via browser_download_url with a bare curl, which only answers an authenticated browser
session — on this org every repo is private. resolve-deployed.sh now emits the API asset
URL and the monitor downloads it through gh with the octet-stream Accept header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st entry

Third finding from the end-to-end runs, and the same class as the first two: silence where
a reason belongs. With `permissions: contents: read` the environments list answers but
every per-environment deployments fetch returns 403; the `|| continue` dropped Production
from the answer entirely, and the run record showed only the mobile target — an
incomplete verdict for a reason nobody could see.

Three changes:

  resolve-deployed names an unreadable environment ("token likely lacks the deployments
  permission") and an environment whose records are all non-tag refs ("nothing states
  which application release runs here"), instead of dropping either. The specific reasons
  also survive the nothing-at-all early exit, which used to replace them with the generic
  "nothing states what is running".

  the monitor's unscannable list now applies the same production filter as its targets —
  a Development environment without an SBOM must not keep every record at `incomplete`
  forever, because an alert that always fires carries no information.

  the e2e caller grants deployments: read, which is what the real callers in
  patches/product-repo/ inherit via their documented permissions block.

157 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit said the callers carry this; now they do. Without the block the org
default decides, and where that default is restricted the monitor and the backstop read an
environment list they cannot follow into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth runner finding, and the subtlest: /environments answers 403 for the workflow token
even with deployments:read granted, and gh api prints the RESPONSE BODY to stdout on an
HTTP error. Captured without checking the exit code, {"message":"Resource not accessible
by integration"} became an environment name; the filtered query for that phantom politely
returned [], and every real environment vanished with no error anywhere — the third run's
record showed only the mobile target while Production had a hundred readable records.

The environments listing is now captured only on exit 0 and parsed defensively; when it is
refused, the environment names are derived from the newest 100 deployment records (the
per-environment queries themselves work with deployments:read — proven by the same debug
run). An environment quiet for longer than those records is stated as invisible rather
than implied absent. The status lookup had the same stdout-on-error shape and now goes
through a pipeline that pipefail guards.

The test fakes now model real gh — error body on stdout AND exit 1. The earlier fake
modelled only the exit code, which is exactly why the phantom-environment path survived
the previous regression test.

158 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t 128KB

Fifth runner finding, and the reason the record kept coming back empty after every other
fix: the per-environment pages were folded together via --argjson, which passes the whole
growing document as ONE process argument. Linux MAX_ARG_STRLEN is 128KB; one hundred real
deployment records are comfortably past that. jq died with "Argument list too long", the
failed command substitution left an empty string, and everything downstream ran through
empty — no error in the output, because the fetches themselves had all succeeded. The same
script passed on macOS, whose per-argument limit is larger, which is why 159 local tests
and four runner iterations were needed to corner it. Only running the script on the runner
with stderr visible showed it.

Pages now land in files and are folded with jq -s; downstream, only the five fields the
grouping needs survive, so nothing later can grow past any argument limit. The suite gains
a fixture with a ~400KB environment page — it reproduces the failure on any Linux machine
the tests run on, and pins the file-based path everywhere else.

159 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@grafele

grafele commented Aug 6, 2026

Copy link
Copy Markdown
Author

End-to-end verified on a real runner — six iterations on a temporary branch (mindnet@soup/e2e-test, workflow runs only there, touches no releases or stores; delete after merge). Both composite actions pinned to this PR branch. Final state: both jobs green, and the monitor record on the runner is byte-for-byte what the design says it should be:

"verdict": "incomplete",
"not_scanned": [
  {"name": "Production", "version": "v1.0.15", "why": "release exists but carries no sbom-v1.0.15.cdx.json — released before the SBOM pipeline, …"},
  {"name": "mobile",     "version": "v1.0.15", "why": "production release carries no SBOM asset"}
]

That is the honest pre-adoption answer; it becomes an all-clear the moment a release carries its SBOM asset. The SBOM job assessed the full repository on the runner: 34 candidates, scope gate 20/14/0, 5442 components, 1725 findings → 118 remediation units, 0 in KEV, three grq-4 contradictions surfaced against real SOUP records.

The iterations found five defects no local test could reach, all fixed on this branch with regression tests (159 total now):

  1. 35bb128 — the action never passed a policy to the pipeline, so no CI bundle had ever been assessed. Local runs always passed it by hand.
  2. The runner lacked gh; the prerequisite check caught it loudly (runner provisioning is the durable fix; the e2e bootstraps a pinned binary meanwhile).
  3. 3fa73bd — with contents: read only, the per-environment deployments fetch 403s; the old || continue silently dropped Production. Environments the token cannot read are now named gaps, and the callers grant deployments: read.
  4. 670bb47gh api prints the HTTP error body to stdout; captured unchecked, {"message":"Resource not accessible…"} became a phantom environment name and every real environment vanished errorlessly. Error bodies are never accepted as data now, and the env names fall back to the deployment records themselves.
  5. 8e44649 — Linux caps a single process argument at 128KB (MAX_ARG_STRLEN); folding deployment pages via --argjson blew it, jq died, and the empty substitution emptied every downstream list. macOS has a larger limit, which is why 150+ local tests never saw it. Pages accumulate in files now, and a ~400KB fixture reproduces the failure on any Linux machine the suite runs on.

Pattern across all five: the wrong answer looked exactly like a right one. Every fix converts silence into a named reason.

grafele and others added 9 commits August 6, 2026 10:52
Tag e2e finding: the attach step uses gh, the prerequisite check did not know that, and a
runner without gh ran the full six-minute pipeline before failing on the upload. The check
now requires gh exactly when publish-to-release is on, so the failure moves to the first
second and names the tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three gaps remained after the e2e runs, all provisioning rather than code. Two close here:

  gh: both reusable workflows now bootstrap a pinned gh into the runner tool cache when the
  runner has none — one 10MB download, persistent on self-hosted, a no-op everywhere else
  and a no-op from the start once the runner image ships gh.

  maven: the pipeline prefers the module's own ./mvnw over a runner-provisioned maven, so
  `mvn wrapper:wrapper` in a module closes its gap without touching any runner. The gap
  message now says exactly that.

The third — the Android closure needs the built APK — is wiring, not tooling: the caller
template documents the SBOM_ARTIFACT_app_android hand-over from the release's own build
artifact. Whether to wire it is a per-product decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… claim

Measured on a real staging bundle: syft reads zero components out of an AAB, because dex
bytecode carries no package metadata. Wiring the built artifact in would therefore replace
the named gap with an empty inventory that reads as covered — the exact failure mode this
pipeline exists to prevent. Discovery now routes the android candidate to the gradle
lockfile when dependency locking is enabled, and the gap message states the one command
that turns it on. The Dart side was always covered by pubspec.lock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a lockfile

Gradle persists locks only for configurations where locking is activated; without the
dependencyLocking block the command silently writes nothing — one more wrong answer that
would have looked right. The message now states the actual sequence, and which half of the
closure is already covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot an orphan

Analysing the twelve "matches no component" records on a real product showed the message
lumped three different situations together:

  four records named images that sat fully scanned in the same document — the join matched
  record.package against component names only, and image artifacts are named after the
  candidate id, not the image. Records now also match on the scanned reference
  (quickbird:scan:target), so keycloak finds quay.io/keycloak/keycloak and the approval
  lands on the artifact component.

  two records named things the build ships in a DIFFERENT version family — wireguard
  approved as 1.0.20241014 with 1.0.20210914 deployed, node approved as 24.x with 22.x
  live. That is approval drift, not a stale record, and it now gets its own count, its own
  metadata property in the evidence document, and a message that names both versions
  (WI §7 #4 review event).

  the genuinely absent ones remain orphans.

162 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places, matching the three consumers:

  the component itself carries quickbird:soup:approval-drift — whoever opens the document
  at the component must not have to hunt the metadata to learn that an approval exists and
  does not cover this version;

  the PDF renders the mismatches as their own red section next to the orphans, one line per
  record with both versions;

  the dated monitor record carries the result of the approval check per scanned target
  (matched / orphaned / version_mismatch) — WI §7.1 stage #4 step 5 names that check, and
  the record is its evidence, so a log line nobody retains was not enough.

Verified against the real product document: the drift lands on the deployed wireguard image
(approved 1.0.20241014, shipped 1.0.20210914), both node binaries and their images, and the
three Dart-side records.

162 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esolved to-do

"open: version-check" showed the requirement key and hid the justification sitting right
next to it in the record — a documented, approver-signed deviation looked identical to the
one case that actually blocks (no reason recorded). The PDF now prints the reason inline,
trimmed, and reserves red bold for a missing one. Asked about by exactly the reader the
table exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four changes, all requested by the person the document is for, all fed from the bundle so
the PDF stays a pure function of it:

  the SOUP assessment table gains a Latest column. check-currency now annotates every
  checked component with quickbird:currency:latest/:status/:detail — including the ones
  that are fine, which the report file never carried. The column made its own case on the
  first real render: the mantine records reason "will move to 9 once stable" now sits next
  to a Latest of 9.5.1.

  an unmet requirement states what it asks, what holds instead, and the recorded reason —
  "not met: version-check (Is the latest Major …). Shipped: 8.3.15, latest: 9.5.1.
  Reason: …" instead of a bare key.

  a grq-4 contradiction lists the High+ findings behind the count, each linked to its
  advisory.

  the vulnerability table shows the classification (track, score, both dated deadlines,
  overdue in red) instead of only a vector string — the classifier now stamps
  quickbird:finding:* onto the vulnerabilities in the bundle, which Annex B B.7 had
  documented before it was true — and a Where column names the affected component and the
  artefact that carries it, with the CVE id linked.

164 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Orange on every behind/stale value made a clean 7/7 approval look like a finding and
painted half the table. The colour now follows the record state — exactly the rows where
the newer version is part of why a requirement is open. The currency status itself stays
in the bundle properties for anything that wants to filter on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant