SOUP: dependency discovery, SBOM per build, and daily monitoring of the deployed version - #51
SOUP: dependency discovery, SBOM per build, and daily monitoring of the deployed version#51grafele wants to merge 82 commits into
Conversation
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>
|
End-to-end verified on a real runner — six iterations on a temporary branch ( "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 (
Pattern across all five: the wrong answer looked exactly like a right one. Every fix converts silence into a named reason. |
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>
… presence of a record "Direct dependency" was defined as "carries a SOUP record" — a proxy that could not fail in either direction: a deliberately chosen library without a record hid among the transitives, and the coverage figure (direct libraries with an approved SOUP) was true by construction. The manifests have carried the answer all along. mark-scope.py reads it per ecosystem and stamps quickbird:dependency:scope onto every component it can decide: pubspec.lock marks every entry itself; npm is the join of dependencies/devDependencies across every package.json that resolves against the scanned lockfile (workspace members included — that is why discovery folds them into the root candidate); maven takes the declared <dependencies> of the module pom; go.mod requires minus "// indirect"; gradle the declared coordinates in the module build.gradle, because the lockfile mixes both. Container contents are transitive; the image itself is the deliberate choice and its artefact subject is stamped direct at consolidation. Anything the manifests cannot answer stays undetermined rather than guessed. Two consumers change with it: merge-assessment gains the finding this step exists for — a component marked direct with no SOUP record behind it: chosen, shipped, never approved. Counted in the metadata, named per component, warned per run, fatal under ASSESSMENT_STRICT. The coverage figure can now fail. check-currency selects the components to check by scope (records still count — an approval implies a choice), falling back to the old record-based rule for documents without scope information. The daily record carries the new counter next to orphaned and version-mismatch. 171 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…slint The manifest-read scope marked devDependencies as direct, and the very first run on a real product flagged 100 direct-without-record findings: babel, eslint, test frameworks — build tooling that is deliberately chosen and sits in the lockfile, but ships in no product and rightly carries no SOUP record. A finding that fires a hundred times wrongly buries the case it exists for. scope is now direct | dev | transitive: pubspec's own "direct dev" marker, npm's devDependencies (direct wins when a name is in both), maven test/provided scopes. The direct-without-record finding and the currency selection consider only `direct`; dev tooling stays visible in the inventory under its own value. 171 tests, dev cases pinned for all three parsers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wireguard appeared on the first real run as approval drift AND as direct-without-record — the same record, counted twice. Without-record now means what it says: no record matches the name at all; a record whose family does not cover the shipped version stays what it is, drift, reported once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e VDR assesses it The approved report split, implemented. render-bundle-pdf.py is replaced by two renderers: render-sbom-pdf.py — composition only, one per release, nothing that ages: direct dependencies with supplier/licence/record, transitive and OS components as an ecosystem aggregate, scanned artefacts with digests and build dates, the CISA document metadata (producer, tool, format, lifecycle, document hash). render-vdr-pdf.py — the dated assessment against the configured rules. Opens with an applied-rules block stating every rule with its value and its source (config vs default, derived from the project policy file); then updates beyond and within the limits, CVEs per library sorted by severity with linked ids, a Fixed-in column and VEX state, stale/deprecated, and the remediation actions with their deadlines. Rows are shaded by decision state: rule violated without an accepted decision, open within the process, or plain. Feeding them, check-currency now takes supplier, licence and the registry deprecation flag out of the registry document it already fetches per package — one request, four columns. Supplier and licence land in the CycloneDX standard fields; a deprecated declaration is its own currency status and a warning. "current" now means current: an available update inside the limits is update-available, because the report lists it. consolidate stamps the document-level CISA fields (supplier, lifecycle post-build, tools with the pinned syft version). The release gains a third asset: sbom-<tag>.pdf is the SBOM report, vdr-<tag>.pdf the assessment; the object store carries both. 171 tests; the renderer smoke tests skip where reportlab is absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SBOM report lists every transitive component again (name, version, identifier, containing artefact) instead of an ecosystem aggregate; the document metadata is a table with the full SHA-256. Both reports divide their component sections into the same platform subcategories (Web, Flutter app, Android/JVM, ...). classify --annotate-bom stamps quickbird:finding:epss so the VDR shows the score the escalation rule used next to the CVSS band. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mark-graph.py reads the graph from the npm lockfiles (yarn v1/berry, package-lock v2/v3 with nearest-first resolution) and writes standard CycloneDX dependencies[] edges into the per-target BOM; consolidation already merges them. Both reports walk the merged graph: the SBOM gets a Via column on every transitive component, the VDR names the path under each library with CVEs — the shortest chain from the direct dependency that pulls it in. Ecosystems whose lockfile carries no graph stay without edges and render as undetermined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pub: pubspec.lock carries no edges, but the registry that resolved it does — one API call per hosted package returns its pubspec, and the dependency names resolve against the locked versions. gradle: the POM of each locked artifact names its runtime dependencies (google maven, maven central, flutter storage); only the names come from the POM, every version resolves against the lockfile. Fetches run in a small pool, degrade per package, and give up once the registry is provably unreachable so a blocked network cannot stall the pipeline. Against mindnet v1.1.0-qa1 this adds 1088 pub and 1019 maven edges; no pubspec or POM was unavailable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reports are records in their own right; the process document that governs them is not named inside them. The unscored-findings note now points at the applied-rules block the report itself carries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The document dropped its section numbering when it was brought to the house style of the QMS space, so every WI reference in a comment now pointed at nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
House style for QMS text: state the fact, do not set it off with a dash. Section headings use a colon, the two status strings read as plain clauses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The work instruction had two annexes. Neither held a rule: the technical one named identifiers, the other recorded the measurements behind the rule values. What the instruction needed from them is now in it, namely the three files a project owns and the default of every parameter. The rest lives here as IMPLEMENTATION.md, where it changes with the code instead of needing a QMS release for a renamed script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The document no longer numbers its stages, so a comment pointing at stage #6 pointed at nothing. Each activity has a name, and the Slack alert now says what is required instead of citing a number. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reports, the Slack alert and the reconciliation details are read by a person and some of them are QMS records, so they follow the same rules as the work instruction: no em-dash asides, no semicolons joining two clauses, and no section numbers now that the document refers to its sections by name. Empty table cells read n/a, matching the work instruction. Comments and developer-facing help text are untouched.
The line read "2 breached, 1 breached with no valid decision on record", which invited the second number to be read as a subset of the first. They are disjoint: a breach with a decision on record or still inside the decision period counts as breached, one past that period with nothing recorded counts separately.
The work instruction dropped 'backstop' as a second name for the reconciliation, so a comment that explains a parameter by that word now cites a term the document does not define. The configuration key stays 'backstop:' because renaming a shipped key would break every project file for no gain.
The work instruction no longer cites proposals, so a notification should not either. MDR Art. 87 vigilance carries the warning on its own: the message still says that not being in CRA scope is not the same as nothing being reportable.
The tier carried exactly two things: the reconciliation cadence and a cap on maintenance_interval. Both are per-product SLA values that the project already states itself, so the tier was an indirection with no content of its own. The cadence becomes reconciliation_interval, a duration like the maintenance commitment next to it, and the cron in the product's reconciliation workflow has to match it. The cap is gone with no replacement: what a product commits to is the SLA's business, and the control is the CODEOWNERS review on the policy file rather than a rule here. Both intervals are still checked for being durations, and both are watched for drift between runs. Unrelated to the document tier in resolve-tier.sh, which stays as it is.
The schedule that fires is a cron set by hand in each product's reconciliation workflow, so it could drift away from reconciliation_interval and nothing would notice. The run now reads the earlier reports back out of the object store and compares the gap against what each product promised. Three outcomes are named rather than collapsed: overdue when the previous run was longer ago than the commitment, short-window when the run reads less history than the period it is meant to cover, and first when there are no earlier reports to compare against. The last one is not a finding, because not knowing is not the same as being late. Also fixes a quoting error in the window description of the reusable workflow, where shell escaping had ended up inside a YAML single-quoted string and made the file unparseable.
Rule 9 is the last resort: no parsable CVSS 3.x vector and no usable vendor severity band, so nothing is known about the finding at all. Parking it on Expedited gave it a 30-day mitigation clock on the strength of an unknown, and because a finding only ever moves up a track, one that eventually scored 2.0 stayed at Expedited forever. Planned keeps the same remediation target, the next maintenance window, and drops the mitigation clock. An unknown is still not treated as low, and the finding moves up as soon as a score arrives.
WI-006-09 is split into a parent and two children, following the WI-006-03 pattern: the parent describes the process, WI-006-09-01 holds the classification rules and the timeframes, WI-006-09-02 holds the parameters a project sets. A comment that cites a section by name now names the document it moved to, so the reference still resolves.
Third-party components: the inventory of every build, daily monitoring of the deployed version, and
the classification and timeframes that govern the response. 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@mainfails until the file is onmain.What this does
Four points in time, each with its own record:
The daily run 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.
Each tagged build produces three artefacts: the CycloneDX document with the assessment written onto
the findings, an SBOM Report stating composition, and a dated Dependency & Vulnerability
Report stating the assessment. Both PDFs are rendered from the CycloneDX document alone, so neither
can drift from it.
What the assessment covers
membership, EPSS and CVSS, in that order of precedence.
kev-epss-enrichmentsupplies the twoexploitation feeds and records the KEV catalog version and the EPSS model version with every score,
because EPSS scores are not comparable between model versions.
that does not ship, or transitive. Read from the manifests, which is what makes coverage measurable
against the actual choices rather than against the record list.
resolving registry otherwise, so a finding can be traced to the direct dependency that pulled it in.
and deprecation taken from the same registry data.
present in the build but absent from the records.
Three things it deliberately refuses to do
.soup-scope.ymlas included or excluded, each with a reason. An unclassified candidate stops therun, because "not listed" is otherwise indistinguishable from "deliberately excluded".
twice: a
select()inside a jq object constructor made the whole constructionempty, and theaction wrote a 0-byte record while reporting all-clear.
tier,cra_scopeandmaintenance_intervalcome from the customerSLA and have no safe default.
cra_scopedefaults tounknownrather thanfalse, because anunnecessary report costs less than an omitted one.
Where the rules live
Not in this repository. The work instruction is a controlled QMS document, which is where it has a
review workflow and a single version: WI-006-09 Dependency and Vulnerability Management.
policy-defaults.ymlhere is its machine-readable counterpart. The document is what a person readsand 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. Comments in the code cite the document by section name, as
WI: Classification of a finding, so a reference survives the document being reordered.soup-discovery/IMPLEMENTATION.mdis the other half: the files, the scripts, the configuration keysand the object store layout. It lives here because it describes this code and changes with it.
Two places where BSI TR-03161 is stricter than the process default, enforced rather than documented.
O.TrdP_2requires the newest version or the one preceding it, so a product in TR-03161 scope muststate a patch limit.
O.TrdP_8is a prohibition, so accepting obsolescence with a recorded reason isnot 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 scope and policy drafts and the deploy
workflow patch go to the product repositories, which is where a product's configuration belongs.
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, being an image in scope in one
artefact and out of scope in another, our own release-versioned image, a builder stage, and
infrastructure.
Measured against real repositories
Everything below is from actual runs, not from examples. On one backend product:
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.
of observed exploitation to findings that carried none. KEV is now its own track, and the other
bands got timeframes that can be met.
and its packages move when it does.
determined because its deployment workflow declares no environment.
Seven defects in my own implementation were found by running it rather than by reading it. Each has a
regression test.
Tests
soup-discovery/tests/run-tests.sh: 174 cases passing, plus 7 that hit live feeds and are skippedwithout
TEST_NETWORK=1. Most are regressions for defects found while building this.Suggested reading order
and what each project decides.
soup-discovery/policy-defaults.yml. The same decisions in machine-readable form, with the reasonfor each value in comments.
soup-discovery/IMPLEMENTATION.md. Which script does what, and which configuration key reaches it.patches/README.md. Which file goes into which repository, and the order to wire a product.soup-discovery/scripts/classify-findings.pyandgroup-remediation.py. The two places where awrong rule would be hardest to notice.
Not in this PR
patches/product-repo/as templates. Nothingruns on a schedule yet, which also means no timeframe is running: both clocks start on the date a
run first reports a finding.
product repositories. Each needs confirmation by its project before the product is released under
this document.
workflow artefacts and the reconciliation cannot cover a longer period.
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. The rules are under
WI: The maintenance window.