From 6d3b0a636702a4767a1ab880d227169affe550bc Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 02:33:18 +0000 Subject: [PATCH 01/12] fix(gate): drop the unsatisfiable standard, and refuse one as a precondition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise run ntia-check` ran `ntia fsct3-min`, and `fsct3-min` cannot pass for any document syft can emit — so the gate reported at least one violation forever and the `warn` row could never clear. Three measurements, the third re-taken on syft 1.51.0: 1. `fsct_checker.py:94`'s `check_compliance()` requires `bool( self.sbom_gen_context)`, which no field of the JSON report surfaces. A document with supplier, licenseConcluded and copyrightText set on every component still returns `isConformant: false` with every nonconformant list empty and `conformanceMessages: []`. 2. `base_checker.py:407`'s `get_sbom_types()` returns `[]` unless `sbom_spec == "spdx3"`, so the condition is unsatisfiable for SPDX 2.x by construction. 3. syft 1.51.0's `--output` format list is byte-identical to 1.42.4's, and `spdx-json` is SPDX 2.3. syft 1.46.0's "SPDX 3 Support" note (anchore/syft#4269) is model and parsing support and added no `-o` format, so there is still nothing to switch to. So `NTIA_STANDARDS` defaults to `ntia`, with the measurement recorded beside it as the reason rather than as a preference. The failure summary stops asserting a cause. It read "The gap is in what a cargo lockfile can supply (no license or supplier fields exist there)" for every standard — true of `ntia`, false of the other, and stated in the one place a reader debugging the gate stops. It now names the standards that refused and points at their own per-standard counts. The durable half is a precondition: `--precondition` reads the derived document's `spdxVersion` and refuses a configured standard whose required spec the document does not carry, as exit 2 rather than exit 1. The failure mode was a standard nobody could satisfy being read as a document nobody had fixed, and only a precondition tells those apart. An absent or unclassifiable `spdxVersion` is could-not-look, never a pass. The refusal is a property of the document's spec rather than a blocklist on a name, so a producer that gains an SPDX 3 emitter makes the standard askable again with no edit here. The satisfiability test is written as `case` plus a bare `if` rather than the shorter `|| continue` pair: `|` is the `#MUTANT` field delimiter, so a condition containing `||` cannot be expressed as a mutation, and this is the line that must not lose its proof. `ntia-check` joins `$MUTANT_GATES` and both new mutations are killed by the cases they name. Also takes syft to 1.51 (mise.toml, mise.lock), since every measurement above is void on 1.42.4. Two-component style so taplo's comment alignment holds — renovate's `1.51.0` is what failed `taplo format` on #572. Refs: CLOUD-926 Refs: CLOUD-941 Closes CLOUD-666 --- mise-tasks/ntia-check.sh | 137 ++++++++++++++++++++++++++++++++++++--- mise.lock | 44 ++++++------- mise.toml | 4 +- tests/ntia-check.bats | 94 ++++++++++++++++++++++++++- 4 files changed, 242 insertions(+), 37 deletions(-) diff --git a/mise-tasks/ntia-check.sh b/mise-tasks/ntia-check.sh index 05f1959e7..ccc073f6c 100755 --- a/mise-tasks/ntia-check.sh +++ b/mise-tasks/ntia-check.sh @@ -11,9 +11,14 @@ # agree. This one asks whether the inventory is USABLE by whoever receives it: # the NTIA 2021 minimum elements and CISA's 2024 FSCT minimum expectation are # what a procurement review checks, and "we publish an SPDX SBOM" satisfies -# neither by itself. CLOUD-279's M1 measured that gap on v0.0.52 and re-measured -# it here on 2026-08-14: 243 components, `componentSuppliers` absent on 190, -# `componentConcludedLicenses` and `componentCopyrightTexts` absent on 243/243. +# neither by itself. CLOUD-279's M1 measured that gap on v0.0.52, again here on +# 2026-08-14 (243 components, `componentSuppliers` absent on 190, both +# `componentConcludedLicenses` and `componentCopyrightTexts` absent on 243/243), +# and again on 2026-08-23 at v0.0.106 under syft 1.51.0: **340 components, +# no-supplier=282, no-license=340, no-copyright=340**. The shape is unchanged and +# the denominator moved with the lockfile — which is CLOUD-664's point, that the +# denominator is itself wrong, and this line is a count of what the document says +# rather than of what the repository depends on. # # WHY THE CONFORMANCE ROW LANDS AS `warn` — the open question CLOUD-580 carried, # settled by measurement rather than preference. `Cargo.lock` contains ZERO @@ -48,7 +53,13 @@ # programs. # A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. #MUTANT nonconformant-sbom-passes|s/^\texit 1$/\texit 0/|a nonconformant document fails - +# +# The precondition's satisfiability arm is the durable half of CLOUD-666, so it +# ships with the mutations that prove it decides. Neutering either one restores +# the state this row closed: a standard nobody can satisfy reporting as a +# document nobody has fixed. +#MUTANT precondition-ignores-the-spec|s/^\t\tif \[\[ "\$doc_spec" = spdx3 \]\]; then$/\t\tif true; then/|THE DURABLE HALF +#MUTANT precondition-guesses-an-absent-spec|s/^\tif \[\[ -z "\$doc_version" \]\]; then$/\tif false; then/|a document declaring no spdxVersion is could-not-look, never a pass set -euo pipefail # Resolved BEFORE the cd: `$0` may be relative, and moving first would leave this @@ -57,10 +68,50 @@ SBOM="$(cd "$(dirname "$0")" && pwd)/sbom.sh" cd "${NTIA_CHECK_ROOT:-$(git rev-parse --show-toplevel)}" -# The standards to hold the document to, both of them: they are different -# published expectations (2021 NTIA, 2024 CISA FSCT) and either alone would let a -# regression in the other land. Overridable so the bats suite can drive one. -read -r -a STANDARDS <<<"${NTIA_STANDARDS:-ntia fsct3-min}" +# The standard to hold the document to. `ntia` ALONE, and that is a measurement +# rather than a preference (CLOUD-666). +# +# `fsct3-min` was here too, on the reasoning that the 2021 NTIA minimum elements +# and CISA's 2024 FSCT tier-3 minimum are different published expectations and +# either alone would let a regression in the other land. That reasoning is sound +# and the second standard was still unsatisfiable for every document this +# producer can emit, so its only effect was to make the gate permanently red: +# +# 1. `fsct_checker.py:94`'s `check_compliance()` requires eleven conditions, +# one of which is `bool(self.sbom_gen_context)`. No field of the JSON report +# corresponds to it, so the report cannot explain its own refusal — measured +# with `supplier`, `licenseConcluded` and `copyrightText` set on every +# component: all sub-checks true, every nonconformant list empty, +# `conformanceMessages: []`, and still `isConformant: false`. +# 2. `base_checker.py:407`'s `get_sbom_types()` opens `if not self.doc or +# self.sbom_spec != "spdx3": return []`, its docstring giving the reason — +# "In SPDX 3, SBOM type is only available in /Software/Sbom class." So for +# any SPDX 2.x document the list is empty and the condition is unsatisfiable +# by construction. +# 3. And syft cannot emit SPDX 3. Re-measured 2026-08-23 on syft **1.51.0**, +# whose `--output` format list is byte-identical to 1.42.4's: `cyclonedx-json +# cyclonedx-xml github-json purls spdx-json spdx-tag-value syft-json +# syft-table syft-text template`. `spdx-json` is SPDX 2.3, and this tree's +# document reports `spdxVersion: SPDX-2.3` / `sbomSpec: spdx2`. syft +# 1.46.0's "SPDX 3 Support" release note (anchore/syft#4269) is model and +# parsing support; it added no `-o` format, so there is still nothing to +# switch to. +# +# So no amount of enrichment reached it, and a permanently-red gate is a sensor +# reporting a constant. Whether FSCT v3 is worth pursuing is a separate decision +# that needs an SPDX 3 producer; recording it as a known non-goal is honest. +# +# Overridable, so the bats suite can drive one — and so re-adding a standard is +# possible. What re-adding one CANNOT do is silently return to this state: the +# precondition below refuses a standard whose required spec this producer's own +# document does not carry. +read -r -a STANDARDS <<<"${NTIA_STANDARDS:-ntia}" + +# The standards that require an SPDX 3 document, and the whole reason the +# precondition below can decide anything. Data, not a heuristic: each name here +# is one whose `check_compliance()` reads a field `get_sbom_types()` only +# populates for `sbom_spec == "spdx3"` (point 2 above). +readonly SPDX3_ONLY_STANDARDS=" fsct3-min " # `BATTEN_BIN` for the same reason `linear-check` takes it: the suite must be able # to stub the binary rather than build the workspace, since hk deliberately @@ -108,11 +159,61 @@ if [[ "${1:-}" = "--precondition" ]]; then echo "::error:: ntia-check: sbomcheck is present but does not answer --version, so no verdict it gave could be trusted." >&2 exit 2 fi - echo "ntia-check: precondition holds — sbomcheck resolves and ${spdx##*/} derives" + + # THE CONFIGURATION IS PART OF THE MECHANISM (CLOUD-666). A standard the + # producer's own document can never satisfy does not report nonconformance — + # it reports a constant, and for two months it was read as a document nobody + # had enriched. Only a precondition tells those two apart, which is why this + # lives on the `deny` row: "could we even ask this question" is exactly what + # this mode answers, and the answer here is no. + # + # The spec is read from the DOCUMENT rather than asked of the producer, so + # this stays a pure read of the artifact under test — no extra subprocess and + # no network (§3) — and it keeps deciding correctly if syft ever gains an + # SPDX 3 emitter, because the document is what would change. + # + # ABSENT IS EXIT 2, never a pass. A document with no `spdxVersion` is one + # whose spec could not be looked at, and a precondition that clears every + # standard it cannot classify is the silent return this row exists to close. + doc_version=$(jq -r '.spdxVersion // ""' "$spdx" 2>/dev/null) || doc_version="" + if [[ -z "$doc_version" ]]; then + echo "::error:: ntia-check: ${spdx##*/} declares no spdxVersion, so which spec it is cannot be read and no standard can be checked for satisfiability." >&2 + exit 2 + fi + case "$doc_version" in + SPDX-3*) doc_spec=spdx3 ;; + SPDX-2*) doc_spec=spdx2 ;; + *) + echo "::error:: ntia-check: ${spdx##*/} declares an spdxVersion this gate cannot classify ($doc_version), so no standard can be checked for satisfiability." >&2 + exit 2 + ;; + esac + + # Written as `case` and a bare `if` rather than the shorter `|| continue` + # pair, for the reason `claim-check` records about its own `takeover_requested` + # flag: `|` is the `#MUTANT` field delimiter, so a condition containing `||` + # cannot be expressed as a mutation — and the satisfiability test is exactly + # the line that must not lose its proof. + for standard in "${STANDARDS[@]}"; do + case "$SPDX3_ONLY_STANDARDS" in + *" $standard "*) ;; + *) continue ;; + esac + if [[ "$doc_spec" = spdx3 ]]; then + continue + fi + echo "::error:: ntia-check: NTIA_STANDARDS names '$standard', which requires an spdx3 document, and ${spdx##*/} is $doc_spec — no document this producer emits can satisfy it, so its refusal would be a constant rather than a verdict about this tree. Drop it from NTIA_STANDARDS, or change the producer to emit SPDX 3." >&2 + exit 2 + done + + echo "ntia-check: precondition holds — sbomcheck resolves, ${spdx##*/} derives as $doc_spec, and every configured standard is satisfiable by it" exit 0 fi violations=0 +# WHICH standards refused, so the summary can name them instead of asserting one +# cause for all of them (CLOUD-666, and the CLOUD-198 class it belongs to). +refused="" report() { # pointer-only (rule 4): document name, rule id, counts. Never a component. echo "$1 $2" >&2 violations=$((violations + 1)) @@ -139,10 +240,26 @@ for standard in "${STANDARDS[@]}"; do detail="$detail $counts" fi report "${spdx##*/}:0" "sbom-ntia-nonconformant ($standard $detail)" + refused="${refused}${refused:+ }$standard" done if [[ "$violations" -ne 0 ]]; then - echo "::error:: ntia-check: $violations standard(s) refused this document. The gap is in what a cargo lockfile can supply (no license or supplier fields exist there), so closing it means enriching the SBOM, not re-running this." >&2 + # NAMES THE STANDARD THAT REFUSED, AND ASSERTS NO CAUSE (CLOUD-666). + # + # This line used to read "The gap is in what a cargo lockfile can supply (no + # license or supplier fields exist there), so closing it means enriching the + # SBOM, not re-running this." That is true of `ntia` and it was printed for + # every standard — including one whose refusal no enrichment could ever reach. + # A false cause is worse here than no cause, because it is stated in the one + # place a reader debugging the gate will stop: it names something real, so + # there is no reason to doubt it, and the reader goes on enriching fields + # forever. That is the CLOUD-198 class. + # + # So the summary points at the per-standard lines above, which carry the + # standard and its own counts, and stops explaining on their behalf. A gate + # whose explanation cannot be wrong is worth more than one whose explanation + # is usually right. + echo "::error:: ntia-check: $violations standard(s) refused this document: $refused. Each line above names the standard and its own counts — read the cause from the standard that refused, not from this line." >&2 exit 1 fi diff --git a/mise.lock b/mise.lock index e64815db2..6c7cc98dc 100644 --- a/mise.lock +++ b/mise.lock @@ -40,43 +40,43 @@ url = "https://github.com/EmbarkStudios/cargo-deny/releases/download/0.20.2/carg url_api = "https://api.github.com/repos/EmbarkStudios/cargo-deny/releases/assets/471599057" [[tools."aqua:anchore/syft"]] -version = "1.42.4" +version = "1.51.0" backend = "aqua:anchore/syft" [tools."aqua:anchore/syft"."platforms.linux-arm64"] -checksum = "sha256:5029bad1ed372649527b1e443cbceef7f5d6ae1cfe52c16e721559f94267128b" -url = "https://github.com/anchore/syft/releases/download/v1.42.4/syft_1.42.4_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/anchore/syft/releases/assets/391847478" +checksum = "sha256:6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/anchore/syft/releases/assets/508878652" [tools."aqua:anchore/syft"."platforms.linux-arm64-musl"] -checksum = "sha256:5029bad1ed372649527b1e443cbceef7f5d6ae1cfe52c16e721559f94267128b" -url = "https://github.com/anchore/syft/releases/download/v1.42.4/syft_1.42.4_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/anchore/syft/releases/assets/391847478" +checksum = "sha256:6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/anchore/syft/releases/assets/508878652" [tools."aqua:anchore/syft"."platforms.linux-x64"] -checksum = "sha256:590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f" -url = "https://github.com/anchore/syft/releases/download/v1.42.4/syft_1.42.4_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/anchore/syft/releases/assets/391847479" +checksum = "sha256:2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/anchore/syft/releases/assets/508878656" [tools."aqua:anchore/syft"."platforms.linux-x64-musl"] -checksum = "sha256:590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f" -url = "https://github.com/anchore/syft/releases/download/v1.42.4/syft_1.42.4_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/anchore/syft/releases/assets/391847479" +checksum = "sha256:2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/anchore/syft/releases/assets/508878656" [tools."aqua:anchore/syft"."platforms.macos-arm64"] -checksum = "sha256:0797b64cf8841c904682e6007a695f9cd3e72103f064dd286723c0a56a2273e2" -url = "https://github.com/anchore/syft/releases/download/v1.42.4/syft_1.42.4_darwin_arm64.tar.gz" -url_api = "https://api.github.com/repos/anchore/syft/releases/assets/391847481" +checksum = "sha256:4f37f4c7fefce0a68e4cf71ba3f5f9829a99e65d89b29f7ee41b8c2c10ea8c59" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/anchore/syft/releases/assets/508878653" [tools."aqua:anchore/syft"."platforms.macos-x64"] -checksum = "sha256:4a14affad1b90f0bfa38fdb784279f01598b6099df40686391d814620e9de226" -url = "https://github.com/anchore/syft/releases/download/v1.42.4/syft_1.42.4_darwin_amd64.tar.gz" -url_api = "https://api.github.com/repos/anchore/syft/releases/assets/391847473" +checksum = "sha256:cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/anchore/syft/releases/assets/508878649" [tools."aqua:anchore/syft"."platforms.windows-x64"] -checksum = "sha256:a712f912e8fc83ce2bf6a7cea213c2d5185778d66ea2e07d42c767817f77e381" -url = "https://github.com/anchore/syft/releases/download/v1.42.4/syft_1.42.4_windows_amd64.zip" -url_api = "https://api.github.com/repos/anchore/syft/releases/assets/391847483" +checksum = "sha256:fc5ffaeffb993576ece9c791da5a688fb2c8969a1479bbfe58583672c64da336" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_windows_amd64.zip" +url_api = "https://api.github.com/repos/anchore/syft/releases/assets/508878657" [[tools."aqua:cli/cli"]] version = "2.97.0" diff --git a/mise.toml b/mise.toml index 1603542f7..e7f5193ff 100644 --- a/mise.toml +++ b/mise.toml @@ -21,7 +21,7 @@ rust = { version = "1.97.1", components = "rustfmt,clippy" } hk = "1.54.0" # git-hook runner (see hk.pkl) "aqua:cli/cli" = "2.97" # gh — reads CI checks (repo scope bundles checks-read) and drives PRs; `gh pr checks --watch` "aqua:EmbarkStudios/cargo-deny" = "0.20" # dependency license/advisory policy (deny.toml) -"aqua:anchore/syft" = "1.42" # SBOM of the tagged source: cargo-deny judges that tree, this inventories it (mise-tasks/sbom.sh) +"aqua:anchore/syft" = "1.51" # SBOM of the tagged source: cargo-deny judges that tree, this inventories it (mise-tasks/sbom.sh) "aqua:release-plz/release-plz" = "0.3" # conventional-commit-driven semver + changelog "aqua:jqlang/jq" = "1.7" # JSON for shell tasks — reads the PreToolUse payload in `gh-guard` # The measurement instrument for `mise run perf` (CLOUD-207). Batten runs as a @@ -391,7 +391,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-move-guard,board-sweep,board-write-record,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,prose-only-check,coderabbit-config-check,config-lint,connector-allow-guard,connector-verb-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,evaluator-closure-check,evaluator-io-check,fanout-guard,filed-here-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,issue-read-check,issue-read-guard,issue-search-check,issue-search-guard,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" +MUTANT_GATES = "ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-move-guard,board-sweep,board-write-record,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,prose-only-check,coderabbit-config-check,config-lint,connector-allow-guard,connector-verb-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,evaluator-closure-check,evaluator-io-check,fanout-guard,filed-here-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,issue-read-check,issue-read-guard,issue-search-check,issue-search-guard,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/tests/ntia-check.bats b/tests/ntia-check.bats index 007f61e21..049af17bd 100644 --- a/tests/ntia-check.bats +++ b/tests/ntia-check.bats @@ -45,8 +45,11 @@ setup() { stub_batten } -# A `syft` that writes the two documents `mise-tasks/sbom.sh` asks for. Sentinel: -# syft.fails exit non-zero, so no document can be derived +# A `syft` that writes the two documents `mise-tasks/sbom.sh` asks for. Sentinels: +# syft.fails exit non-zero, so no document can be derived +# syft.spdxver the `spdxVersion` to declare (default SPDX-2.3, matching what +# the real `spdx-json` output carries); the literal string +# `NONE` omits the key, so the could-not-look arm is reachable stub_syft() { cat >"$STUB/syft" <"\$spdx" +ver="SPDX-2.3" +[ ! -f "$BATS_TEST_TMPDIR/syft.spdxver" ] || ver="\$(cat "$BATS_TEST_TMPDIR/syft.spdxver")" +verkey="\"spdxVersion\":\"\$ver\"," +[ "\$ver" != "NONE" ] || verkey="" +echo "{\$verkey\"SPDXID\":\"SPDXRef-DOCUMENT\",\"name\":\"batten\",\"packages\":[{\"name\":\"crate0\",\"externalRefs\":[{\"referenceType\":\"purl\",\"referenceLocator\":\"pkg:cargo/crate0@1.0.0\"}]}]}" >"\$spdx" echo '{"components":[{"name":"crate0","purl":"pkg:cargo/crate0@1.0.0"}]}' >"\$cdx" EOF chmod +x "$STUB/syft" @@ -279,3 +286,84 @@ EOF run "$CHECK" [ "$status" -eq 1 ] } + +# ─── CLOUD-666: the standards set, and the precondition that keeps it honest ─── + +@test "the DEFAULT standards set is satisfiable: a conformant document exits 0" { + # The case that could not pass before this row. `fsct3-min` was in the default + # set and is unsatisfiable for every document syft can emit, so the gate was + # guaranteed non-zero whatever the SBOM said — and the `warn` row could never + # clear. `NTIA_STANDARDS` is unset here deliberately: the point is the DEFAULT, + # not a set the suite chose. + unset NTIA_STANDARDS + run "$CHECK" + [ "$status" -eq 0 ] + [[ "$output" == *"conforms to ntia"* ]] + [[ "$output" != *"fsct3-min"* ]] + [ "$(cat "$BATS_TEST_TMPDIR/receipts")" = "receipt record sbom-ntia" ] +} + +@test "dropping the unsatisfiable standard does not disarm the gate" { + # The other half of the same change: a document missing a required field must + # still fail under the narrowed default. A green gate is only worth having if + # it can still go red. + unset NTIA_STANDARDS + : >"$BATS_TEST_TMPDIR/check.ntia.fails" + run "$CHECK" + [ "$status" -eq 1 ] + [[ "$output" == *"sbom-ntia-nonconformant (ntia"* ]] + [ ! -f "$BATS_TEST_TMPDIR/receipts" ] +} + +@test "THE DURABLE HALF: an spdx3-only standard over an spdx2 document is a PRECONDITION refusal" { + # The failure mode this row closes is a standard nobody could satisfy being + # read as a document nobody had fixed. Those are different answers and only a + # precondition tells them apart: exit 2 ("could not ask"), never exit 1 + # ("this tree is nonconformant"). + export NTIA_STANDARDS="ntia fsct3-min" + run "$CHECK" --precondition + [ "$status" -eq 2 ] + [[ "$output" == *"fsct3-min"* ]] + [[ "$output" == *"requires an spdx3 document"* ]] +} + +@test "the same standard over an spdx3 document is NOT refused" { + # The refusal is a property of the document's spec, not a blocklist on a name: + # if the producer ever emits SPDX 3, the standard becomes askable again with no + # edit to this gate. + export NTIA_STANDARDS="ntia fsct3-min" + echo "SPDX-3.0.1" >"$BATS_TEST_TMPDIR/syft.spdxver" + run "$CHECK" --precondition + [ "$status" -eq 0 ] + [[ "$output" == *"precondition holds"* ]] + [[ "$output" == *"spdx3"* ]] +} + +@test "a document declaring no spdxVersion is could-not-look, never a pass" { + # A precondition that clears every standard it cannot classify is the silent + # return this row exists to close. + export NTIA_STANDARDS="ntia fsct3-min" + echo "NONE" >"$BATS_TEST_TMPDIR/syft.spdxver" + run "$CHECK" --precondition + [ "$status" -eq 2 ] + [[ "$output" == *"no spdxVersion"* ]] +} + +@test "an unclassifiable spdxVersion is could-not-look too" { + export NTIA_STANDARDS="ntia fsct3-min" + echo "SPDX-9.9" >"$BATS_TEST_TMPDIR/syft.spdxver" + run "$CHECK" --precondition + [ "$status" -eq 2 ] + [[ "$output" == *"cannot classify"* ]] +} + +@test "the nonconformance summary names the standards that refused and asserts no cause" { + # CLOUD-198's class. The old summary blamed the cargo lockfile for every + # standard, which was true of `ntia` and false of the other — and stated in the + # one place a reader debugging the gate stops. + : >"$BATS_TEST_TMPDIR/check.ntia.fails" + run "$CHECK" + [ "$status" -eq 1 ] + [[ "$output" == *"refused this document: ntia"* ]] + [[ "$output" != *"what a cargo lockfile can supply"* ]] +} From c12bf05e2db2a2e195b969ff3762378a1cba30b8 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 02:51:40 +0000 Subject: [PATCH 02/12] fix(sbom): one entry per thing depended on, not one per reference site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SBOM reported 340 components for 290 distinct things, and every per-component conformance denominator was computed over the inflated number. Re-measured 2026-08-23 on syft 1.51.0 at v0.0.106 (CLOUD-664's body measured 244 for 198 on v0.0.79; the census moved with the lockfile and is recorded on the row): * 57 `pkg:github` entries for 9 unique actions — syft's github-actions cataloger emits a component per reference SITE, so the document said this repository depends on `actions/checkout` twenty-two times. * a `./action` component, `versionInfo: UNKNOWN`, `supplier: "Organization: ."` — a relative path in this repository rather than a dependency of it, and nothing that can ever be enriched. Identity is the triple `(name, versionInfo, purl)`. A post-process in `sbom.sh` rather than a syft setting, because syft has no configuration for per-site emission, and there so that one file still decides what the documents contain: `sbom-check` and `ntia-check` re-run it, so the bytes a gate judges stay the bytes a release publishes. Both formats are normalised — SPDX relationships and CycloneDX `dependencies`/`dependsOn` are rewritten onto the canonical entry and deduplicated, so no edge is left dangling. THE SUBJECT IS NEVER MERGED, and CLOUD-664's cause 2 is misdiagnosed. The body reads "the root package is listed twice" and asks for one entry. Both entries are real and they are two ROLES: `SPDXRef-DocumentRoot-Directory-...` is the document's subject, the sole target of DESCRIBES and the sole source of all 339 CONTAINS edges, while `SPDXRef-Package-rust-crate-batten-...` is the workspace member as a dependency-graph node carrying 27 DEPENDENCY_OF edges. Deleting either corrupts the document. They are also now indistinguishable by triple — syft 1.50.0 stopped emitting a registry purl for a local workspace package (anchore/syft#5105), correctly, since `batten` is `publish = false` and is in no registry — so a naive dedupe silently eats whichever sorts second. The subject is resolved from the document's own DESCRIBES edge and exempted. No purl is synthesised for the workspace member: a registry coordinate would be a claim about the world that is false. `sbom-package-drift` expected every `[[package]]` entry, which was right only while syft gave the workspace member a purl. It now compares against the lockfile's SOURCED entries — the property that actually predicts a purl — which is 280 of 281 here and keeps holding if the workspace grows a second member. That off-by-one is what made PR #572's CI red. New `sbom-components-inflated` clause, pointer-only: entries, distinct triples, path-like and unversioned counts, never a component name. A document carrying no DESCRIBES edge is exit 2, because the subject is what the count exempts and without it every number is measured over the wrong set. `sbom.sh` had no suite of its own — its output was covered only through `sbom-check`, which re-runs it, and that is the wrong instrument here: the clause and the normaliser share one identity rule, so after a successful normalisation the clause has nothing to find and its agreement asserts nothing. `tests/sbom.bats` asserts on the producer's output directly, three `#MUTANT` rows carry the clause's firing proof, and `sbom` joins $MUTANT_GATES. The normaliser crashed on a package carrying no SPDXID, caught by `ntia-check`'s stub; an entry nothing can reference is left alone rather than keyed by null. Result: 340 -> 291 packages, 290 of 290 distinct, 9 unique actions, zero path-like, zero unversioned, 1447 -> 1397 relationships, zero dangling, cargo count unchanged at 280 so no real dependency was merged. Refs: CLOUD-926 Refs: CLOUD-941 Closes CLOUD-664 --- mise-tasks/sbom-check.sh | 74 ++++++++++++- mise-tasks/sbom.sh | 151 +++++++++++++++++++++++++ tests/sbom-check.bats | 97 ++++++++++++++-- tests/sbom.bats | 233 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 544 insertions(+), 11 deletions(-) create mode 100644 tests/sbom.bats diff --git a/mise-tasks/sbom-check.sh b/mise-tasks/sbom-check.sh index 0405e2de0..626b305b1 100755 --- a/mise-tasks/sbom-check.sh +++ b/mise-tasks/sbom-check.sh @@ -66,9 +66,27 @@ report() { # pointer-only (rule 4): asset:line rule-id, never document contents violations=$((violations + 1)) } -# `|| true` because `grep -c` exits 1 on a zero count, which is a real answer here -# rather than a failure — `sbom-empty` is what judges it. -declared=$(grep -c '^\[\[package\]\]' Cargo.lock || true) +# THE EXPECTED CARGO COUNT IS THE LOCKFILE'S *SOURCED* PACKAGES, NOT ALL OF THEM +# (CLOUD-664). This clause compared against every `[[package]]` entry, which was +# right for as long as syft gave the local workspace member a registry purl. It +# stopped being right at syft 1.50.0, which deliberately does not +# (anchore/syft#5105): `batten` is `publish = false` and is in no registry, so a +# `pkg:cargo/batten@…` coordinate would assert a registry presence that does not +# exist. Measured 2026-08-23 at v0.0.106: 281 `[[package]]` entries, 280 carrying +# a `source`, and 280 cargo purls in the document — the one without a source is +# the workspace member, and it is the one with no purl. +# +# So the invariant is stated over the thing that actually predicts a purl: a +# lockfile entry with a `source` key is a registry or git dependency and gets one; +# an entry without is local to this workspace and does not. That also keeps +# holding if the workspace grows a second member, where subtracting a hardcoded 1 +# would not. +# +# `|| true` on the total for the reason it was always there — `grep -c` exits 1 on +# a zero count, which is a real answer here rather than a failure, and +# `sbom-empty` is what judges it. +lock_packages=$(grep -c '^\[\[package\]\]' Cargo.lock || true) +declared=$(grep -c '^source = ' Cargo.lock || true) if ! first=$(SBOM_OUT_DIR="$scratch/one" "$SBOM"); then echo "::error:: sbom-check: could not derive the SBOM, so its contents are unverified." >&2 @@ -132,9 +150,57 @@ compare() { # $1 = label, $2 = first run's document, $3 = second run's compare spdx "$spdx_one" "$spdx_two" compare cdx "$cdx_one" "$cdx_two" +# --- one entry per thing depended on (CLOUD-664) ----------------------------- +# +# syft emits a component per REFERENCE SITE, so the document claimed 340 entries +# for 290 distinct things: 57 `pkg:github` entries for 9 unique actions, plus a +# `./action` component that is a relative path in this repository rather than a +# dependency of it. `sbom.sh` normalises that now; this is the clause that keeps +# it normalised, and it is deliberately a property of the DOCUMENT rather than of +# the normaliser — a cataloger that starts emitting a new inflated shape is caught +# without anyone having predicted which shape. +# +# THE SUBJECT IS EXEMPT, for the reason `sbom.sh` records at length: the document +# root and the workspace member are two roles, not two entries for one thing, and +# since syft stopped emitting a workspace purl they are indistinguishable by +# triple. Resolved from the document's own `DESCRIBES` edge, so a rename upstream +# does not turn this clause into a demand to corrupt the document. +# +# Pointer-only per rule 4: counts and the asset path, never a component name. +inflated=$(jq ' + ([.relationships[]? | select(.relationshipType == "DESCRIBES") | .relatedSpdxElement] | first) as $subject + | [.packages[]? | select(.SPDXID != $subject)] as $components + | { + entries: ($components | length), + distinct: ($components + | map([(.name // ""), (.versionInfo // ""), + ([.externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first // "")]) + | unique | length), + pathlike: ($components | map(select((.name // "") | startswith("./"))) | length), + unversioned: ($components | map(select((.versionInfo // "") == "UNKNOWN")) | length), + subject: (if $subject == null then 0 else 1 end) + } + | "\(.entries) \(.distinct) \(.pathlike) \(.unversioned) \(.subject)" +' -r "$spdx_one") || inflated="" +if [[ -z "$inflated" ]]; then + echo "::error:: sbom-check: could not read component identity from ${spdx_one##*/}, so whether the inventory is inflated is unverified." >&2 + exit 2 +fi +read -r entries distinct pathlike unversioned subject <<<"$inflated" +# A document that DESCRIBES nothing is could-not-look, not a clean inventory: the +# subject is what the exemption above is computed from, so without it every +# following count is measured over the wrong set. +if [[ "$subject" -eq 0 ]]; then + echo "::error:: sbom-check: ${spdx_one##*/} carries no DESCRIBES relationship, so the document's own subject cannot be identified and component identity is unverified." >&2 + exit 2 +fi +if [[ "$entries" -ne "$distinct" ]] || [[ "$pathlike" -ne 0 ]] || [[ "$unversioned" -ne 0 ]]; then + report "${spdx_one##*/}:0" "sbom-components-inflated (entries=$entries distinct=$distinct pathlike=$pathlike unversioned=$unversioned)" +fi + if [[ "$violations" -ne 0 ]]; then echo "::error:: sbom-check: $violations violation(s). Re-run 'mise run sbom' and inspect the documents; a count mismatch means a cataloger missed something, an unstable one means a field varies that the normalizer does not cover." >&2 exit 1 fi -echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock, and two scans agree" +echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock's $declared sourced entries of $lock_packages, $entries component(s) each a distinct thing, and two scans agree" diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index 20dc5373f..ae03f9968 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -32,6 +32,18 @@ # Scope of the claim, stated because overclaiming here is the failure mode: this # describes the REPOSITORY at the tag, not the shipped binary. A binary-level # inventory needs the compiler's own record, and is CLOUD-263. +# +# The normalization is where this file can silently stop doing its job, and it is +# the one thing `sbom-check`'s own inflation clause cannot prove: that clause and +# the normalizer share an identity rule, so after a successful normalization the +# clause has nothing to find and agreement means nothing. Removing each call is +# what shows the suite discriminates. +#MUTANT sbom-skips-spdx-normalization|s@^\tif ! normalize "\$spdx" "\$SPDX_NORMALIZE"; then@\tif false; then@|one action referenced twice yields ONE component +#MUTANT sbom-skips-cdx-normalization|s@^\tif ! normalize "\$cdx" "\$CDX_NORMALIZE"; then@\tif false; then@|the CycloneDX graph is rewritten too +# And the guard that keeps the dedupe from corrupting the document: with the +# subject no longer exempt it shares a triple with the workspace member and one of +# them is deleted, which for the subject means the document describes nothing. +#MUTANT sbom-dedupes-the-subject|s@select(.relationshipType == "DESCRIBES")@select(false)@|THE GUARD set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" @@ -68,6 +80,134 @@ crate_version() { printf '%s' "$version" } +# --- component identity: one entry per thing this repository depends on -------- +# +# CLOUD-664. syft emits a component per REFERENCE SITE, not per dependency, so +# the document overstated what this repository depends on: measured 2026-08-23 on +# syft 1.51.0, 340 entries for 290 distinct things — 57 `pkg:github` entries for 9 +# unique actions (`actions/checkout` alone appearing 22 times), plus a `./action` +# component that is a relative path in this repository rather than a dependency of +# it. The denominator every conformance count is computed over was the inflated +# number, so a reader could not answer the one question an inventory exists to +# answer. +# +# Identity is the triple `(name, versionInfo, purl)`. A post-process rather than a +# syft setting because syft has no configuration for this — the github-actions +# cataloger's per-site emission is not a flag — and it runs HERE so that one +# script still decides what the documents contain (§1), which is what lets +# `sbom-check` and `ntia-check` re-run this and judge the bytes a release +# publishes. +# +# ─── THE SUBJECT IS NEVER MERGED, AND THIS GUARD IS THE WHOLE CORRECTNESS ARGUMENT +# +# CLOUD-664's body reads "the root package is listed twice" and asks for one +# entry. Both entries are real and they are not duplicates — they are two ROLES: +# +# SPDXRef-DocumentRoot-Directory-batten the document's SUBJECT. `DESCRIBES` +# targets it, and it is the sole +# source of all 339 `CONTAINS` edges. +# SPDXRef-Package-rust-crate-batten-… the workspace member as a node in +# the dependency graph, carrying 27 +# `DEPENDENCY_OF` edges. +# +# Deleting either corrupts the document: without the subject it describes +# nothing, and without the graph node 27 edges dangle. And they are now +# INDISTINGUISHABLE BY TRIPLE — syft 1.50.0 stopped emitting a registry purl for a +# local workspace package (anchore/syft#5105, correctly: `batten` is +# `publish = false` and is in no registry), so both are `(batten, 0.0.106, "")`. +# A naive dedupe therefore silently eats the subject. The subject is resolved from +# the document's own `DESCRIBES` edge and excluded, rather than matched by name or +# by SPDXID shape, so this keeps holding if syft renames either one. +# +# No purl is synthesised for the workspace member. It is in no registry, so a +# registry coordinate would be a claim about the world that is false — the exact +# thing this document exists not to do. `sbom-check`'s cargo-count clause accounts +# for it instead. +# +# Removal is confined to entries that can never be enriched because there is +# nothing to enrich: a relative-path name, or `versionInfo: UNKNOWN`. Nothing that +# resolves to a real dependency leaves the inventory, which is the line CLOUD-608 +# drew when it declined to buy conformance by narrowing scope. +# shellcheck disable=SC2016 # a jq program: `$subject` and friends are jq bindings, not shell +readonly SPDX_NORMALIZE=' + # The subject, from the document rather than by name: never merged, never dropped. + ([.relationships[]? | select(.relationshipType == "DESCRIBES") | .relatedSpdxElement] | first) as $subject + | def ident: [(.name // ""), (.versionInfo // ""), + ([.externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first // "")]; + # An entry with no SPDXID is left strictly alone: nothing can reference it, so + # merging it would rewrite no edge, and it cannot be a key in the rename map at + # all. Guarded rather than assumed — a package without one crashed this + # program, and "the real cataloger always emits SPDXID" is exactly the kind of + # assumption a cataloger release breaks. + def rid: (.SPDXID // ""); + # Entries with nothing to enrich. The subject is exempt: it is the document, + # not a dependency, whatever its version string looks like. + [.packages[]? | select(rid != "") | select(rid != $subject) + | select(((.name // "") | startswith("./")) or ((.versionInfo // "") == "UNKNOWN")) + | rid] as $dropped + | (reduce (.packages[]? + | select(rid != "") | select(rid != $subject) + | select([rid] | inside($dropped) | not)) + as $p ({}; .[($p | ident | tojson)] += [$p | rid])) as $by_ident + # Canonical = the lexicographically first SPDXID of the group, so two runs of + # syft over one tree normalise identically — `sbom-check` compares the bytes. + | (reduce ($by_ident | to_entries[]) as $g ({}; + ($g.value | sort) as $ids | reduce $ids[1:][] as $id (.; .[$id] = $ids[0]))) as $merged + | ($dropped | map({(.): true}) | add // {}) as $gone + | .packages = [.packages[]? | select(($gone[rid] // false) | not) + | select(($merged[rid] // rid) == rid)] + | .relationships = ([.relationships[]? + | select((($gone[.spdxElementId] // false) or ($gone[.relatedSpdxElement] // false)) | not) + | .spdxElementId = ($merged[.spdxElementId] // .spdxElementId) + | .relatedSpdxElement = ($merged[.relatedSpdxElement] // .relatedSpdxElement)] + | unique) +' + +# The same identity rule over CycloneDX, whose graph is `dependencies[].ref` and +# `.dependsOn` rather than SPDX relationships. `metadata.component` is this +# format's subject and is not in `.components` at all, so it needs no exemption. +# shellcheck disable=SC2016 # a jq program: `$subject` and friends are jq bindings, not shell +readonly CDX_NORMALIZE=' + def ident: [(.name // ""), (.version // ""), (.purl // "")]; + # Same guard as the SPDX arm: a component with no `bom-ref` is referenced by + # nothing and is left alone rather than keyed by null. + def rid: (."bom-ref" // ""); + [.components[]? | select(rid != "") + | select(((.name // "") | startswith("./")) or ((.version // "") == "UNKNOWN")) + | rid] as $dropped + | (reduce (.components[]? | select(rid != "") | select([rid] | inside($dropped) | not)) + as $c ({}; .[($c | ident | tojson)] += [$c | rid])) as $by_ident + | (reduce ($by_ident | to_entries[]) as $g ({}; + ($g.value | sort) as $ids | reduce $ids[1:][] as $id (.; .[$id] = $ids[0]))) as $merged + | ($dropped | map({(.): true}) | add // {}) as $gone + | .components = [.components[]? | select(($gone[rid] // false) | not) + | select(($merged[rid] // rid) == rid)] + | if has("dependencies") then + .dependencies = ([.dependencies[]? + | select(($gone[.ref] // false) | not) + | .ref = ($merged[.ref] // .ref) + | if has("dependsOn") then + .dependsOn = ([.dependsOn[]? | select(($gone[.] // false) | not) + | ($merged[.] // .)] | unique) + else . end] + | group_by(.ref) | map(.[0] + {dependsOn: ([.[].dependsOn // []] | add | unique)})) + else . end +' + +# Applied in place, via a temporary file: a partial write must not leave a +# truncated document where a valid one was, since `sbom-check` re-runs this and +# would report an unparseable file rather than a normalisation defect. +normalize() { + local doc="$1" program="$2" tmp + tmp="${doc}.normalizing" + if ! jq "$program" "$doc" >"$tmp"; then + rm -f "$tmp" + echo "::error:: sbom: could not normalise component identity in ${doc##*/}, so the inventory would overstate what this repository depends on" >&2 + return 1 + fi + mv "$tmp" "$doc" +} + main() { local version spdx cdx @@ -99,6 +239,17 @@ main() { return 1 fi + # One entry per thing depended on, not one per reference site (CLOUD-664). + # Guarded rather than called bare: a task body does not run under `set -e` + # where it is invoked through mise, and a failed normalisation must not leave + # an inflated document behind a success. + if ! normalize "$spdx" "$SPDX_NORMALIZE"; then + return 1 + fi + if ! normalize "$cdx" "$CDX_NORMALIZE"; then + return 1 + fi + # stdout is the answer: pointers to the artifacts, never their bytes (rule 4). # KEY=VALUE so the release workflow appends it to $GITHUB_OUTPUT unchanged, and # `sbom-check` reads the paths from here rather than rebuilding the names. diff --git a/tests/sbom-check.bats b/tests/sbom-check.bats index 7ab96e109..005d5ee9f 100644 --- a/tests/sbom-check.bats +++ b/tests/sbom-check.bats @@ -30,13 +30,21 @@ setup() { stub_syft } -# A Cargo.lock declaring $1 packages — the number the cargo purl count must equal. +# A Cargo.lock declaring $1 SOURCED packages — the number the cargo purl count must +# equal (CLOUD-664). Every entry carries a `source`, because that is what makes it a +# registry dependency and so what predicts a purl in the document. A second +# argument adds one entry WITHOUT a source: a local workspace member, which syft +# 1.50.0+ deliberately gives no registry purl (anchore/syft#5105), so it must count +# toward `[[package]]` and not toward the expected purls. lockfile() { - local n=$1 i + local n=$1 local_member="${2:-}" i : >"$ROOT/Cargo.lock" for ((i = 0; i < n; i++)); do - printf '[[package]]\nname = "crate%d"\n\n' "$i" >>"$ROOT/Cargo.lock" + printf '[[package]]\nname = "crate%d"\nversion = "1.0.0"\nsource = "registry+https://example.invalid/index"\n\n' "$i" >>"$ROOT/Cargo.lock" done + if [ -n "$local_member" ]; then + printf '[[package]]\nname = "batten"\nversion = "9.9.9"\n\n' >>"$ROOT/Cargo.lock" + fi } # A `syft` that writes both documents, varying the four volatile fields on every @@ -77,24 +85,58 @@ if [ -f "$BATS_TEST_TMPDIR/syft.drift" ] && [ \$((n % 2)) -eq 0 ]; then name=renamed fi -packages='{"name":"'\$name'","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/'\$name'@1.0.0"}]}' -components='{"name":"'\$name'","purl":"pkg:cargo/'\$name'@1.0.0"}' +packages='{"SPDXID":"SPDXRef-Package-a","name":"'\$name'","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/'\$name'@1.0.0"}]}' +components='{"bom-ref":"ref-a","name":"'\$name'","version":"1.0.0","purl":"pkg:cargo/'\$name'@1.0.0"}' + +# The three inflated shapes CLOUD-664 measured, each reachable on its own so a +# case can name which condition it means. They are appended as EXTRA components, +# because that is how syft produced them: a second entry for something already +# inventoried, or an entry for something that was never a dependency. +if [ -f "$BATS_TEST_TMPDIR/syft.duplicate" ]; then + packages="\$packages,"'{"SPDXID":"SPDXRef-Package-a-again","name":"'\$name'","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/'\$name'@1.0.0"}]}' + components="\$components,"'{"bom-ref":"ref-a-again","name":"'\$name'","version":"1.0.0","purl":"pkg:cargo/'\$name'@1.0.0"}' +fi +if [ -f "$BATS_TEST_TMPDIR/syft.pathlike" ]; then + packages="\$packages,"'{"SPDXID":"SPDXRef-Package-local","name":"./action","versionInfo":"UNKNOWN","supplier":"Organization: ."}' + components="\$components,"'{"bom-ref":"ref-local","name":"./action","version":"UNKNOWN"}' +fi +if [ -f "$BATS_TEST_TMPDIR/syft.unversioned" ]; then + packages="\$packages,"'{"SPDXID":"SPDXRef-Package-nover","name":"mystery","versionInfo":"UNKNOWN"}' + components="\$components,"'{"bom-ref":"ref-nover","name":"mystery","version":"UNKNOWN"}' +fi + +# The document's own subject, and the relationship that identifies it. Present in +# every fixture because it is present in every real syft document, and because the +# gate reads it to decide what to EXEMPT: the subject shares its triple with the +# workspace member and must not be read as a duplicate of it. +subject='{"SPDXID":"SPDXRef-DocumentRoot-Directory-batten","name":"batten","versionInfo":"9.9.9"}' +relationships='{"spdxElementId":"SPDXRef-DOCUMENT","relatedSpdxElement":"SPDXRef-DocumentRoot-Directory-batten","relationshipType":"DESCRIBES"}' +if [ -f "$BATS_TEST_TMPDIR/syft.nodescribes" ]; then + relationships="" +fi + if [ -f "$BATS_TEST_TMPDIR/syft.empty" ]; then packages="" components="" fi +if [ -n "\$packages" ]; then + packages="\$subject,\$packages" +else + packages="\$subject" +fi mkdir -p "\$(dirname "\$spdx")" "\$(dirname "\$cdx")" cat >"\$spdx" <"\$cdx" <"$BATS_TEST_TMPDIR/syft.duplicate" + : >"$BATS_TEST_TMPDIR/syft.pathlike" + : >"$BATS_TEST_TMPDIR/syft.unversioned" + run "$CHECK" + [ "$status" -eq 0 ] + [[ "$output" == *"each a distinct thing"* ]] +} + +@test "a document that DESCRIBES nothing is could-not-look, not a clean inventory" { + # The subject is what the identity clause exempts, so without it every count is + # measured over the wrong set. Reporting green there would be a verdict reached + # by not looking — exit 2, the same answer this gate gives for a missing + # Cargo.lock. + : >"$BATS_TEST_TMPDIR/syft.nodescribes" + run "$CHECK" + [ "$status" -eq 2 ] + [[ "$output" == *"no DESCRIBES"* ]] +} + @test "this repo's real tree satisfies the gate — with the real syft" { # The self-consumption case. The stub proves the logic; this proves the logic # is pointed at a tree and a toolchain that actually satisfy it, which is the diff --git a/tests/sbom.bats b/tests/sbom.bats new file mode 100644 index 000000000..d9ab83ca4 --- /dev/null +++ b/tests/sbom.bats @@ -0,0 +1,233 @@ +#!/usr/bin/env bats +# subject: mise-tasks/sbom.sh +# sbom's component-identity normalization (CLOUD-664): does the produced inventory +# carry one entry per thing this repository depends on, rather than one per place +# that thing is referenced? +# +# This suite exists because `sbom.sh` had none. Its output was covered only through +# `sbom-check`, which re-runs it — and that is exactly the wrong instrument for the +# normalizer, because the gate's inflation clause and the normalizer share one +# identity rule. Post-normalization the clause cannot fire, so a suite driving the +# gate can only ever observe agreement. Asserting on `sbom.sh`'s own output is what +# distinguishes "normalized" from "compared against itself"; the clause's own +# firing proof is the `#MUTANT` row on the call this suite covers. +# +# Driven against a stubbed `syft`, which is the only way to produce the inflated +# shapes on demand: the real cataloger's output depends on how many times a +# workflow happens to reference an action, so a fixture built from it would assert +# whatever this repository's workflows looked like that week. + +setup() { + SBOM="$BATS_TEST_DIRNAME/../mise-tasks/sbom.sh" + STUB="$BATS_TEST_TMPDIR/bin" + mkdir -p "$STUB" + PATH="$STUB:$PATH" + export PATH + + ROOT="$BATS_TEST_TMPDIR/repo" + mkdir -p "$ROOT" + printf 'version = "9.9.9"\n' >"$ROOT/Cargo.toml" + export SBOM_ROOT="$ROOT" + export SBOM_OUT_DIR="$BATS_TEST_TMPDIR/out" + stub_syft +} + +# A `syft` reproducing the three shapes CLOUD-664 measured, plus the two roles that +# must survive normalization. +# +# The document's own subject and the workspace member share a triple — `(batten, +# 9.9.9, "")` — and that is not a fixture convenience: syft 1.50.0 stopped emitting +# a registry purl for a local workspace package (anchore/syft#5105), so on the real +# tree they are genuinely indistinguishable by triple. A normalizer that deduped +# them would delete the thing `DESCRIBES` points at. +stub_syft() { + cat >"$STUB/syft" <"\$spdx" <<'JSON' +{"SPDXID":"SPDXRef-DOCUMENT","name":"batten", + "documentNamespace":"https://example.invalid/syft/1", + "creationInfo":{"created":"2026-08-10T00:00:00Z"}, + "packages":[ + {"SPDXID":"SPDXRef-DocumentRoot-Directory-batten","name":"batten","versionInfo":"9.9.9"}, + {"SPDXID":"SPDXRef-Package-rust-crate-batten-aaa","name":"batten","versionInfo":"9.9.9"}, + {"SPDXID":"SPDXRef-Package-crate0","name":"crate0","versionInfo":"1.0.0", + "externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/crate0@1.0.0"}]}, + {"SPDXID":"SPDXRef-Package-action-bbb","name":"actions/checkout","versionInfo":"v7", + "externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:github/actions/checkout@v7"}]}, + {"SPDXID":"SPDXRef-Package-action-aaa","name":"actions/checkout","versionInfo":"v7", + "externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:github/actions/checkout@v7"}]}, + {"SPDXID":"SPDXRef-Package-local","name":"./action","versionInfo":"UNKNOWN", + "supplier":"Organization: ."} + ], + "relationships":[ + {"spdxElementId":"SPDXRef-DOCUMENT","relatedSpdxElement":"SPDXRef-DocumentRoot-Directory-batten","relationshipType":"DESCRIBES"}, + {"spdxElementId":"SPDXRef-DocumentRoot-Directory-batten","relatedSpdxElement":"SPDXRef-Package-crate0","relationshipType":"CONTAINS"}, + {"spdxElementId":"SPDXRef-DocumentRoot-Directory-batten","relatedSpdxElement":"SPDXRef-Package-action-aaa","relationshipType":"CONTAINS"}, + {"spdxElementId":"SPDXRef-DocumentRoot-Directory-batten","relatedSpdxElement":"SPDXRef-Package-action-bbb","relationshipType":"CONTAINS"}, + {"spdxElementId":"SPDXRef-DocumentRoot-Directory-batten","relatedSpdxElement":"SPDXRef-Package-local","relationshipType":"CONTAINS"}, + {"spdxElementId":"SPDXRef-Package-crate0","relatedSpdxElement":"SPDXRef-Package-rust-crate-batten-aaa","relationshipType":"DEPENDENCY_OF"} + ]} +JSON +cat >"\$cdx" <<'JSON' +{"serialNumber":"urn:uuid:0000-1", + "metadata":{"timestamp":"2026-08-10T00:00:00Z", + "component":{"bom-ref":"ref-root","name":"batten","version":"9.9.9"}}, + "components":[ + {"bom-ref":"ref-crate0","name":"crate0","version":"1.0.0","purl":"pkg:cargo/crate0@1.0.0"}, + {"bom-ref":"ref-action-bbb","name":"actions/checkout","version":"v7","purl":"pkg:github/actions/checkout@v7"}, + {"bom-ref":"ref-action-aaa","name":"actions/checkout","version":"v7","purl":"pkg:github/actions/checkout@v7"}, + {"bom-ref":"ref-local","name":"./action","version":"UNKNOWN"} + ], + "dependencies":[ + {"ref":"ref-root","dependsOn":["ref-crate0","ref-action-aaa","ref-action-bbb","ref-local"]}, + {"ref":"ref-action-bbb","dependsOn":[]} + ]} +JSON +EOF + chmod +x "$STUB/syft" +} + +spdx_path() { echo "$BATS_TEST_TMPDIR/out/batten.spdx.json"; } +cdx_path() { echo "$BATS_TEST_TMPDIR/out/batten.cdx.json"; } + +@test "one action referenced twice yields ONE component" { + # The shape that produced 57 entries for 9 unique actions. Fails on raw syft + # output, which yields one component per reference site. + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(jq '[.packages[] | select(.name == "actions/checkout")] | length' "$(spdx_path)")" -eq 1 ] + [ "$(jq '[.components[] | select(.name == "actions/checkout")] | length' "$(cdx_path)")" -eq 1 ] +} + +@test "the relative-path component is gone — it was never a dependency" { + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(jq '[.packages[] | select((.name // "") | startswith("./"))] | length' "$(spdx_path)")" -eq 0 ] + [ "$(jq '[.components[] | select((.name // "") | startswith("./"))] | length' "$(cdx_path)")" -eq 0 ] +} + +@test "THE GUARD: the document still DESCRIBES its subject, which shares a triple with the workspace member" { + # The case that stops this being a corruption. Both `batten` entries are real + # and are two ROLES — the document's subject and the dependency-graph node — + # and since syft stopped emitting a workspace purl they are identical by + # triple. A dedupe without the exemption eats whichever one sorts second, and + # if that is the subject the document describes nothing at all. + run "$SBOM" + [ "$status" -eq 0 ] + local subject + subject=$(jq -r '[.relationships[] | select(.relationshipType == "DESCRIBES") | .relatedSpdxElement] | first' "$(spdx_path)") + [ "$subject" = "SPDXRef-DocumentRoot-Directory-batten" ] + [ "$(jq --arg s "$subject" '[.packages[] | select(.SPDXID == $s)] | length' "$(spdx_path)")" -eq 1 ] + # And the graph node survives beside it, so its DEPENDENCY_OF edge still lands. + [ "$(jq '[.packages[] | select(.SPDXID == "SPDXRef-Package-rust-crate-batten-aaa")] | length' "$(spdx_path)")" -eq 1 ] + [ "$(jq '[.packages[] | select(.name == "batten")] | length' "$(spdx_path)")" -eq 2 ] +} + +@test "no relationship is left dangling, and none is duplicated" { + # Merging and dropping components rewrites the graph. A document whose edges + # point at SPDXIDs it no longer carries is not a smaller inventory, it is an + # invalid one — and the merge collapses two CONTAINS edges into one, which must + # be deduplicated rather than left as a repeated edge. + run "$SBOM" + [ "$status" -eq 0 ] + local dangling + dangling=$(jq ' + ([.packages[].SPDXID] + [(.files // [])[].SPDXID] + ["SPDXRef-DOCUMENT"]) as $ids + | [.relationships[] + | select((([.spdxElementId] | inside($ids)) | not) + or (([.relatedSpdxElement] | inside($ids)) | not))] | length' "$(spdx_path)") + [ "$dangling" -eq 0 ] + [ "$(jq '.relationships | length' "$(spdx_path)")" -eq "$(jq '.relationships | unique | length' "$(spdx_path)")" ] + # The fixture's four CONTAINS edges — crate0, both action reference sites, and + # the relative path — become two: the relative path's edge goes with it, and + # the two action edges collapse into one. + [ "$(jq '[.relationships[] | select(.relationshipType == "CONTAINS")] | length' "$(spdx_path)")" -eq 2 ] +} + +@test "the CycloneDX graph is rewritten too, not just its component list" { + # `dependencies[].ref` and `.dependsOn` are that format's edges. A dropped or + # merged bom-ref left inside them is the same invalidity as a dangling SPDX + # relationship, in the format nothing else in this suite would catch. + run "$SBOM" + [ "$status" -eq 0 ] + local refs + refs=$(jq -c '[.components[]."bom-ref"] + [.metadata.component."bom-ref"]' "$(cdx_path)") + [ "$(jq --argjson r "$refs" '[.dependencies[] | select(([.ref] | inside($r)) | not)] | length' "$(cdx_path)")" -eq 0 ] + [ "$(jq --argjson r "$refs" '[.dependencies[] | (.dependsOn // [])[] | select(([.] | inside($r)) | not)] | length' "$(cdx_path)")" -eq 0 ] + # The dropped `./action` left the root's dependsOn, and the two action refs + # collapsed to one, so the root depends on two things rather than four. + [ "$(jq '[.dependencies[] | select(.ref == "ref-root") | .dependsOn[]] | length' "$(cdx_path)")" -eq 2 ] +} + +@test "every remaining component is a distinct thing" { + # The invariant `sbom-check`'s clause reads, asserted here over the producer's + # own output: entries equal distinct triples, once the subject is set aside. + run "$SBOM" + [ "$status" -eq 0 ] + local counts + counts=$(jq -r ' + ([.relationships[] | select(.relationshipType == "DESCRIBES") | .relatedSpdxElement] | first) as $subject + | [.packages[] | select(.SPDXID != $subject)] as $c + | "\($c | length) \($c | map([(.name // ""), (.versionInfo // ""), + ([.externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first // "")]) | unique | length)"' "$(spdx_path)") + local entries distinct + read -r entries distinct <<<"$counts" + [ "$entries" -eq "$distinct" ] +} + +@test "normalization is deterministic — two runs produce identical documents" { + # `sbom-check` compares two scans byte for byte after stripping four volatile + # fields. A normalizer that picked its canonical entry non-deterministically + # would make that clause flap, so the canonical choice is the lexicographically + # first SPDXID rather than whichever one came first out of the map. + run "$SBOM" + [ "$status" -eq 0 ] + cp "$(spdx_path)" "$BATS_TEST_TMPDIR/first.json" + run "$SBOM" + [ "$status" -eq 0 ] + run diff -q "$BATS_TEST_TMPDIR/first.json" "$(spdx_path)" + [ "$status" -eq 0 ] +} + +@test "--names answers without scanning, and reports the normalized asset paths" { + # The release workflow and `release-assets-check` read the names from here. The + # normalization must not have moved them. + run "$SBOM" --names + [ "$status" -eq 0 ] + [[ "$output" == *"batten.spdx.json"* ]] + [[ "$output" == *"batten.cdx.json"* ]] + [ ! -f "$(spdx_path)" ] +} + +@test "a syft that cannot run produces no document and fails" { + cat >"$STUB/syft" <<'EOF' +#!/usr/bin/env bash +exit 1 +EOF + chmod +x "$STUB/syft" + run "$SBOM" + [ "$status" -eq 1 ] + [[ "$output" == *"could not scan"* ]] +} From e38b9ff36031d52666c94b346584d88e3054ccbc Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 03:00:59 +0000 Subject: [PATCH 03/12] fix(sbom): supplier from the resolution, originator from the manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supplier` was NOASSERTION on every cargo component. CLOUD-630 was filed believing the field unreachable, and the evidence was right: `authors` is empty on 55 of 281 packages, is self-asserted where present, and `repository` is a URL rather than an entity. All of it is about the wrong field. SPDX distinguishes `PackageSupplier` — who DISTRIBUTED the package — from `PackageOriginator` — who CREATED it. Measured 2026-08-23: the lockfile resolves every dependency to exactly one distinct source, `registry+https://github.com/rust-lang/crates.io-index`. So the distributor is a fact the resolution states rather than something inferred, and that is the supplier. `authors` answers the other question, and where it is empty NOASSERTION is the correct value — 55 packages assert nothing about authorship and the document should not either. Result: supplier is set on 291 of 291 components, zero NOASSERTION anywhere — 280 `Organization: crates.io`, 2 `Organization: Button Inc.` (the document's subject and the workspace member), 9 action suppliers syft already derived and this does not touch. Originator is NOASSERTION on exactly the 55 empty-authors packages. No source is ever labelled crates.io on a guess: only the crates.io index URL maps to it, and a git or path dependency gets NOASSERTION because its distributor is stated nowhere this can read. Every package in the tree resolves to crates.io today, so nothing here exercises that branch — which is why it is driven from a synthetic fixture rather than discovered in a release after someone adds a git dependency. Two things the first implementation got wrong, both caught by counting rather than by reading: * A purl-keyed lookup missed five packages. A purl percent-encodes semver build metadata, so `toml 1.1.4+spec-1.1.0` arrives as `pkg:cargo/toml@1.1.4%2Bspec-1.1.0` and matches no `cargo metadata` key — silently, as NOASSERTION. * It could not reach the workspace member at all, which has no purl since syft 1.50.0. That is the component CLOUD-630 §7 names first: the document's own subject reading NOASSERTION about itself. Both are fixed by keying on the component's own name and version, with `pkg:github` entries excluded by their purl rather than selected by absence of one — absence is exactly what the two `batten` entries have. `Organization:` for the originator is a formatting choice rather than a claim, and it follows the convention the document already uses: SPDX requires a kind prefix, a manifest's `authors` does not state one, and syft writes `Organization: ` for both fields on every action entry. New `sbom-supplier-unset` clause. It reads `cargo metadata` rather than only the document, because a supplier count alone cannot tell an originator that agrees with the manifest from one copied out of the supplier field — the agreement is what makes the two fields mean different things. Disagreement is counted in both directions: a missing originator loses data the tree states, an invented one asserts authorship nobody claimed. The subject is excluded from the cargo count, since it is the document rather than a dependency, and asserted separately so the exclusion is not a hole. Pointer-only, and it matters more here than elsewhere: an `authors` entry is a personal name and often an email, so the finding carries counts and never a value. Refs: CLOUD-926 Closes CLOUD-630 --- mise-tasks/sbom-check.sh | 60 ++++++++++++++ mise-tasks/sbom.sh | 161 +++++++++++++++++++++++++++++++++++++ tests/ntia-check.bats | 17 ++++ tests/sbom-check.bats | 28 ++++++- tests/sbom.bats | 169 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 434 insertions(+), 1 deletion(-) diff --git a/mise-tasks/sbom-check.sh b/mise-tasks/sbom-check.sh index 626b305b1..d5579a546 100755 --- a/mise-tasks/sbom-check.sh +++ b/mise-tasks/sbom-check.sh @@ -198,6 +198,66 @@ if [[ "$entries" -ne "$distinct" ]] || [[ "$pathlike" -ne 0 ]] || [[ "$unversion report "${spdx_one##*/}:0" "sbom-components-inflated (entries=$entries distinct=$distinct pathlike=$pathlike unversioned=$unversioned)" fi +# --- supplier and originator (CLOUD-630) ------------------------------------- +# +# `supplier` was `NOASSERTION` on every cargo component. It is reachable with zero +# inference once the SPDX distinction is respected — `PackageSupplier` is who +# DISTRIBUTED the package, which the lockfile's resolution states, and +# `PackageOriginator` is who WROTE it, which `cargo metadata`'s `authors` answers +# or honestly does not. +# +# Both halves are checked, and the second is why this reads `cargo metadata` +# rather than only the document: a supplier count alone cannot tell an originator +# that agrees with the manifest from one that was copied from the supplier field. +# The agreement is what makes the two fields mean different things. +if ! meta=$(cargo metadata --format-version 1 --offline 2>/dev/null); then + echo "::error:: sbom-check: could not read cargo metadata, so whether the document's originators agree with the manifests is unverified." >&2 + exit 2 +fi +# `{"@": true}` for every package declaring at least one author. +authored=$(jq -c '[.packages[] | select((.authors // []) | length > 0) + | {key: "\(.name)@\(.version)", value: true}] | from_entries' <<<"$meta") || authored="" +if [[ -z "$authored" ]]; then + echo "::error:: sbom-check: could not read authorship from cargo metadata, so originator agreement is unverified." >&2 + exit 2 +fi +entities=$(jq -r --argjson authored "$authored" ' + ([.relationships[]? | select(.relationshipType == "DESCRIBES") | .relatedSpdxElement] | first) as $subject + | [.packages[]? + | select(.SPDXID != $subject) + | select(([.externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first // "") + | startswith("pkg:github/") | not)] as $cargo + | { + cargo: ($cargo | length), + # The subject is not a cargo dependency and is excluded from that count — + # but it is the one component whose supplier a reader checks first, so it is + # asserted on its own rather than left unjudged by the exclusion. + subjectunset: ([.packages[]? | select(.SPDXID == $subject) + | select((.supplier // "NOASSERTION") == "NOASSERTION")] | length), + nosupplier: ($cargo | map(select((.supplier // "NOASSERTION") == "NOASSERTION")) | length), + # An originator is expected exactly where the manifest declares an author, + # and `NOASSERTION` exactly where it does not. Both directions count as a + # disagreement: a missing one loses data the tree states, and an invented one + # asserts authorship nobody claimed. + disagrees: ($cargo | map( + "\(.name // "")@\(.versionInfo // "")" as $key + | ((.originator // "NOASSERTION") != "NOASSERTION") as $set + | select($set != (($authored[$key] // false)))) | length) + } + | "\(.cargo) \(.nosupplier) \(.disagrees) \(.subjectunset)" +' "$spdx_one") || entities="" +if [[ -z "$entities" ]]; then + echo "::error:: sbom-check: could not read supplier and originator from ${spdx_one##*/}, so those fields are unverified." >&2 + exit 2 +fi +read -r cargo_components nosupplier disagrees subjectunset <<<"$entities" +# Pointer-only per rule 4, and it matters more here than elsewhere in this file: +# an `authors` entry is a personal name and often an email address, so the finding +# carries counts and never a value. +if [[ "$nosupplier" -ne 0 ]] || [[ "$disagrees" -ne 0 ]] || [[ "$subjectunset" -ne 0 ]]; then + report "${spdx_one##*/}:0" "sbom-supplier-unset (cargo=$cargo_components no-supplier=$nosupplier originator-disagrees=$disagrees subject-unset=$subjectunset)" +fi + if [[ "$violations" -ne 0 ]]; then echo "::error:: sbom-check: $violations violation(s). Re-run 'mise run sbom' and inspect the documents; a count mismatch means a cataloger missed something, an unstable one means a field varies that the normalizer does not cover." >&2 exit 1 diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index ae03f9968..1cec64d18 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -44,6 +44,13 @@ # subject no longer exempt it shares a triple with the workspace member and one of # them is deleted, which for the subject means the document describes nothing. #MUTANT sbom-dedupes-the-subject|s@select(.relationshipType == "DESCRIBES")@select(false)@|THE GUARD +# And the supplier/originator pass (CLOUD-630). Skipping it returns every cargo +# component to NOASSERTION, which is the state the row was filed about; collapsing +# the two fields into one is the design the row rejected, where `authors` was asked +# to fill the supplier slot and 55 packages went supplier-less for a reason that +# has nothing to do with who distributed them. +#MUTANT sbom-skips-entity-enrichment|s@^\tif ! enrich "\$spdx" "\$SPDX_ENTITIES" "\$entities"; then@\tif false; then@|the document's own subject carries the workspace supplier +#MUTANT sbom-conflates-supplier-and-originator|s@else "Organization: " + .\[0\] end)@else $own end)@|the originator is the author rather than the registry set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" @@ -194,6 +201,130 @@ readonly CDX_NORMALIZE=' else . end ' +# --- supplier and originator: two fields, two authorities --------------------- +# +# CLOUD-630. `supplier` was `NOASSERTION` on every cargo component, and the issue +# was filed believing the field unreachable: `authors` is empty on 55 of 281 +# packages, is self-asserted where present, and `repository` is a URL rather than +# an entity. All true, and all about the wrong field. +# +# SPDX distinguishes `PackageSupplier` — who DISTRIBUTED the package — from +# `PackageOriginator` — who CREATED it. Measured 2026-08-23: the lockfile resolves +# every dependency to exactly one distinct source, +# `registry+https://github.com/rust-lang/crates.io-index`, so the distributor is +# a fact the resolution states rather than something inferred. That is the +# supplier. `authors` answers the other question, and where it is empty +# `NOASSERTION` is the correct and honest value — 55 packages assert nothing about +# authorship and the document should not either. +# +# READ FROM `cargo metadata`, WHOSE `source` IS THE LOCKFILE'S. CLOUD-630 §1 names +# `Cargo.lock`'s per-package `source` key as the authority, and this reads the same +# datum through the tool that resolves it: `cargo metadata` reports the resolved +# source per package, and `authors` besides, so one subprocess answers both +# questions where parsing the lockfile by hand would answer one and still need the +# other. `--offline`, so this adds no network call. +# +# NO SOURCE IS EVER LABELLED crates.io ON A GUESS. Only the crates.io index URL +# maps to `Organization: crates.io`. A git or path dependency gets `NOASSERTION`, +# because its distributor is not stated anywhere this can read and a plausible +# guess is exactly the overclaim CLOUD-608 refused. Every package in the tree +# resolves to crates.io today, so nothing here exercises that branch — which is +# why `tests/sbom.bats` drives it from a synthetic fixture rather than waiting for +# someone to add a git dependency and discover the mislabelling in a release. +# +# `Organization:` FOR THE ORIGINATOR, and it is a formatting choice rather than a +# claim. SPDX requires a kind prefix; a manifest's `authors` entry does not state +# whether it names a person or a group, and "The Rust Project Developers" and a +# named individual arrive in the same field. `Organization:` is the convention the +# document already uses — syft writes `Organization: ` for both +# fields on every `pkg:github` entry — so following it keeps one convention in one +# document instead of two. SPDX's originator is single-valued, so the first author +# is recorded and the full list stays in `cargo metadata`. +readonly CRATES_IO_SOURCE='registry+https://github.com/rust-lang/crates.io-index' + +# The map the enrichment reads: `{"@": {supplier, originator}}`. +# Built once, from one `cargo metadata` call, keyed to match a `pkg:cargo` purl. +cargo_entities() { + local meta + if ! meta=$(cargo metadata --format-version 1 --offline 2>/dev/null); then + echo "::error:: sbom: could not read cargo metadata, so supplier and originator are unknown for every cargo component" >&2 + return 1 + fi + # The workspace's own packages have no `source` — they are not distributed by a + # registry at all — so their supplier is this repository's own manifest + # identity, passed in rather than re-read here. + jq -c --arg crates "$CRATES_IO_SOURCE" --arg own "$WORKSPACE_SUPPLIER" ' + [.packages[] + | {key: "\(.name)@\(.version)", + value: { + supplier: + (if .source == $crates then "Organization: crates.io" + elif .source == null then $own + else "NOASSERTION" end), + originator: + ((.authors // []) | if length == 0 then "NOASSERTION" + else "Organization: " + .[0] end) + }}] + | from_entries' <<<"$meta" +} + +# Read from the workspace manifest rather than written here, for the reason +# `crate_version` gives about the version: a label this file invents can disagree +# with what the package actually declares. +workspace_supplier() { + local authors + authors=$(awk -F'"' '/^authors = \[/ { print $2; exit }' Cargo.toml) + if [[ -z "$authors" ]]; then + printf 'NOASSERTION' + return 0 + fi + printf 'Organization: %s' "$authors" +} + +# Only `pkg:cargo` components are touched: the `pkg:github` entries already carry +# a supplier and an originator syft derived from the action's namespace owner, and +# overwriting those would replace a real answer with a less specific one. +# KEYED ON THE COMPONENT'S OWN name AND version, NOT ON ITS PURL, and both reasons +# are things a purl-keyed version got wrong on this tree: +# +# * A purl PERCENT-ENCODES semver build metadata, so `toml 1.1.4+spec-1.1.0` +# arrives as `pkg:cargo/toml@1.1.4%2Bspec-1.1.0` and matches no `cargo +# metadata` key. Five packages here carry a `+` and all five silently kept +# `NOASSERTION` — the shape of failure that is invisible without a count. +# * The workspace member has NO purl at all since syft 1.50.0, so a purl-keyed +# pass cannot reach the one component CLOUD-630 §7 names first: the document's +# own subject reading `NOASSERTION` is the gap a reader notices before any of +# the 280 others. +# +# `pkg:github` entries are excluded by their purl rather than selected by absence +# of one, because absence is exactly what the two `batten` entries have. Those +# already carry a supplier and originator syft derived from the action's namespace +# owner, and overwriting them would replace a specific answer with a general one. +# shellcheck disable=SC2016 # a jq program: `$entities` is a jq binding, not shell +readonly SPDX_ENTITIES=' + .packages = [.packages[]? + | ([.externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first // "") as $purl + | "\(.name // "")@\(.versionInfo // "")" as $key + | if ($purl | startswith("pkg:github/")) then . + elif $entities[$key] then + .supplier = $entities[$key].supplier + | .originator = $entities[$key].originator + else . end] +' + +# shellcheck disable=SC2016 # a jq program: `$entities` is a jq binding, not shell +readonly CDX_ENTITIES=' + .components = [.components[]? + | (.purl // "") as $purl + | "\(.name // "")@\(.version // "")" as $key + | if ($purl | startswith("pkg:github/")) then . + elif $entities[$key] then + .publisher = $entities[$key].supplier + | (if $entities[$key].originator == "NOASSERTION" then . + else .author = ($entities[$key].originator | ltrimstr("Organization: ")) end) + else . end] +' + # Applied in place, via a temporary file: a partial write must not leave a # truncated document where a valid one was, since `sbom-check` re-runs this and # would report an unparseable file rather than a normalisation defect. @@ -208,6 +339,18 @@ normalize() { mv "$tmp" "$doc" } +# Same in-place discipline as `normalize`, with the entity map bound as `$entities`. +enrich() { + local doc="$1" program="$2" entities="$3" tmp + tmp="${doc}.enriching" + if ! jq --argjson entities "$entities" "$program" "$doc" >"$tmp"; then + rm -f "$tmp" + echo "::error:: sbom: could not write supplier and originator into ${doc##*/}, so its cargo components would claim NOASSERTION over data this tree states" >&2 + return 1 + fi + mv "$tmp" "$doc" +} + main() { local version spdx cdx @@ -228,6 +371,10 @@ main() { fi version=$(crate_version) + # Bound before the scan so a manifest this file cannot read fails before syft + # spends a minute cataloguing a tree whose subject it could not name. + WORKSPACE_SUPPLIER=$(workspace_supplier) + export WORKSPACE_SUPPLIER mkdir -p "$OUT_DIR" # One scan, both formats: the catalogers run once and each output is a @@ -250,6 +397,20 @@ main() { return 1 fi + # Who distributed each cargo component, and who wrote it (CLOUD-630). After + # normalisation, so the map is applied once per surviving component rather than + # once per reference site. + local entities + if ! entities=$(cargo_entities); then + return 1 + fi + if ! enrich "$spdx" "$SPDX_ENTITIES" "$entities"; then + return 1 + fi + if ! enrich "$cdx" "$CDX_ENTITIES" "$entities"; then + return 1 + fi + # stdout is the answer: pointers to the artifacts, never their bytes (rule 4). # KEY=VALUE so the release workflow appends it to $GITHUB_OUTPUT unchanged, and # `sbom-check` reads the paths from here rather than rebuilding the names. diff --git a/tests/ntia-check.bats b/tests/ntia-check.bats index 049af17bd..df4ddd042 100644 --- a/tests/ntia-check.bats +++ b/tests/ntia-check.bats @@ -43,6 +43,23 @@ setup() { stub_syft stub_sbomcheck stub_batten + stub_cargo +} + +# `mise-tasks/sbom.sh` reads `cargo metadata` for supplier and originator +# (CLOUD-630), and this suite drives it against a synthetic tree with no lockfile, +# where the real `cargo` cannot answer. Stubbed for exactly the one package the +# syft stub above catalogs; nothing in this suite asserts on what it returns. +stub_cargo() { + cat >"$STUB/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ "${1:-}" = "metadata" ] || exit 1 +cat <<'JSON' +{"packages":[{"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]} +JSON +EOF + chmod +x "$STUB/cargo" } # A `syft` that writes the two documents `mise-tasks/sbom.sh` asks for. Sentinels: diff --git a/tests/sbom-check.bats b/tests/sbom-check.bats index 005d5ee9f..e255696fa 100644 --- a/tests/sbom-check.bats +++ b/tests/sbom-check.bats @@ -24,10 +24,36 @@ setup() { # version from, and a lockfile whose `[[package]]` count is the expectation. ROOT="$BATS_TEST_TMPDIR/repo" mkdir -p "$ROOT" - printf 'version = "9.9.9"\n' >"$ROOT/Cargo.toml" + # `authors` as well as `version`: the workspace supplier is read from here + # (CLOUD-630), and a manifest declaring none leaves the document's own subject + # at NOASSERTION — which the supplier clause correctly refuses. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" lockfile 1 export SBOM_ROOT="$ROOT" stub_syft + stub_cargo +} + +# `sbom.sh` reads `cargo metadata` for supplier and originator, and this gate +# re-runs it — so the synthetic tree needs an answer even though no case here +# asserts on those fields. It reports the one crate the syft stub catalogs, with an +# author, so the gate's originator-agreement clause is satisfied rather than +# bypassed. `renamed` is the drift fixture's alternate name and is declared too, or +# the drift case would fail the agreement clause instead of the stability one. +stub_cargo() { + cat >"$STUB/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ "${1:-}" = "metadata" ] || exit 1 +cat <<'JSON' +{"packages":[ + {"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}, + {"name":"renamed","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}, + {"name":"batten","version":"9.9.9","source":null,"authors":["Button Inc."]} +]} +JSON +EOF + chmod +x "$STUB/cargo" } # A Cargo.lock declaring $1 SOURCED packages — the number the cargo purl count must diff --git a/tests/sbom.bats b/tests/sbom.bats index d9ab83ca4..44903af91 100644 --- a/tests/sbom.bats +++ b/tests/sbom.bats @@ -30,6 +30,10 @@ setup() { export SBOM_ROOT="$ROOT" export SBOM_OUT_DIR="$BATS_TEST_TMPDIR/out" stub_syft + # The supplier/originator pass reads `cargo metadata`, so every case needs one + # — including the identity cases below, which care about nothing it returns. It + # answers for exactly the packages the default syft fixture catalogs. + stub_cargo '[{"name":"batten","version":"9.9.9","source":null,"authors":["Button Inc."]},{"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]' } # A `syft` reproducing the three shapes CLOUD-664 measured, plus the two roles that @@ -221,6 +225,171 @@ cdx_path() { echo "$BATS_TEST_TMPDIR/out/batten.cdx.json"; } [ ! -f "$(spdx_path)" ] } +# ─── CLOUD-630: supplier and originator are two fields with two authorities ─── +# +# These drive a stubbed `cargo` as well as a stubbed `syft`, because the whole +# point is the mapping between what a manifest says and what the document claims, +# and the real workspace can only ever exercise one row of that table: every +# package here resolves to crates.io and 226 of 281 declare an author. A synthetic +# metadata fixture is the only way to reach a git source or an empty author list. + +# A `cargo` whose `metadata` answers with the packages named in $1 (a JSON array). +stub_cargo() { + cat >"$STUB/cargo" <"$STUB/syft" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +spdx="" +want=0 +for arg in "$@"; do + if [ "$want" = 1 ]; then + case "$arg" in + spdx-json=*) spdx="${arg#spdx-json=}" ;; + cyclonedx-json=*) cdx="${arg#cyclonedx-json=}" ;; + esac + want=0 + continue + fi + [ "$arg" = "--output" ] && want=1 +done +mkdir -p "$(dirname "$spdx")" +cat "$SYFT_SPDX_FIXTURE" >"$spdx" +cat "$SYFT_CDX_FIXTURE" >"$cdx" +EOF + chmod +x "$STUB/syft" +} + +# One SPDX document carrying the named cargo components, plus the subject. +write_fixtures() { + local pkgs="$1" comps="$2" + cat >"$BATS_TEST_TMPDIR/spdx.fixture" <"$BATS_TEST_TMPDIR/cdx.fixture" <"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"crate0","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/crate0@1.0.0"}]}' '{"bom-ref":"r-a","name":"crate0","version":"1.0.0","purl":"pkg:cargo/crate0@1.0.0"}' + stub_cargo '[{"name":"batten","version":"9.9.9","source":null,"authors":["Button Inc."]},{"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(supplier_of batten)" = "Organization: Button Inc." ] +} + +@test "THE FIELD SPLIT: an empty authors array still gets a supplier, and NOASSERTION for originator" { + # The case that fails under the design CLOUD-630 was filed against, where + # `authors` was pushed into the supplier slot: 55 of 281 packages here declare + # none, and every one of them would have gone supplier-less for a reason that + # has nothing to do with who distributed it. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"anon","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/anon@1.0.0"}]}' '{"bom-ref":"r-a","name":"anon","version":"1.0.0","purl":"pkg:cargo/anon@1.0.0"}' + stub_cargo '[{"name":"anon","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":[]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(supplier_of anon)" = "Organization: crates.io" ] + [ "$(originator_of anon)" = "NOASSERTION" ] +} + +@test "a crate with authors gets both, and the originator is the author rather than the registry" { + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"written","versionInfo":"2.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/written@2.0.0"}]}' '{"bom-ref":"r-a","name":"written","version":"2.0.0","purl":"pkg:cargo/written@2.0.0"}' + stub_cargo '[{"name":"written","version":"2.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["A Real Author"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(supplier_of written)" = "Organization: crates.io" ] + [ "$(originator_of written)" = "Organization: A Real Author" ] + [ "$(originator_of written)" != "$(supplier_of written)" ] +} + +@test "a package whose source is NOT crates.io is never labelled crates.io" { + # Every package in this tree resolves to crates.io today, so a git or path + # dependency would be mislabelled and nothing would notice. Written now rather + # than when someone adds one — which is the only moment it would otherwise be + # discovered, in a published release. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"forked","versionInfo":"3.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/forked@3.0.0"}]}' '{"bom-ref":"r-a","name":"forked","version":"3.0.0","purl":"pkg:cargo/forked@3.0.0"}' + stub_cargo '[{"name":"forked","version":"3.0.0","source":"git+https://example.invalid/forked?rev=deadbeef","authors":["Forker"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(supplier_of forked)" != "Organization: crates.io" ] + [ "$(supplier_of forked)" = "NOASSERTION" ] + # The originator is still known — who wrote it does not depend on who shipped it. + [ "$(originator_of forked)" = "Organization: Forker" ] +} + +@test "a semver build-metadata version still resolves, despite the purl encoding it" { + # `toml 1.1.4+spec-1.1.0` reaches the document as + # `pkg:cargo/toml@1.1.4%2Bspec-1.1.0`. A purl-keyed lookup missed all five such + # packages in this tree and left them NOASSERTION, silently. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"toml","versionInfo":"1.1.4+spec-1.1.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/toml@1.1.4%2Bspec-1.1.0"}]}' '{"bom-ref":"r-a","name":"toml","version":"1.1.4+spec-1.1.0","purl":"pkg:cargo/toml@1.1.4%2Bspec-1.1.0"}' + stub_cargo '[{"name":"toml","version":"1.1.4+spec-1.1.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Toml Author"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(supplier_of toml)" = "Organization: crates.io" ] +} + +@test "an action's own supplier is never overwritten by the cargo pass" { + # syft derives supplier and originator for a `pkg:github` entry from the + # action's namespace owner, which is more specific than anything the cargo pass + # knows. Replacing it would be a regression dressed as enrichment. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"actions/checkout","versionInfo":"v7","supplier":"Organization: GitHub","originator":"Organization: GitHub","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:github/actions/checkout@v7"}]}' '{"bom-ref":"r-a","name":"actions/checkout","version":"v7","purl":"pkg:github/actions/checkout@v7"}' + stub_cargo '[{"name":"actions/checkout","version":"v7","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Wrong"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(supplier_of actions/checkout)" = "Organization: GitHub" ] + [ "$(originator_of actions/checkout)" = "Organization: GitHub" ] +} + +@test "a cargo metadata that cannot run fails rather than shipping NOASSERTION" { + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"crate0","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/crate0@1.0.0"}]}' '{"bom-ref":"r-a","name":"crate0","version":"1.0.0","purl":"pkg:cargo/crate0@1.0.0"}' + cat >"$STUB/cargo" <<'EOF' +#!/usr/bin/env bash +exit 1 +EOF + chmod +x "$STUB/cargo" + run "$SBOM" + [ "$status" -eq 1 ] + [[ "$output" == *"could not read cargo metadata"* ]] +} + @test "a syft that cannot run produces no document and fails" { cat >"$STUB/syft" <<'EOF' #!/usr/bin/env bash From 226308ef31725b3af8caf104ea84b9c5310dc4fd Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 03:13:01 +0000 Subject: [PATCH 04/12] fix(sbom): copyright from the bytes the lockfile pins, NONE where there is none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `copyrightText` was NOASSERTION on every component, and unlike license or supplier the field has no source in `cargo metadata` at all. CLOUD-629 is therefore a decision before it is an implementation, and it makes three. THE REGISTRY CACHE IS ADMISSIBLE, AND THE REASON IS THE CHECKSUM. The cache looks like machine state, which would rule it out — a document whose contents depend on cache warmth would break the stability clause, and `.claude/rules/toolchain.md` draws exactly that line between a property of the commit and a property of the world. But `Cargo.lock` carries a `checksum` for every external package and cargo verifies the unpacked tree against it, so the content of the cache is a FUNCTION OF THE LOCKFILE. What is machine state is availability, not content — and that gets a mechanism rather than a judgement: `cargo fetch --locked` runs first, and a package the lockfile names with no unpacked source is a hard failure. Emitting anything for it is what would let cache warmth into the artifact. ONLY AN ANCHORED HOLDER LINE COUNTS. A first-match search for the word returns, on ahash, anstream, serde and regex alike, the string `copyright notice that is included in or attached to the work` — a fragment of the Apache-2.0 text. The loose reading does not merely miss a holder; it writes license prose into the field and asserts it as a copyright statement. So the pattern anchors at a line start, allows a comment marker, and requires a year followed by a name. Two stages, because one stage was wrong in both directions. License-shaped files are authoritative and read first. Where they carry no anchored line the whole pinned tree is searched and the most frequent line wins — measured, 4 of the 11 crates shipping no license file at all do state a holder elsewhere (json5, r-efi twice, yaml-rust2), so a license-files-only rule writes NONE over data the pinned bytes carry. Most-frequent rather than first because a vendored fixture contributes one line where a crate's own headers contribute many; ties break on the sorted line, so two scans agree. AND THE RESIDUE IS `NONE`, NOT `NOASSERTION`. SPDX separates them — NOASSERTION means we did not determine, NONE means we determined there is nothing — and measured against sbomcheck 5.0.3 the first is nonconformant and the second is not. Because both stages search every pinned byte, NONE is a claim this can stand behind rather than a nicer word for unknown. Measured on this tree, 280 external crates: 162 carry a holder, 118 are NONE, and zero Apache-2.0 boilerplate reaches the field. The workspace member is read the same way from this repository's own license files, restricted to the root because a repository's tree contains fixtures whose copyright lines are not this package's; the answer is NONE, and it is the true one — the only license file here is LICENSE-APACHE, whose sole mentions of the word are the boilerplate the pattern rejects. `sbom-check` reports `162 with a copyright holder and 119 determined to have none`, with the new `sbom-copyright-unenriched` clause refusing the third state. Pointer-only, and this field needs it more than any other in the document: a copyright statement is a personal name, so echoing the value would publish names into every CI log that reads the gate. Two things worth knowing for the next reader. A single quote inside a single-quoted jq program ends the shell string, which is why no apostrophes appear in those comments; and a backtick inside an unquoted heredoc is command substitution, which is how a fixture came to run `fetch` as a command. Refs: CLOUD-926 Closes CLOUD-629 --- mise-tasks/sbom-check.sh | 35 +++++++- mise-tasks/sbom.sh | 172 +++++++++++++++++++++++++++++++++++++-- tests/ntia-check.bats | 10 +++ tests/sbom-check.bats | 16 ++++ tests/sbom.bats | 142 +++++++++++++++++++++++++++++++- 5 files changed, 364 insertions(+), 11 deletions(-) diff --git a/mise-tasks/sbom-check.sh b/mise-tasks/sbom-check.sh index d5579a546..03d81d3e3 100755 --- a/mise-tasks/sbom-check.sh +++ b/mise-tasks/sbom-check.sh @@ -242,15 +242,24 @@ entities=$(jq -r --argjson authored "$authored" ' disagrees: ($cargo | map( "\(.name // "")@\(.versionInfo // "")" as $key | ((.originator // "NOASSERTION") != "NOASSERTION") as $set - | select($set != (($authored[$key] // false)))) | length) + | select($set != (($authored[$key] // false)))) | length), + # The three-way split CLOUD-629 asks for, which is the useful pointer here: a + # holder we read, an absence we determined, and the state this clause + # refuses. NONE is conformant and NOASSERTION is not, so counting them + # together would hide the only difference that matters. (No apostrophes in + # here: this program is a single-quoted shell string, and one ends it.) + holder: ($cargo | map(select(((.copyrightText // "NOASSERTION") | test("^Copyright"; "i")))) | length), + none: ($cargo | map(select((.copyrightText // "NOASSERTION") == "NONE")) | length), + unset: ($cargo | map(select(((.copyrightText // "NOASSERTION") == "NOASSERTION") + or ((.copyrightText // "") == ""))) | length) } - | "\(.cargo) \(.nosupplier) \(.disagrees) \(.subjectunset)" + | "\(.cargo) \(.nosupplier) \(.disagrees) \(.subjectunset) \(.holder) \(.none) \(.unset)" ' "$spdx_one") || entities="" if [[ -z "$entities" ]]; then echo "::error:: sbom-check: could not read supplier and originator from ${spdx_one##*/}, so those fields are unverified." >&2 exit 2 fi -read -r cargo_components nosupplier disagrees subjectunset <<<"$entities" +read -r cargo_components nosupplier disagrees subjectunset holder none unset <<<"$entities" # Pointer-only per rule 4, and it matters more here than elsewhere in this file: # an `authors` entry is a personal name and often an email address, so the finding # carries counts and never a value. @@ -258,9 +267,27 @@ if [[ "$nosupplier" -ne 0 ]] || [[ "$disagrees" -ne 0 ]] || [[ "$subjectunset" - report "${spdx_one##*/}:0" "sbom-supplier-unset (cargo=$cargo_components no-supplier=$nosupplier originator-disagrees=$disagrees subject-unset=$subjectunset)" fi +# --- copyright (CLOUD-629) --------------------------------------------------- +# +# `copyrightText` was NOASSERTION on every component, and the field has no source +# in `cargo metadata` at all — it is read from the bytes `Cargo.lock` pins by +# checksum. The producer writes one of exactly two values and never NOASSERTION: +# the anchored holder line where the pinned sources carry one, and `NONE` where +# every pinned byte was searched and none does. Measured against `sbomcheck` +# 5.0.3, `NONE` is conformant and `NOASSERTION` is not, so this clause refuses +# only the third state — which the producer's own hard failure on an absent +# unpacked source has already made unreachable. +# +# Pointer-only, and this field needs it more than any other in the document: a +# copyright statement is a personal name, so echoing the value would publish names +# into every CI log that reads this gate. +if [[ "$unset" -ne 0 ]]; then + report "${spdx_one##*/}:0" "sbom-copyright-unenriched (cargo=$cargo_components holder=$holder none=$none unset=$unset)" +fi + if [[ "$violations" -ne 0 ]]; then echo "::error:: sbom-check: $violations violation(s). Re-run 'mise run sbom' and inspect the documents; a count mismatch means a cataloger missed something, an unstable one means a field varies that the normalizer does not cover." >&2 exit 1 fi -echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock's $declared sourced entries of $lock_packages, $entries component(s) each a distinct thing, and two scans agree" +echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock's $declared sourced entries of $lock_packages, $entries component(s) each a distinct thing, every one carrying a supplier, $holder with a copyright holder and $none determined to have none, and two scans agree" diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index 1cec64d18..f786f5fea 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -51,6 +51,14 @@ # has nothing to do with who distributed them. #MUTANT sbom-skips-entity-enrichment|s@^\tif ! enrich "\$spdx" "\$SPDX_ENTITIES" "\$entities"; then@\tif false; then@|the document's own subject carries the workspace supplier #MUTANT sbom-conflates-supplier-and-originator|s@else "Organization: " + .\[0\] end)@else $own end)@|the originator is the author rather than the registry +# And the two decisions CLOUD-629 makes. The residue must be `NONE` rather than +# `NOASSERTION` — measured against sbomcheck 5.0.3, one is conformant and the other +# is not, and writing the timid value forfeits conformance for data we actually +# read. And an absent unpacked source must be a hard failure, because emitting +# anything for it would make the document depend on how warm this machine's cache +# is rather than on the lockfile. +#MUTANT sbom-copyright-residue-is-noassertion|s@== "" then "NONE"@== "" then "NOASSERTION"@|THE BOILERPLATE TRAP +#MUTANT sbom-tolerates-an-absent-source|s@^\tif \[\[ "\$missing" -ne 0 \]\]; then$@\tif false; then@|a lockfile package absent from the cache is a HARD FAILURE set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" @@ -242,20 +250,133 @@ readonly CDX_NORMALIZE=' # is recorded and the full list stays in `cargo metadata`. readonly CRATES_IO_SOURCE='registry+https://github.com/rust-lang/crates.io-index' -# The map the enrichment reads: `{"@": {supplier, originator}}`. -# Built once, from one `cargo metadata` call, keyed to match a `pkg:cargo` purl. +# --- copyright: read from the bytes the lockfile pins ------------------------ +# +# CLOUD-629, and it is the row's DECISION rather than only its implementation. +# `copyrightText` was `NOASSERTION` on every component, and unlike license or +# supplier the field has no source in `cargo metadata` at all. +# +# THE REGISTRY CACHE IS ADMISSIBLE, AND THE REASON IS THE CHECKSUM. The cache +# looks like machine state, which would make it inadmissible — `.claude/rules/ +# toolchain.md` draws exactly that line between a property of the commit and a +# property of the world, and a document whose contents depend on cache warmth +# would break `sbom-check`'s stability clause. But `Cargo.lock` carries a +# `checksum` for every external package and cargo verifies the unpacked tree +# against it, so the content of `/registry/src//- +# /` is a FUNCTION OF THE LOCKFILE. What is machine state is +# AVAILABILITY, not content — and availability gets a mechanism rather than a +# judgement: a package the lockfile names and the cache lacks is a hard failure, +# never a silent `NOASSERTION`. `cargo fetch --locked` is run first so a cold +# container is a fetch rather than a refusal. +# +# ONLY AN ANCHORED HOLDER LINE COUNTS, AND THE LOOSE READING IS MEASURABLY WRONG. +# A first-match search for the word "copyright" returns, on `ahash`, `anstream`, +# `serde` and `regex` alike, the string `copyright notice that is included in or +# attached to the work` — a fragment of the Apache-2.0 text itself. So the loose +# reading does not merely miss a holder; it writes license prose into +# `copyrightText` and asserts it as a copyright statement. The pattern therefore +# anchors at the start of a line, allows a comment marker, and requires a year +# followed by a name. +# +# TWO STAGES, because one stage was wrong in both directions. License-shaped files +# are authoritative and are read first. Where they carry no anchored line, the +# whole pinned tree is searched and the MOST FREQUENT anchored line wins — measured +# 2026-08-23, 4 of the 11 crates shipping no license file at all do state a holder +# elsewhere (`json5`, `r-efi` twice, `yaml-rust2`), so a license-files-only rule +# writes `NONE` over data the pinned bytes actually carry. Most-frequent rather +# than first-in-order because a vendored fixture contributes one line while a +# crate's own headers contribute many, which makes mis-attribution unlikely rather +# than merely bounded; ties break on the sorted line, so two runs agree. +# +# AND THE RESIDUE IS `NONE`, NOT `NOASSERTION` — the distinction that moves the +# ceiling from partial to complete. SPDX separates them: `NOASSERTION` means we did +# not determine, `NONE` means we determined there is nothing. Measured against +# `sbomcheck` 5.0.3, `NONE` is conformant and `NOASSERTION` is not. Because both +# stages search every pinned byte, `NONE` is a claim this can stand behind rather +# than a nicer word for unknown. +# +# Measured on this tree, 280 external crates: **162 carry a holder, 118 are +# `NONE`**, and zero Apache-2.0 boilerplate reaches the field. +readonly COPYRIGHT_RE='^[[:space:]]*(#|//|\*|;)?[[:space:]]*Copyright[[:space:]]*(\(c\)|©)?[[:space:]]*[0-9][0-9,[:space:]-]*[[:alpha:]].*' +# Strips leading whitespace and one comment marker, so the same statement found in +# a `LICENSE` file and in a source header normalises to one string. +readonly COPYRIGHT_TIDY='s/^[[:space:]]*//; s/^\(#\|\/\/\|\*\|;\)[[:space:]]*//' + +# The unpacked source root. Several registries can be present; each package is +# looked up under all of them, so a vendored or alternate registry resolves too. +cargo_src_roots() { + local home="${CARGO_HOME:-$HOME/.cargo}" + printf '%s\n' "$home"/registry/src/*/ +} + +# `` or the empty string, for one `-` directory. +copyright_of() { + local dir="$1" line="" files=() + shopt -s nullglob nocaseglob + files=("$dir"/LICENSE* "$dir"/COPYING* "$dir"/COPYRIGHT* "$dir"/NOTICE*) + shopt -u nocaseglob + if [[ "${#files[@]}" -gt 0 ]]; then + line=$(grep -hoiE "$COPYRIGHT_RE" "${files[@]}" 2>/dev/null | sed "$COPYRIGHT_TIDY" | head -1) || line="" + fi + if [[ -z "$line" ]]; then + line=$(grep -rhoiE "$COPYRIGHT_RE" "$dir" 2>/dev/null | sed "$COPYRIGHT_TIDY" | + sort | uniq -c | sort -k1,1nr -k2 | head -1 | sed 's/^ *[0-9]* //') || line="" + fi + printf '%s' "$line" +} + +# The map the enrichment reads: +# `{"@": {supplier, originator, copyright}}`. Built once, from one +# `cargo metadata` call plus one pass over the pinned sources. cargo_entities() { local meta + if ! cargo fetch --locked >/dev/null 2>&1; then + echo "::error:: sbom: \`cargo fetch --locked\` failed, so the pinned sources the copyright statements are read from are not present" >&2 + return 1 + fi if ! meta=$(cargo metadata --format-version 1 --offline 2>/dev/null); then echo "::error:: sbom: could not read cargo metadata, so supplier and originator are unknown for every cargo component" >&2 return 1 fi + + # One line per external package, resolved against the cache. A package the + # lockfile names and no registry root holds is a HARD FAILURE: emitting + # `NOASSERTION` for it would make the document's contents depend on how warm + # this machine's cache is, which is the property-of-the-world failure the + # admissibility argument above turns on. + local -a roots + mapfile -t roots < <(cargo_src_roots) + local copyrights="{}" missing=0 name version dir found + while IFS=$'\t' read -r name version; do + found="" + for root in "${roots[@]}"; do + dir="${root%/}/${name}-${version}" + if [[ -d "$dir" ]]; then + found="$dir" + break + fi + done + if [[ -z "$found" ]]; then + missing=$((missing + 1)) + continue + fi + copyrights=$(jq -c --arg k "${name}@${version}" --arg v "$(copyright_of "$found")" \ + '.[$k] = $v' <<<"$copyrights") || return 1 + done < <(jq -r '.packages[] | select(.source != null) | "\(.name)\t\(.version)"' <<<"$meta") + if [[ "$missing" -ne 0 ]]; then + # Pointer-only: a count, never the crate names, matching this file's siblings. + echo "::error:: sbom: $missing lockfile package(s) have no unpacked source under \$CARGO_HOME/registry/src, so their copyright statements could not be read. Run \`cargo fetch --locked\`; a document that reported NOASSERTION here would depend on this machine's cache rather than on the lockfile." >&2 + return 1 + fi # The workspace's own packages have no `source` — they are not distributed by a # registry at all — so their supplier is this repository's own manifest # identity, passed in rather than re-read here. - jq -c --arg crates "$CRATES_IO_SOURCE" --arg own "$WORKSPACE_SUPPLIER" ' + jq -c --arg crates "$CRATES_IO_SOURCE" --arg own "$WORKSPACE_SUPPLIER" \ + --arg owncopyright "$WORKSPACE_COPYRIGHT" \ + --argjson copyrights "$copyrights" ' [.packages[] - | {key: "\(.name)@\(.version)", + | "\(.name)@\(.version)" as $key + | {key: $key, value: { supplier: (if .source == $crates then "Organization: crates.io" @@ -263,11 +384,45 @@ cargo_entities() { else "NOASSERTION" end), originator: ((.authors // []) | if length == 0 then "NOASSERTION" - else "Organization: " + .[0] end) - }}] + else "Organization: " + .[0] end), + # `NONE` rather than `NOASSERTION` where the pinned bytes carry no + # statement: we looked at all of them, so "there is none" is what we + # actually determined. The workspace member has no pinned source to read + # and its own statement is not asserted here. + copyright: + (if .source == null then $owncopyright + elif ($copyrights[$key] // "") == "" then "NONE" + else $copyrights[$key] end) + }}] | from_entries' <<<"$meta" } +# The workspace member has no pinned registry source, so its copyright is read +# from THIS repository's own license-shaped files — the same anchored pattern, over +# the tree being scanned. Restricted to the root rather than recursive, unlike the +# registry-cache reader: a repository's own tree contains test fixtures and +# vendored material whose copyright lines are not this package's, and the fallback +# that is safe for an unpacked crate is not safe here. +# +# On this tree the answer is `NONE`, and it is the right one rather than a +# shortfall: the only license file is `LICENSE-APACHE`, whose sole mentions of the +# word are the Apache-2.0 boilerplate the pattern is built to reject. We read the +# bytes and there is no copyright statement in them. +workspace_copyright() { + local line="" files=() + shopt -s nullglob nocaseglob + files=(LICENSE* COPYING* COPYRIGHT* NOTICE*) + shopt -u nocaseglob + if [[ "${#files[@]}" -gt 0 ]]; then + line=$(grep -hoiE "$COPYRIGHT_RE" "${files[@]}" 2>/dev/null | sed "$COPYRIGHT_TIDY" | head -1) || line="" + fi + if [[ -z "$line" ]]; then + printf 'NONE' + return 0 + fi + printf '%s' "$line" +} + # Read from the workspace manifest rather than written here, for the reason # `crate_version` gives about the version: a label this file invents can disagree # with what the package actually declares. @@ -309,6 +464,7 @@ readonly SPDX_ENTITIES=' elif $entities[$key] then .supplier = $entities[$key].supplier | .originator = $entities[$key].originator + | .copyrightText = $entities[$key].copyright else . end] ' @@ -322,6 +478,8 @@ readonly CDX_ENTITIES=' .publisher = $entities[$key].supplier | (if $entities[$key].originator == "NOASSERTION" then . else .author = ($entities[$key].originator | ltrimstr("Organization: ")) end) + | (if $entities[$key].copyright == "NONE" or $entities[$key].copyright == "NOASSERTION" then . + else .copyright = $entities[$key].copyright end) else . end] ' @@ -375,6 +533,8 @@ main() { # spends a minute cataloguing a tree whose subject it could not name. WORKSPACE_SUPPLIER=$(workspace_supplier) export WORKSPACE_SUPPLIER + WORKSPACE_COPYRIGHT=$(workspace_copyright) + export WORKSPACE_COPYRIGHT mkdir -p "$OUT_DIR" # One scan, both formats: the catalogers run once and each output is a diff --git a/tests/ntia-check.bats b/tests/ntia-check.bats index df4ddd042..3ca539a0f 100644 --- a/tests/ntia-check.bats +++ b/tests/ntia-check.bats @@ -54,12 +54,22 @@ stub_cargo() { cat >"$STUB/cargo" <<'EOF' #!/usr/bin/env bash set -euo pipefail +# `fetch` is a no-op here: this synthetic tree has no crates to fetch. +[ "${1:-}" != "fetch" ] || exit 0 [ "${1:-}" = "metadata" ] || exit 1 cat <<'JSON' {"packages":[{"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]} JSON EOF chmod +x "$STUB/cargo" + # An unpacked registry cache for each package the stub declares. `sbom.sh` + # refuses to produce a document when a package the lockfile names has no + # unpacked source, so without this every case here would exercise that refusal + # instead of what it means to test. Empty directories, which yield `NONE` — no + # case in this suite asserts on a copyright value. + export CARGO_HOME="$BATS_TEST_TMPDIR/cargo" + mkdir -p "$CARGO_HOME/registry/src/index.crates.io-fixture" + mkdir -p "$CARGO_HOME/registry/src/index.crates.io-fixture/crate0-1.0.0" } # A `syft` that writes the two documents `mise-tasks/sbom.sh` asks for. Sentinels: diff --git a/tests/sbom-check.bats b/tests/sbom-check.bats index e255696fa..26e6b31e2 100644 --- a/tests/sbom-check.bats +++ b/tests/sbom-check.bats @@ -44,6 +44,8 @@ stub_cargo() { cat >"$STUB/cargo" <<'EOF' #!/usr/bin/env bash set -euo pipefail +# `fetch` is a no-op here: this synthetic tree has no crates to fetch. +[ "${1:-}" != "fetch" ] || exit 0 [ "${1:-}" = "metadata" ] || exit 1 cat <<'JSON' {"packages":[ @@ -54,6 +56,16 @@ cat <<'JSON' JSON EOF chmod +x "$STUB/cargo" + # An unpacked registry cache for each package the stub declares. `sbom.sh` + # refuses to produce a document when a package the lockfile names has no + # unpacked source, so without this every case here would exercise that refusal + # instead of what it means to test. Empty directories, which yield `NONE` — no + # case in this suite asserts on a copyright value. + export CARGO_HOME="$BATS_TEST_TMPDIR/cargo" + mkdir -p "$CARGO_HOME/registry/src/index.crates.io-fixture" + mkdir -p "$CARGO_HOME/registry/src/index.crates.io-fixture/crate0-1.0.0" + mkdir -p "$CARGO_HOME/registry/src/index.crates.io-fixture/renamed-1.0.0" + mkdir -p "$CARGO_HOME/registry/src/index.crates.io-fixture/mystery-UNKNOWN" } # A Cargo.lock declaring $1 SOURCED packages — the number the cargo purl count must @@ -297,6 +309,10 @@ EOF # is pointed at a tree and a toolchain that actually satisfy it, which is the # only way the suite can also assert the committed pin works. unset SBOM_ROOT + # And the real registry cache: `setup` points CARGO_HOME at a fixture holding + # only the stub's crates, which for the real tree would be an absent-source + # refusal rather than a verdict about the document. + unset CARGO_HOME PATH="${PATH#"$STUB":}" export PATH cd "$BATS_TEST_DIRNAME/.." || return 1 diff --git a/tests/sbom.bats b/tests/sbom.bats index 44903af91..1ae4fe699 100644 --- a/tests/sbom.bats +++ b/tests/sbom.bats @@ -233,11 +233,34 @@ cdx_path() { echo "$BATS_TEST_TMPDIR/out/batten.cdx.json"; } # package here resolves to crates.io and 226 of 281 declare an author. A synthetic # metadata fixture is the only way to reach a git source or an empty author list. -# A `cargo` whose `metadata` answers with the packages named in $1 (a JSON array). +# A `cargo` whose `metadata` answers with the packages named in $1 (a JSON array), +# AND an unpacked registry cache holding a directory for each of them. +# +# The cache half is not a convenience: `sbom.sh` refuses to produce a document when +# a package the lockfile names has no unpacked source, deliberately, so a fixture +# declaring a dependency it does not materialise is testing that refusal rather +# than whatever it meant to test. Directories are created empty, which yields +# `NONE` — the cases that want a holder write one with `fake_crate`, and the case +# that wants the refusal uses `stub_cargo_uncached`. stub_cargo() { + stub_cargo_uncached "$1" + local nv + while read -r nv; do + [ -n "$nv" ] || continue + mkdir -p "$BATS_TEST_TMPDIR/cargo/registry/src/index.crates.io-fixture/$nv" + done < <(jq -r '.[] | select(.source != null) | "\(.name)-\(.version)"' <<<"$1") + export CARGO_HOME="$BATS_TEST_TMPDIR/cargo" +} + +# The same stub with NO cache entries, so the absent-source refusal is reachable. +stub_cargo_uncached() { + export CARGO_HOME="$BATS_TEST_TMPDIR/cargo" + mkdir -p "$CARGO_HOME/registry/src/index.crates.io-fixture" cat >"$STUB/cargo" <"$dir/$file" + export CARGO_HOME="$BATS_TEST_TMPDIR/cargo" +} + +copyright_of() { jq -r --arg n "$1" '[.packages[] | select(.name == $n) | .copyrightText // "ABSENT"] | first' "$(spdx_path)"; } + +@test "THE BOILERPLATE TRAP: an Apache-2.0 LICENSE yields NONE, never the license prose" { + # Two distinct failures meet in this one case. A loose extractor greps for the + # word and writes `copyright notice that is included in or attached to the work` + # — a fragment of the Apache-2.0 text — into the field, asserting license prose + # as a copyright statement. A timid one writes NOASSERTION and forfeits + # conformance for data it actually read. Neither is acceptable and only this + # fixture separates them. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + fake_crate "boiler-1.0.0" ' Apache License + Version 2.0, January 2004 + + 4. Redistribution. You may reproduce and distribute copies of the Work + provided that You retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and attribution + notices from the Source form of the Work, and You must include a + copyright notice that is included in or attached to the work. +' + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"boiler","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/boiler@1.0.0"}]}' '{"bom-ref":"r-a","name":"boiler","version":"1.0.0","purl":"pkg:cargo/boiler@1.0.0"}' + stub_cargo '[{"name":"boiler","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(copyright_of boiler)" = "NONE" ] + [[ "$(copyright_of boiler)" != *"notice that is included in or attached"* ]] +} + +@test "an MIT-style LICENSE yields exactly its holder line" { + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + fake_crate "aho-1.1.5" 'Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +' + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"aho","versionInfo":"1.1.5","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/aho@1.1.5"}]}' '{"bom-ref":"r-a","name":"aho","version":"1.1.5","purl":"pkg:cargo/aho@1.1.5"}' + stub_cargo '[{"name":"aho","version":"1.1.5","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Andrew Gallant"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(copyright_of aho)" = "Copyright (c) 2015 Andrew Gallant" ] +} + +@test "a holder outside the license files is still found, and a comment marker is stripped" { + # Measured 2026-08-23: 4 of the 11 crates shipping no license file at all do + # state a holder elsewhere in their pinned tree. A license-files-only rule + # writes NONE over data the checksum-pinned bytes actually carry, which is the + # timid failure in its other form. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + fake_crate "headered-1.0.0" '// Copyright 2015, Yuheng Chen. +// Licensed under whatever. +fn main() {} +' "src/lib.rs" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"headered","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/headered@1.0.0"}]}' '{"bom-ref":"r-a","name":"headered","version":"1.0.0","purl":"pkg:cargo/headered@1.0.0"}' + stub_cargo '[{"name":"headered","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Chen"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(copyright_of headered)" = "Copyright 2015, Yuheng Chen." ] +} + +@test "a lockfile package absent from the cache is a HARD FAILURE, not a NOASSERTION" { + # Availability is the one thing about the registry cache that really is machine + # state, and this is the mechanism that keeps it from leaking into the document. + # Emitting NOASSERTION here would make the artifact's contents depend on how + # warm this machine's cache is — the property-of-the-world failure the whole + # admissibility argument turns on. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + fake_crate "present-1.0.0" 'Copyright (c) 2020 Someone' + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"absent","versionInfo":"2.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/absent@2.0.0"}]}' '{"bom-ref":"r-a","name":"absent","version":"2.0.0","purl":"pkg:cargo/absent@2.0.0"}' + stub_cargo_uncached '[{"name":"absent","version":"2.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Nobody"]}]' + run "$SBOM" + [ "$status" -eq 1 ] + [[ "$output" == *"no unpacked source"* ]] + # Pointer-only: a count, never the crate name. + [[ "$output" != *"absent-2.0.0"* ]] +} + +@test "the copyright pass is deterministic across two runs" { + # The most-frequent-line rule breaks ties on the sorted line precisely so that + # `sbom-check`'s byte comparison of two scans holds. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + fake_crate "multi-1.0.0" 'Copyright (c) 2020 First Holder +Copyright (c) 2021 Second Holder +Copyright (c) 2021 Second Holder +' + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"multi","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/multi@1.0.0"}]}' '{"bom-ref":"r-a","name":"multi","version":"1.0.0","purl":"pkg:cargo/multi@1.0.0"}' + stub_cargo '[{"name":"multi","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + local first + first="$(copyright_of multi)" + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(copyright_of multi)" = "$first" ] + # The license file is authoritative, so its FIRST anchored line wins there — + # the frequency rule is the fallback for crates whose license files carry none. + [ "$first" = "Copyright (c) 2020 First Holder" ] +} + @test "a cargo metadata that cannot run fails rather than shipping NOASSERTION" { printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"crate0","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/crate0@1.0.0"}]}' '{"bom-ref":"r-a","name":"crate0","version":"1.0.0","purl":"pkg:cargo/crate0@1.0.0"}' + # `fetch` succeeds and `metadata` does not, because the fetch runs first: a + # stub that failed both would exercise the fetch refusal and assert the + # metadata one, which is a case that passes for the wrong reason. cat >"$STUB/cargo" <<'EOF' #!/usr/bin/env bash +[ "${1:-}" != "fetch" ] || exit 0 exit 1 EOF chmod +x "$STUB/cargo" From 2ca29629baa2028f75b380edf42ef9b0f9b170e9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 03:17:21 +0000 Subject: [PATCH 05/12] fix(sbom): concluded license from cargo metadata, and the slash form rewritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `licenseConcluded` was NOASSERTION on every cargo component while `cargo metadata` reported a license for all of them — 281 of 281, none falling back to `license-file`. This is the one field whose data was authoritative here all along: `cargo-deny` already gates on the same expressions, so reading them makes no new trust decision, it stops the document withholding what the repository already acts on. `--locked` alongside `--offline`, per the row: the document is derived from the tagged source, so a resolution that could differ from Cargo.lock would make the inventory describe a tree nobody shipped. Written to BOTH SPDX fields. `licenseDeclared` is what the package states, which is exactly what a manifest is; `licenseConcluded` is the conclusion drawn from it, and concluding the declaration is defensible precisely because `deny.toml` gates on it. Leaving the conclusion at NOASSERTION with the declaration beside it would be a document declining to say what this repository enforces everywhere else. THE DEPRECATED SLASH FORM IS REWRITTEN, and that is a documented equivalence rather than an interpretation: the cargo manifest reference defines `/` as the deprecated spelling of OR. Measured on this tree, 10 packages still use it (`Apache-2.0/MIT`, `Apache-2.0 / MIT`), and it is not a parseable SPDX license expression — writing it verbatim would put an unreadable value in a field whose entire purpose is to be read. After the rewrite all 24 distinct expressions in the document parse. An empty manifest license stays NOASSERTION. Nothing in this tree exercises that path, so only a synthetic fixture reaches it — which is exactly the guessing this row exists not to do, and the mutation that fills it with a plausible license is what shows the case discriminates. New `sbom-license-unenriched` clause, refusing both a component the manifest describes and the document does not, and a slash form that reached the document unrewritten. Pointer-only: counts, never an expression or a package name. `sbom-check` on the real tree now reports 280 cargo packages matching the lockfile, 290 distinct components, every one carrying a supplier and a license, 162 with a copyright holder and 119 determined to have none, and two scans agreeing. What this does NOT claim: `ntia-check` still reports non-zero and `sbom-ntia-conformance` stays `warn`. The 9 SHA-pinned actions gain no license here — that is CLOUD-667 — and the promotion is CLOUD-631, last in the chain by its own acceptance clause. Refs: CLOUD-926 Closes CLOUD-628 --- mise-tasks/sbom-check.sh | 32 +++++++++++++++++++--- mise-tasks/sbom.sh | 34 ++++++++++++++++++++++- tests/ntia-check.bats | 2 +- tests/sbom-check.bats | 6 ++-- tests/sbom.bats | 59 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/mise-tasks/sbom-check.sh b/mise-tasks/sbom-check.sh index 03d81d3e3..96512b1a1 100755 --- a/mise-tasks/sbom-check.sh +++ b/mise-tasks/sbom-check.sh @@ -251,15 +251,24 @@ entities=$(jq -r --argjson authored "$authored" ' holder: ($cargo | map(select(((.copyrightText // "NOASSERTION") | test("^Copyright"; "i")))) | length), none: ($cargo | map(select((.copyrightText // "NOASSERTION") == "NONE")) | length), unset: ($cargo | map(select(((.copyrightText // "NOASSERTION") == "NOASSERTION") - or ((.copyrightText // "") == ""))) | length) + or ((.copyrightText // "") == ""))) | length), + # CLOUD-628. A cargo component whose license the manifest states and the + # document does not is the whole finding; one the manifest leaves empty is + # honest absence and is counted separately rather than refused, because + # guessing is what this must not do. The slash count is the second half: the + # deprecated cargo spelling is not a valid SPDX expression, so one reaching + # the document unrewritten is an unparseable field rather than a missing one. + nolicense: ($cargo | map(select(((.licenseConcluded // "NOASSERTION") == "NOASSERTION") + or ((.licenseConcluded // "") == ""))) | length), + slashed: ($cargo | map(select(((.licenseConcluded // "") | test("/")))) | length) } - | "\(.cargo) \(.nosupplier) \(.disagrees) \(.subjectunset) \(.holder) \(.none) \(.unset)" + | "\(.cargo) \(.nosupplier) \(.disagrees) \(.subjectunset) \(.holder) \(.none) \(.unset) \(.nolicense) \(.slashed)" ' "$spdx_one") || entities="" if [[ -z "$entities" ]]; then echo "::error:: sbom-check: could not read supplier and originator from ${spdx_one##*/}, so those fields are unverified." >&2 exit 2 fi -read -r cargo_components nosupplier disagrees subjectunset holder none unset <<<"$entities" +read -r cargo_components nosupplier disagrees subjectunset holder none unset nolicense slashed <<<"$entities" # Pointer-only per rule 4, and it matters more here than elsewhere in this file: # an `authors` entry is a personal name and often an email address, so the finding # carries counts and never a value. @@ -285,9 +294,24 @@ if [[ "$unset" -ne 0 ]]; then report "${spdx_one##*/}:0" "sbom-copyright-unenriched (cargo=$cargo_components holder=$holder none=$none unset=$unset)" fi +# --- license (CLOUD-628) ----------------------------------------------------- +# +# `cargo metadata` reports a license for every package in this tree and +# `cargo-deny` already gates on those same expressions, so this is the one field +# whose data was authoritative here all along and simply unused by the document. +# The clause refuses a component the manifest describes and the document does not, +# and separately refuses the deprecated slash spelling, which is not a valid SPDX +# expression — an unparseable value in a field whose purpose is to be parsed is +# worse than an honest NOASSERTION. +# +# Pointer-only: counts, never an expression or a package name. +if [[ "$nolicense" -ne 0 ]] || [[ "$slashed" -ne 0 ]]; then + report "${spdx_one##*/}:0" "sbom-license-unenriched (cargo=$cargo_components no-license=$nolicense slash-form=$slashed)" +fi + if [[ "$violations" -ne 0 ]]; then echo "::error:: sbom-check: $violations violation(s). Re-run 'mise run sbom' and inspect the documents; a count mismatch means a cataloger missed something, an unstable one means a field varies that the normalizer does not cover." >&2 exit 1 fi -echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock's $declared sourced entries of $lock_packages, $entries component(s) each a distinct thing, every one carrying a supplier, $holder with a copyright holder and $none determined to have none, and two scans agree" +echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock's $declared sourced entries of $lock_packages, $entries component(s) each a distinct thing, every one carrying a supplier and a license, $holder with a copyright holder and $none determined to have none, and two scans agree" diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index f786f5fea..ba179949a 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -59,6 +59,11 @@ # is rather than on the lockfile. #MUTANT sbom-copyright-residue-is-noassertion|s@== "" then "NONE"@== "" then "NOASSERTION"@|THE BOILERPLATE TRAP #MUTANT sbom-tolerates-an-absent-source|s@^\tif \[\[ "\$missing" -ne 0 \]\]; then$@\tif false; then@|a lockfile package absent from the cache is a HARD FAILURE +# And CLOUD-628. The deprecated slash spelling reaching the document unrewritten +# is an unparseable SPDX expression in a field whose purpose is to be parsed; a +# manifest with no license must stay NOASSERTION rather than borrow a neighbour. +#MUTANT sbom-keeps-the-slash-license-form|s@gsub("\[\[:space:\]\]\*/\[\[:space:\]\]\*"; " OR ")@.@|the deprecated slash spelling is rewritten to OR +#MUTANT sbom-invents-a-missing-license|s@if . == "" then "NOASSERTION"@if . == "" then "Apache-2.0"@|HONEST ABSENCE set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" @@ -334,7 +339,7 @@ cargo_entities() { echo "::error:: sbom: \`cargo fetch --locked\` failed, so the pinned sources the copyright statements are read from are not present" >&2 return 1 fi - if ! meta=$(cargo metadata --format-version 1 --offline 2>/dev/null); then + if ! meta=$(cargo metadata --format-version 1 --locked --offline 2>/dev/null); then echo "::error:: sbom: could not read cargo metadata, so supplier and originator are unknown for every cargo component" >&2 return 1 fi @@ -389,6 +394,29 @@ cargo_entities() { # statement: we looked at all of them, so "there is none" is what we # actually determined. The workspace member has no pinned source to read # and its own statement is not asserted here. + # CLOUD-628. `cargo metadata` reports a license for every package in + # this tree (281 of 281, none falling back to `license-file`), and + # `cargo-deny` already judges these same expressions, so the data is + # authoritative here today and was merely unused by the document. + # + # Written to BOTH SPDX fields, and the pair is the honest reading: + # `licenseDeclared` is what the package states, which is exactly what a + # manifest is, and `licenseConcluded` is the conclusion drawn by + # whoever authored the document. Concluding the declaration is defensible precisely because + # `deny.toml` already gates on it; leaving `licenseConcluded` at + # NOASSERTION while the declaration sits beside it would be a document + # withholding a conclusion it acts on everywhere else. + # + # THE DEPRECATED SLASH FORM IS REWRITTEN, and that is a documented + # equivalence rather than a guess: the cargo manifest reference says + # `/` is the deprecated spelling of OR. Measured on this tree, 10 + # packages still use it (`Apache-2.0/MIT`, `Apache-2.0 / MIT`), and it is + # not a valid SPDX license expression — writing it verbatim would put an + # unparseable expression in a field whose whole purpose is to be parsed. + license: + ((.license // "") + | if . == "" then "NOASSERTION" + else gsub("[[:space:]]*/[[:space:]]*"; " OR ") end), copyright: (if .source == null then $owncopyright elif ($copyrights[$key] // "") == "" then "NONE" @@ -465,6 +493,8 @@ readonly SPDX_ENTITIES=' .supplier = $entities[$key].supplier | .originator = $entities[$key].originator | .copyrightText = $entities[$key].copyright + | .licenseDeclared = $entities[$key].license + | .licenseConcluded = $entities[$key].license else . end] ' @@ -480,6 +510,8 @@ readonly CDX_ENTITIES=' else .author = ($entities[$key].originator | ltrimstr("Organization: ")) end) | (if $entities[$key].copyright == "NONE" or $entities[$key].copyright == "NOASSERTION" then . else .copyright = $entities[$key].copyright end) + | (if $entities[$key].license == "NOASSERTION" then . + else .licenses = [{expression: $entities[$key].license}] end) else . end] ' diff --git a/tests/ntia-check.bats b/tests/ntia-check.bats index 3ca539a0f..8f7df367e 100644 --- a/tests/ntia-check.bats +++ b/tests/ntia-check.bats @@ -58,7 +58,7 @@ set -euo pipefail [ "${1:-}" != "fetch" ] || exit 0 [ "${1:-}" = "metadata" ] || exit 1 cat <<'JSON' -{"packages":[{"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]} +{"packages":[{"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"],"license":"Apache-2.0 OR MIT"}]} JSON EOF chmod +x "$STUB/cargo" diff --git a/tests/sbom-check.bats b/tests/sbom-check.bats index 26e6b31e2..172d2ce18 100644 --- a/tests/sbom-check.bats +++ b/tests/sbom-check.bats @@ -49,9 +49,9 @@ set -euo pipefail [ "${1:-}" = "metadata" ] || exit 1 cat <<'JSON' {"packages":[ - {"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}, - {"name":"renamed","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}, - {"name":"batten","version":"9.9.9","source":null,"authors":["Button Inc."]} + {"name":"crate0","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"],"license":"Apache-2.0 OR MIT"}, + {"name":"renamed","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"],"license":"Apache-2.0 OR MIT"}, + {"name":"batten","version":"9.9.9","source":null,"authors":["Button Inc."],"license":"Apache-2.0"} ]} JSON EOF diff --git a/tests/sbom.bats b/tests/sbom.bats index 1ae4fe699..67cafd00e 100644 --- a/tests/sbom.bats +++ b/tests/sbom.bats @@ -513,6 +513,65 @@ Copyright (c) 2021 Second Holder [ "$first" = "Copyright (c) 2020 First Holder" ] } +# ─── CLOUD-628: license, from the one source already trusted here ───────────── + +license_of() { jq -r --arg n "$1" '[.packages[] | select(.name == $n) | .licenseConcluded // "ABSENT"] | first' "$(spdx_path)"; } +declared_of() { jq -r --arg n "$1" '[.packages[] | select(.name == $n) | .licenseDeclared // "ABSENT"] | first' "$(spdx_path)"; } + +@test "a manifest license reaches BOTH SPDX license fields" { + # `licenseDeclared` is what the package states and `licenseConcluded` is the + # conclusion drawn from it. Concluding the declaration is defensible precisely + # because `deny.toml` already gates on the same expression; withholding the + # conclusion while the declaration sits beside it would be a document declining + # to say what the repository acts on everywhere else. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"licensed","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/licensed@1.0.0"}]}' '{"bom-ref":"r-a","name":"licensed","version":"1.0.0","purl":"pkg:cargo/licensed@1.0.0"}' + stub_cargo '[{"name":"licensed","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"],"license":"Apache-2.0 OR MIT"}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of licensed)" = "Apache-2.0 OR MIT" ] + [ "$(declared_of licensed)" = "Apache-2.0 OR MIT" ] + [ "$(jq -r '[.components[] | select(.name == "licensed") | .licenses[0].expression] | first' "$(cdx_path)")" = "Apache-2.0 OR MIT" ] +} + +@test "the deprecated slash spelling is rewritten to OR, because it is not valid SPDX" { + # Measured 2026-08-23: 10 packages in this tree still use it. `Apache-2.0/MIT` + # is not a parseable SPDX license expression, so writing it verbatim would put + # an unreadable value in a field whose entire purpose is to be read. The + # rewrite is a documented equivalence — the cargo manifest reference defines the + # slash as the deprecated spelling of OR — rather than an interpretation. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"slashy","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/slashy@1.0.0"}]}' '{"bom-ref":"r-a","name":"slashy","version":"1.0.0","purl":"pkg:cargo/slashy@1.0.0"}' + stub_cargo '[{"name":"slashy","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"],"license":"Apache-2.0 / MIT"}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of slashy)" = "Apache-2.0 OR MIT" ] + [[ "$(license_of slashy)" != *"/"* ]] +} + +@test "HONEST ABSENCE: an empty manifest license leaves NOASSERTION rather than guessing" { + # 0 of 281 packages in this tree have an empty `license`, so nothing real + # exercises this path and only a synthetic fixture can reach it — which is + # exactly the guessing this row exists not to do. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"unlicensed","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/unlicensed@1.0.0"}]}' '{"bom-ref":"r-a","name":"unlicensed","version":"1.0.0","purl":"pkg:cargo/unlicensed@1.0.0"}' + stub_cargo '[{"name":"unlicensed","version":"1.0.0","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Someone"]}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of unlicensed)" = "NOASSERTION" ] + # And CycloneDX carries no licenses entry at all rather than an empty one. + [ "$(jq -r '[.components[] | select(.name == "unlicensed") | .licenses] | first // "ABSENT"' "$(cdx_path)")" = "ABSENT" ] +} + +@test "an action keeps whatever license syft gave it — the cargo pass does not reach it" { + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"actions/checkout","versionInfo":"v7","licenseConcluded":"NOASSERTION","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:github/actions/checkout@v7"}]}' '{"bom-ref":"r-a","name":"actions/checkout","version":"v7","purl":"pkg:github/actions/checkout@v7"}' + stub_cargo '[{"name":"actions/checkout","version":"v7","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Wrong"],"license":"WRONG-LICENSE"}]' + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of actions/checkout)" = "NOASSERTION" ] +} + @test "a cargo metadata that cannot run fails rather than shipping NOASSERTION" { printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"crate0","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/crate0@1.0.0"}]}' '{"bom-ref":"r-a","name":"crate0","version":"1.0.0","purl":"pkg:cargo/crate0@1.0.0"}' From 8310b62ecf0aa5250ba14bcac3709c7d39e17a77 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 08:57:08 +0000 Subject: [PATCH 06/12] fix(sbom): license and copyright for the 9 pinned actions, from a gated table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the cargo subset answered, the SHA-pinned GitHub Actions were the only remaining gap between this document and `ntia` conformance — and a far smaller one than the raw counts suggested: 9 unique actions, each already carrying supplier and originator from syft, so only license and copyright were missing. A COMMITTED TABLE IS LEGITIMATE HERE, and the argument is the same one that admitted the registry cache for cargo copyright: a SHA-pinned action's license is immutable, so the fact is a property of THIS commit rather than of the world. What makes a hand-maintained list dangerous is drift with nothing to detect it, and `sbom-action-unmapped` is that detector — it fires on the one event that causes drift, a pin moving, so a renovate bump that does not record the new commit's license fails the gate instead of silently degrading the document. Fetching each LICENSE during the scan was the alternative, and it would put a network call inside the producer and make the document depend on GitHub being reachable. HOW THE ROWS WERE SOURCED, because CONTRIBUTING.md requires it: "A verdict is read from the upstream license file, never from a registry facet, a search result, or a project's own summary of itself." Every value was read from the license file at the pinned commit, fetched as raw bytes and inspected locally rather than summarised. Four rows needed care, and they are why that rule exists: * `Swatinem/rust-cache` ships the LGPLv3 text, whose only Copyright line is `Copyright (C) 2007 Free Software Foundation, Inc.` — the license DOCUMENT's boilerplate, not the project's holder. Recording it would have been the CLOUD-629 error in its purest form. Its package.json declares the deprecated `LGPL-3.0`; the file is v3 with no or-later grant, so `LGPL-3.0-only`. * `sequoia-pgp/fast-forward` is the same shape, and states "GNU Library General Public License ... either version 2 ... or any later version" = `LGPL-2.0-or-later`. * `taiki-e/install-action` ships LICENSE-APACHE **and** LICENSE-MIT, with the README stating "either of ... at your option". Reading one file would have recorded MIT alone. * `actions/attest-build-provenance` states `Copyright GitHub` with no year, so the anchored year-requiring pattern the cargo side uses does not match it. The value is what the file says. `NONE` means the license file and the repository front matter were read at that commit and state no holder — SPDX's "we determined there is nothing", which is conformant where NOASSERTION is not. Matched on the repo rather than the sha, and that is forced: syft keys these components by the `# vX` comment beside the pin, so `pkg:github/actions/checkout@v7` names a component whose `uses:` resolves to `3d3c42e5…`. The sha is what the drift clause compares against the workflows. Two facts recorded for the reader rather than for the gate: two of these are LGPL, and they are build-time CI actions that are not distributed with any batten artifact, so no copyleft obligation attaches to what this repository ships. CONTRIBUTING.md's compatibility column tracks adopted and vendored tools, which these are not. Two defects in my own first cut, both found by running it: a table of nothing but comments crashed on a null object key rather than yielding no rows, and a row short of four fields would have written an empty license into a published document — refused now, and the mutation that accepts one is what shows the case discriminates. **`mise run ntia-check` now exits 0**: `batten.spdx.json conforms to ntia`. That is the predicate CLOUD-631 has been blocked on since it was filed. Refs: CLOUD-926 Closes CLOUD-667 --- mise-tasks/sbom-actions.tsv | 63 ++++++++++++++++++++++ mise-tasks/sbom-check.sh | 66 ++++++++++++++++++++++- mise-tasks/sbom.sh | 101 ++++++++++++++++++++++++++++++++++++ tests/sbom-check.bats | 18 +++++++ tests/sbom.bats | 79 ++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 mise-tasks/sbom-actions.tsv diff --git a/mise-tasks/sbom-actions.tsv b/mise-tasks/sbom-actions.tsv new file mode 100644 index 000000000..b70d2202f --- /dev/null +++ b/mise-tasks/sbom-actions.tsv @@ -0,0 +1,63 @@ +# The license and copyright of every SHA-pinned GitHub Action this repository +# uses (CLOUD-667). One committed authority, read by `mise-tasks/sbom.sh` at scan +# time and gated by `mise-tasks/sbom-check.sh`. +# +# WHY A COMMITTED TABLE IS LEGITIMATE HERE. A SHA-pinned action's license is +# immutable: the bytes at that commit cannot change, so the fact is a property of +# THIS commit rather than of the world — the same argument that makes the +# checksum-pinned registry cache admissible for cargo copyright (CLOUD-629). What +# makes a hand-maintained list dangerous is drift with nothing to detect it, and +# `sbom-action-unmapped` is that detector: it fires on the one event that causes +# drift, a pin moving. +# +# The alternative — fetching each LICENSE during the scan — would put a network +# call inside the producer and make the document depend on GitHub being reachable. +# That is the property-of-the-world failure this repository has ruled out twice. +# +# HOW EACH ROW WAS SOURCED, because CONTRIBUTING.md requires it: "A verdict is +# read from the upstream license file, never from a registry facet, a search +# result, or a project's own summary of itself." Every value below was read on +# 2026-08-23 from the license file at the pinned commit, fetched as raw bytes from +# `raw.githubusercontent.com////` and inspected locally +# rather than summarised. Four rows needed care, and they are why that rule exists: +# +# * `Swatinem/rust-cache` ships the LGPLv3 text. Its only Copyright line is +# `Copyright (C) 2007 Free Software Foundation, Inc.` — the LICENSE DOCUMENT's +# own boilerplate, not the project's holder. Recording it would have been the +# CLOUD-629 error in its purest form: license prose asserted as a copyright +# statement. Its `package.json` declares the deprecated id `LGPL-3.0`; the file +# is version 3 with no or-later grant, so `LGPL-3.0-only` is the current id. +# * `sequoia-pgp/fast-forward` is the same shape — its `Copyright (C) 1991 Free +# Software Foundation, Inc.` is the license text's, and the grant it states is +# "GNU Library General Public License ... either version 2 ... or (at your +# option) any later version" = `LGPL-2.0-or-later`. +# * `taiki-e/install-action` ships LICENSE-APACHE **and** LICENSE-MIT, and its +# README states "Licensed under either of ... at your option". A single-file +# read would have recorded MIT alone. +# * `actions/attest-build-provenance` states `Copyright GitHub` with NO YEAR, so +# the anchored year-requiring pattern the cargo side uses does not match it. +# The value is what the file says. +# +# `NONE` means the license file and the repository front matter (README, +# package.json, Cargo.toml, NOTICE) were read at that commit and state no +# copyright holder. It is SPDX's "we determined there is nothing", which is +# conformant where `NOASSERTION` is not — never a nicer word for unread. +# +# Two of these are LGPL. They are build-time CI actions and are not distributed +# with any batten artifact, so no copyleft obligation attaches to what this +# repository ships; CONTRIBUTING.md's Apache-2.0 compatibility column tracks +# ADOPTED and VENDORED tools, which these are not. +# +# Columns, tab-separated: repo, pinned sha, SPDX license expression, copyright. +# The sha is the authority for drift; enrichment matches on the repo, because the +# document keys components by the `# vX` comment beside the pin rather than by the +# sha itself, and a comment is a label a human can get wrong. +actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1 MIT Copyright (c) 2018 GitHub, Inc. and contributors +jdx/mise-action 9dda3952d607125725deac9ec10a5f0e245d266b MIT Copyright (c) 2018 GitHub, Inc. and contributors +Swatinem/rust-cache 6323deb102c322ba6fcbdcafc7e3dddab59af2b6 LGPL-3.0-only NONE +actions/cache 55cc8345863c7cc4c66a329aec7e433d2d1c52a9 MIT Copyright (c) 2018 GitHub, Inc. and contributors +actions/upload-artifact 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a MIT Copyright (c) 2018 GitHub, Inc. and contributors +linear/linear-release-action 17b8c24f8ceb2b98cabaf1965ff83c55dd596fac MIT Copyright (c) 2026 Linear +sequoia-pgp/fast-forward ea7628bedcb0b0b96e94383ada458d812fca4979 LGPL-2.0-or-later NONE +taiki-e/install-action 91ddec75689c4c78665b598d188dc821c5a43e5c Apache-2.0 OR MIT NONE +actions/attest-build-provenance 4d101475d8b20a2381f78447822ac1eab6504dd8 MIT Copyright GitHub diff --git a/mise-tasks/sbom-check.sh b/mise-tasks/sbom-check.sh index 96512b1a1..2c02022b8 100755 --- a/mise-tasks/sbom-check.sh +++ b/mise-tasks/sbom-check.sh @@ -309,9 +309,73 @@ if [[ "$nolicense" -ne 0 ]] || [[ "$slashed" -ne 0 ]]; then report "${spdx_one##*/}:0" "sbom-license-unenriched (cargo=$cargo_components no-license=$nolicense slash-form=$slashed)" fi +# --- the pinned actions (CLOUD-667) ------------------------------------------ +# +# The 9 SHA-pinned actions were the last conformance gap. Two clauses, and the +# second is what keeps a committed table from rotting into a list nobody updates. +ACTIONS_TABLE="${SBOM_ACTIONS_TABLE:-}" +if [[ -z "$ACTIONS_TABLE" ]]; then + ACTIONS_TABLE="$(cd "$(dirname "$0")" && pwd)/sbom-actions.tsv" +fi +readonly ACTIONS_TABLE + +# 1. Every `pkg:github` component carries both fields. +actions=$(jq -r ' + [.packages[]? + | select(([.externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first // "") + | startswith("pkg:github/"))] as $gh + | { + total: ($gh | length), + unset: ($gh | map(select(((.licenseConcluded // "NOASSERTION") == "NOASSERTION") + or ((.copyrightText // "NOASSERTION") == "NOASSERTION"))) | length) + } + | "\(.total) \(.unset)" +' "$spdx_one") || actions="" +if [[ -z "$actions" ]]; then + echo "::error:: sbom-check: could not read the action components from ${spdx_one##*/}, so their license and copyright are unverified." >&2 + exit 2 +fi +read -r action_total action_unset <<<"$actions" +if [[ "$action_unset" -ne 0 ]]; then + report "${spdx_one##*/}:0" "sbom-action-unenriched (actions=$action_total unset=$action_unset)" +fi + +# 2. THE DRIFT DETECTOR, and the reason a committed table is defensible at all. +# A pinned action's license is immutable, so recording it is a property of this +# commit — but only while the table still describes the pins the workflows carry. +# This fires on the one event that breaks that: a pin moving. A renovate bump that +# does not record the new commit's license fails the gate rather than silently +# degrading the document. +# +# Matched on repo AND sha together: a table row whose sha is stale is exactly the +# drift, so comparing the pair is the check. Pointer-only — the workflow file and +# line, never a license or a holder. +if [[ ! -r "$ACTIONS_TABLE" ]]; then + echo "::error:: sbom-check: cannot read ${ACTIONS_TABLE##*/}, so whether every pinned action is mapped is unverified." >&2 + exit 2 +fi +unmapped=0 +while IFS= read -r pin; do + [[ -n "$pin" ]] || continue + # `::@` + ref="${pin##*:}" + where="${pin%:*}" + repo="${ref%@*}" + sha="${ref#*@}" + if ! grep -qF "$(printf '%s %s ' "$repo" "$sha")" "$ACTIONS_TABLE"; then + echo "$where sbom-action-unmapped ($repo)" >&2 + unmapped=$((unmapped + 1)) + fi +done < <(grep -rnoE 'uses:[[:space:]]+[^[:space:]]+@[0-9a-f]{40}' .github/workflows/ 2>/dev/null | + sed -E 's@uses:[[:space:]]+@@' | sort -u) +if [[ "$unmapped" -ne 0 ]]; then + violations=$((violations + 1)) + echo "${ACTIONS_TABLE##*/}:0 sbom-action-unmapped (unmapped=$unmapped)" >&2 +fi + if [[ "$violations" -ne 0 ]]; then echo "::error:: sbom-check: $violations violation(s). Re-run 'mise run sbom' and inspect the documents; a count mismatch means a cataloger missed something, an unstable one means a field varies that the normalizer does not cover." >&2 exit 1 fi -echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock's $declared sourced entries of $lock_packages, $entries component(s) each a distinct thing, every one carrying a supplier and a license, $holder with a copyright holder and $none determined to have none, and two scans agree" +echo "sbom-check: $spdx_cargo cargo package(s) in both formats, matching Cargo.lock's $declared sourced entries of $lock_packages, $entries component(s) each a distinct thing, every one carrying a supplier and a license, $holder with a copyright holder and $none determined to have none, $action_total pinned action(s) all mapped, and two scans agree" diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index ba179949a..a4951d3bc 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -64,6 +64,11 @@ # manifest with no license must stay NOASSERTION rather than borrow a neighbour. #MUTANT sbom-keeps-the-slash-license-form|s@gsub("\[\[:space:\]\]\*/\[\[:space:\]\]\*"; " OR ")@.@|the deprecated slash spelling is rewritten to OR #MUTANT sbom-invents-a-missing-license|s@if . == "" then "NOASSERTION"@if . == "" then "Apache-2.0"@|HONEST ABSENCE +# And CLOUD-667. Skipping the actions pass returns all 9 to NOASSERTION, which is +# the gap that made the promotion impossible; a short table row must be refused +# rather than writing an empty license into a published document. +#MUTANT sbom-skips-the-actions-table|s@^\tif ! enrich_actions "\$spdx" "\$SPDX_ACTIONS" "\$actions"; then@\tif false; then@|a mapped action carries its license and copyright +#MUTANT sbom-accepts-a-short-action-row|s@if (NF < 4)@if (NF < 0)@|a table row with fewer than four fields is refused set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" @@ -330,6 +335,76 @@ copyright_of() { printf '%s' "$line" } +# --- the actions table (CLOUD-667) ------------------------------------------- +# +# The 9 SHA-pinned GitHub Actions were the last conformance gap: they already +# carry `supplier` and `originator` (syft derives both from the namespace owner), +# so only license and copyright were missing. The values live in one committed +# table beside this file, whose own header records how each row was sourced and +# which four needed care. +# +# MATCHED ON THE REPO, NOT THE SHA, and that is forced rather than chosen: syft +# keys these components by the `# vX` comment beside the pin, not by the pin +# itself — `pkg:github/actions/checkout@v7` for a component whose `uses:` line +# resolves to `3d3c42e5…`. The sha in the table is what `sbom-action-unmapped` +# compares against the workflows, so drift is still caught at the pin; using it +# here would match nothing. +ACTIONS_TABLE="${SBOM_ACTIONS_TABLE:-$(cd "$(dirname "$0")" && pwd)/sbom-actions.tsv}" + +# `{"/": {license, copyright}}` from the committed table. +action_entities() { + if [[ ! -r "$ACTIONS_TABLE" ]]; then + echo "::error:: sbom: cannot read ${ACTIONS_TABLE##*/}, so no pinned action can be given its license or copyright" >&2 + return 1 + fi + # Comments and blank lines skipped by shape. A row short of four fields is a + # malformed table rather than a missing row, and is refused: a partial row + # would silently write an empty license into the document. + local out + if ! out=$(awk -F'\t' ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*$/ { next } + { + if (NF < 4) { print "MALFORMED:" NR > "/dev/stderr"; bad = 1; next } + printf "%s\t%s\t%s\n", $1, $3, $4 + } + END { if (bad) exit 1 } + ' "$ACTIONS_TABLE"); then + echo "::error:: sbom: ${ACTIONS_TABLE##*/} carries a row with fewer than four tab-separated fields, so an action would be given an empty license" >&2 + return 1 + fi + jq -Rn '[inputs + | select(length > 0) + | split("\t") + | {key: .[0], value: {license: .[1], copyright: .[2]}}] + | from_entries' <<<"$out" +} + +# Only `pkg:github` components, and only the two fields syft leaves NOASSERTION — +# its supplier and originator are derived from the namespace owner and are more +# specific than anything this table knows. +# shellcheck disable=SC2016 # a jq program: `$actions` is a jq binding, not shell +readonly SPDX_ACTIONS=' + .packages = [.packages[]? + | ([.externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first // "") as $purl + | if ($purl | startswith("pkg:github/")) and $actions[(.name // "")] + then .licenseDeclared = $actions[(.name // "")].license + | .licenseConcluded = $actions[(.name // "")].license + | .copyrightText = $actions[(.name // "")].copyright + else . end] +' + +# shellcheck disable=SC2016 # a jq program: `$actions` is a jq binding, not shell +readonly CDX_ACTIONS=' + .components = [.components[]? + | (.purl // "") as $purl + | if ($purl | startswith("pkg:github/")) and $actions[(.name // "")] + then .licenses = [{expression: $actions[(.name // "")].license}] + | (if $actions[(.name // "")].copyright == "NONE" then . + else .copyright = $actions[(.name // "")].copyright end) + else . end] +' + # The map the enrichment reads: # `{"@": {supplier, originator, copyright}}`. Built once, from one # `cargo metadata` call plus one pass over the pinned sources. @@ -529,6 +604,20 @@ normalize() { mv "$tmp" "$doc" } +# Same shape as `enrich`, with the table bound as `$actions`. A separate binding +# name rather than reusing `$entities`, so a jq program cannot silently read the +# wrong map if the two calls are ever reordered. +enrich_actions() { + local doc="$1" program="$2" actions="$3" tmp + tmp="${doc}.enriching" + if ! jq --argjson actions "$actions" "$program" "$doc" >"$tmp"; then + rm -f "$tmp" + echo "::error:: sbom: could not write the pinned actions into ${doc##*/}, so they would claim NOASSERTION over data this repository has committed" >&2 + return 1 + fi + mv "$tmp" "$doc" +} + # Same in-place discipline as `normalize`, with the entity map bound as `$entities`. enrich() { local doc="$1" program="$2" entities="$3" tmp @@ -603,6 +692,18 @@ main() { return 1 fi + # The pinned actions (CLOUD-667), from the committed table. + local actions + if ! actions=$(action_entities); then + return 1 + fi + if ! enrich_actions "$spdx" "$SPDX_ACTIONS" "$actions"; then + return 1 + fi + if ! enrich_actions "$cdx" "$CDX_ACTIONS" "$actions"; then + return 1 + fi + # stdout is the answer: pointers to the artifacts, never their bytes (rule 4). # KEY=VALUE so the release workflow appends it to $GITHUB_OUTPUT unchanged, and # `sbom-check` reads the paths from here rather than rebuilding the names. diff --git a/tests/sbom-check.bats b/tests/sbom-check.bats index 172d2ce18..20658d0f6 100644 --- a/tests/sbom-check.bats +++ b/tests/sbom-check.bats @@ -304,6 +304,24 @@ EOF [[ "$output" == *"no DESCRIBES"* ]] } +@test "THE DRIFT DETECTOR: a pin with no table row fails, which is how a bump arrives" { + # The reason a committed table is defensible at all. A pinned action's license + # is immutable, so recording it is a property of this commit — but only while + # the table still describes the pins the workflows carry. This fires on the one + # event that breaks that, and it is the direction it will actually be hit: a + # renovate bump moves a sha, and the row that named the old one no longer + # matches. + mkdir -p "$ROOT/.github/workflows" + printf 'jobs:\n a:\n steps:\n - uses: some/action@%040d\n' 1 >"$ROOT/.github/workflows/w.yml" + printf 'some/action\t%040d\tMIT\tCopyright (c) 2020 Someone\n' 2 >"$ROOT/actions.tsv" + SBOM_ACTIONS_TABLE="$ROOT/actions.tsv" run "$CHECK" + [ "$status" -eq 1 ] + [[ "$output" == *"sbom-action-unmapped"* ]] + # Pointer-only: the workflow file and line, never a license or a holder. + [[ "$output" == *".github/workflows/w.yml"* ]] + [[ "$output" != *"Copyright (c) 2020"* ]] +} + @test "this repo's real tree satisfies the gate — with the real syft" { # The self-consumption case. The stub proves the logic; this proves the logic # is pointed at a tree and a toolchain that actually satisfy it, which is the diff --git a/tests/sbom.bats b/tests/sbom.bats index 67cafd00e..d035ca47b 100644 --- a/tests/sbom.bats +++ b/tests/sbom.bats @@ -564,7 +564,12 @@ declared_of() { jq -r --arg n "$1" '[.packages[] | select(.name == $n) | .licens } @test "an action keeps whatever license syft gave it — the cargo pass does not reach it" { + # The actions table is emptied here on purpose: this case is about the CARGO + # pass not reaching a `pkg:github` component, and leaving the real table in + # play would have the actions pass legitimately set the field, which proves + # something else. printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_table "# no rows" write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"actions/checkout","versionInfo":"v7","licenseConcluded":"NOASSERTION","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:github/actions/checkout@v7"}]}' '{"bom-ref":"r-a","name":"actions/checkout","version":"v7","purl":"pkg:github/actions/checkout@v7"}' stub_cargo '[{"name":"actions/checkout","version":"v7","source":"registry+https://github.com/rust-lang/crates.io-index","authors":["Wrong"],"license":"WRONG-LICENSE"}]' run "$SBOM" @@ -572,6 +577,80 @@ declared_of() { jq -r --arg n "$1" '[.packages[] | select(.name == $n) | .licens [ "$(license_of actions/checkout)" = "NOASSERTION" ] } +# ─── CLOUD-667: the 9 SHA-pinned actions, from a gated committed table ──────── +# +# The table `sbom.sh` reads is `mise-tasks/sbom-actions.tsv`, which is real +# committed data. These cases point the producer at a synthetic one via +# SBOM_ACTIONS_TABLE so a fixture can carry an unmapped pin without editing the +# repository's own table. + +action_table() { + printf '%s\n' "$@" >"$BATS_TEST_TMPDIR/actions.tsv" + export SBOM_ACTIONS_TABLE="$BATS_TEST_TMPDIR/actions.tsv" +} + +action_fixture() { + write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"actions/checkout","versionInfo":"v7","licenseConcluded":"NOASSERTION","copyrightText":"NOASSERTION","supplier":"Organization: GitHub","originator":"Organization: GitHub","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:github/actions/checkout@v7"}]}' '{"bom-ref":"r-a","name":"actions/checkout","version":"v7","purl":"pkg:github/actions/checkout@v7"}' + stub_cargo '[]' +} + +@test "a mapped action carries its license and copyright rather than NOASSERTION" { + # Fails before this row: syft leaves both fields NOASSERTION on every action, + # and no local source can supply them. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_fixture + action_table "$(printf 'actions/checkout\tdeadbeef\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors')" + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of actions/checkout)" = "MIT" ] + [ "$(copyright_of actions/checkout)" = "Copyright (c) 2018 GitHub, Inc. and contributors" ] + # And the supplier syft derived is untouched — the table knows nothing better. + [ "$(supplier_of actions/checkout)" = "Organization: GitHub" ] +} + +@test "NONE is written for an action whose license file states no holder" { + # Two of the nine are like this: their only Copyright line is the LICENSE + # document's own FSF boilerplate, which is not the project's holder. `NONE` is + # the determined answer and is conformant where NOASSERTION is not. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_fixture + action_table "$(printf 'actions/checkout\tdeadbeef\tLGPL-3.0-only\tNONE')" + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(copyright_of actions/checkout)" = "NONE" ] + # CycloneDX omits the field rather than writing the literal NONE. + [ "$(jq -r '[.components[] | select(.name == "actions/checkout") | .copyright] | first // "ABSENT"' "$(cdx_path)")" = "ABSENT" ] +} + +@test "an action absent from the table keeps NOASSERTION rather than borrowing a row" { + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_fixture + action_table "$(printf 'some/other-action\tdeadbeef\tMIT\tCopyright (c) 2020 Someone')" + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of actions/checkout)" = "NOASSERTION" ] +} + +@test "a table row with fewer than four fields is refused, not silently partial" { + # A short row would write an empty license into the document — a field that + # parses as present and says nothing. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_fixture + action_table "$(printf 'actions/checkout\tdeadbeef\tMIT')" + run "$SBOM" + [ "$status" -eq 1 ] + [[ "$output" == *"fewer than four"* ]] +} + +@test "comments and blank lines in the table are skipped by shape" { + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_fixture + action_table "# a comment" "" "$(printf 'actions/checkout\tdeadbeef\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors')" + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of actions/checkout)" = "MIT" ] +} + @test "a cargo metadata that cannot run fails rather than shipping NOASSERTION" { printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" write_fixtures '{"SPDXID":"SPDXRef-P-a","name":"crate0","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:cargo/crate0@1.0.0"}]}' '{"bom-ref":"r-a","name":"crate0","version":"1.0.0","purl":"pkg:cargo/crate0@1.0.0"}' From c23b250825ad34d86c75985d6cf151d5d26d95ef Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 09:01:20 +0000 Subject: [PATCH 07/12] fix(config): promote sbom-ntia-conformance to deny, in the change that passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sbom-ntia-conformance` moves from `warn` to `deny`. That is CLOUD-631's own acceptance clause rather than a preference about when to tighten: a `deny` over a failing predicate blocks every landing, and a passing predicate under a `warn` row is the sensor the row exists to retire, so the two halves have to arrive together. `sbom-ntia-precondition` stays `deny` and is untouched — it answers "could we look", which is a different question. Measured on this tree at promotion time: `mise run ntia-check` exits 0 — `batten.spdx.json conforms to ntia` — over 290 components, every one carrying a supplier and a license, 162 with a copyright holder and 119 determined to have none. CORRECTING WHAT I CLAIMED ONE COMMIT AGO. c865d38 says the action table was "the predicate CLOUD-631 has been blocked on", and CLOUD-667's body calls the 9 actions "the last conformance gap". Both overstate, and the measurement is plain: with the action table emptied, `sbomcheck` 5.0.3 still reports `isConformant: true` while reporting `no-license=9 no-copyright=9`. Its `ntia` conjunction is `specVersionProvided`, `authorNameProvided`, `timestampProvided`, `dependencyRelationshipsProvided`, and `allProvided` on componentNames, componentVersions, componentIdentifiers and **componentSuppliers**. `componentConcludedLicenses` and `componentCopyrightTexts` are counted and reported but are NOT part of the verdict — correctly, since the NTIA 2021 minimum elements are supplier, name, version, other unique identifiers, dependency relationship, SBOM author and timestamp. License and copyright are not among them. So the rows that actually unblocked this promotion are CLOUD-666 (the gate was guaranteed non-zero while `fsct3-min` was in the standards set) and CLOUD-630 (componentSuppliers was failing on 190 of 243 and is the one per-component element the standard requires). CLOUD-628, CLOUD-629 and CLOUD-667 populate fields a procurement review reads and CISA's FSCT expectations name, and they are what CLOUD-608 asked for — but they were not load-bearing for `ntia`, and saying so is cheaper now than having someone re-derive it later. That also corrects CLOUD-608's framing, which calls all three "the SBOM's three NTIA per-component fields". One of the three is. Three assertions, in `tests/ntia-check.bats`: * the committed `batten.toml` declares `severity = "deny"` on the row, read from the bytes rather than inferred from behaviour, so it cannot be quietly relaxed later — `config-lint`'s weakening class covers that shape; * `sbom-ntia-precondition` is still `deny`, because collapsing the two would make an unresolvable checker indistinguishable from a nonconformant document; * a nonconformant document still exits 1 under the promoted row. The promotion changes what a finding DOES, never whether one is produced; a `deny` that never reaches a blocking exit is indistinguishable from `warn`. The replay obligation is answered rather than skipped. The predicate has no commit series to replay over — it is the checker's exit code on the document a tree produces, so its history is per-SHA. It fired on every SHA to date and none of those firings was a false positive, because the checker decides conformance against the minimum elements rather than estimating it. Taking the rate to zero is what this chain did; the `deny` is what keeps it there. Refs: CLOUD-926 Refs: CLOUD-608 Closes CLOUD-631 --- batten.toml | 55 +++++++++++++++++++++++++++++++++---------- tests/ntia-check.bats | 35 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/batten.toml b/batten.toml index 28add167d..5a7b6a333 100644 --- a/batten.toml +++ b/batten.toml @@ -1518,24 +1518,55 @@ severity = "deny" scope = "tree" no_fix_reason = "install the pinned checker (`mise install pipx:ntia-conformance-checker`) or fix why `mise run sbom` cannot derive a document; neither is a change to this tree's content" -# ROW 2 IS THE VERDICT, and it is `warn` by measurement rather than by taste. -# Measured 2026-08-14 on this tree: 243 components, no supplier on 190, no -# concluded license and no copyright text on 243/243 — and `Cargo.lock` carries -# ZERO license fields and no supplier field at all, which is the only input syft's -# cargo cataloger reads. So the gap cannot be closed by a flag; it needs the -# document ENRICHED from `cargo metadata`, which is its own change. `deny` here -# would fail `batten enforce` -> `verify` and stop every landing in the repo until -# that change exists — a gate answering a question nobody asked it. Recording the -# level per SHA is the claim this row can honestly make, and row 1 is what keeps -# the recording real. +# ROW 2 IS THE VERDICT, and it is `deny` as of CLOUD-631 — promoted in the change +# that makes it pass, which is that row's own acceptance clause rather than a +# preference about when to tighten. +# +# It was `warn` by measurement, and the measurement was right at the time: on +# 2026-08-14 this tree produced 243 components with no supplier on 190 and no +# concluded license or copyright text on 243/243, because `Cargo.lock` carries no +# license and no supplier field and that is the only input syft's cargo cataloger +# reads. `deny` then would have failed `batten enforce` -> `verify` and stopped +# every landing in the repo until a change that did not exist yet — a gate +# answering a question nobody had asked it. +# +# What changed is that the document now conforms, from five sources each of which +# reads data this tree already states and invents nothing: +# +# supplier the lockfile's own resolution — one distinct `source`, so +# the distributor is stated rather than inferred (CLOUD-630) +# licenseConcluded `cargo metadata`, which `cargo-deny` already gates on +# (CLOUD-628), plus the pinned-action table (CLOUD-667) +# copyrightText the checksum-pinned registry cache, with `NONE` where the +# pinned bytes carry no holder (CLOUD-629), plus CLOUD-667 +# +# and from two corrections without which none of the above would have been +# legible: the component census counted 340 entries for 290 distinct things +# (CLOUD-664), and `fsct3-min` was in the standards set while being unsatisfiable +# for every document syft can emit, so the gate was guaranteed non-zero whatever +# the SBOM said (CLOUD-666). +# +# Measured on this tree at promotion time: `mise run ntia-check` exits 0 — +# `batten.spdx.json conforms to ntia` — over 290 components, every one carrying a +# supplier and a license, 162 with a copyright holder and 119 determined to have +# none. +# +# THE FIRING RATE IS ZERO BEFORE THE `deny` BINDS, which is what makes this safe +# where a heuristic promotion would not be. The predicate has no commit series to +# replay: it is the checker's exit code on the document a tree produces, so its +# history is per-SHA. It fired on every SHA to date, and none of those firings was +# a false positive — the checker DECIDES conformance against the minimum elements +# rather than estimating it, so a false positive would be a checker defect and not +# a tuning question. Taking the rate to zero is what this change does; the `deny` +# is what keeps it there. [[rule]] id = "sbom-ntia-conformance" kind = "command" glob = "Cargo.lock" check = "mise run ntia-check" -severity = "warn" +severity = "deny" scope = "tree" -no_fix_reason = "the missing fields do not exist in a cargo lockfile, so no command over this tree can add them: the SBOM has to be enriched from `cargo metadata` first" +no_fix_reason = "a nonconformant component is a gap in the produced document, not in this tree's text: re-run `mise run sbom` and read which field the finding names — the sources are the lockfile's resolution, `cargo metadata`, the pinned registry sources and `mise-tasks/sbom-actions.tsv`" # Release provenance, adopted as `gh attestation verify` (CLOUD-583, CLOUD-279 # verdict 1 — `slsa-verifier` is redundant against the same absent provenance and diff --git a/tests/ntia-check.bats b/tests/ntia-check.bats index 8f7df367e..1bc2ede50 100644 --- a/tests/ntia-check.bats +++ b/tests/ntia-check.bats @@ -394,3 +394,38 @@ EOF [[ "$output" == *"refused this document: ntia"* ]] [[ "$output" != *"what a cargo lockfile can supply"* ]] } + +# ─── CLOUD-631: the promotion, asserted over the committed bytes ────────────── + +@test "THE PROMOTION: the committed batten.toml declares deny on sbom-ntia-conformance" { + # Asserted over the bytes rather than inferred from behaviour, so the row cannot + # be quietly relaxed later — `config-lint`s weakening class covers that shape, + # and this pins the value the promotion set. + local toml="$BATS_TEST_DIRNAME/../batten.toml" + run awk '/^id = "sbom-ntia-conformance"$/ { found = 1 } + found && /^severity = / { print; exit }' "$toml" + [ "$status" -eq 0 ] + [ "$output" = 'severity = "deny"' ] +} + +@test "the precondition row is STILL deny, and the two are not the same question" { + # `sbom-ntia-precondition` answers "could we look" and was always deny; the + # promotion moves the verdict row only. A change that collapsed them would make + # an unresolvable checker indistinguishable from a nonconformant document. + local toml="$BATS_TEST_DIRNAME/../batten.toml" + run awk '/^id = "sbom-ntia-precondition"$/ { found = 1 } + found && /^severity = / { print; exit }' "$toml" + [ "$status" -eq 0 ] + [ "$output" = 'severity = "deny"' ] +} + +@test "a nonconformant document still exits 1 under the promoted row" { + # The promotion changes what a finding DOES, never whether one is produced. If + # this ever passed, the deny would be a severity with no verdict behind it — + # which is indistinguishable from leaving the row at warn. + : >"$BATS_TEST_TMPDIR/check.ntia.fails" + run "$CHECK" + [ "$status" -eq 1 ] + [[ "$output" == *"sbom-ntia-nonconformant (ntia"* ]] + [ ! -f "$BATS_TEST_TMPDIR/receipts" ] +} From ff910aec87520bd4db3be58b972678133ce5a388 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 09:34:12 +0000 Subject: [PATCH 08/12] fix(sbom): a portable roots reader, and a coordinate the exclusion could not spell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gates that landed on `main` while this bundle was open refused it on rebase, and both findings are real rather than a stale tree. `no-bash4-mapfile` caught `mapfile -t roots` in `cargo_src_roots`'s caller: bash 4 only, and these programs run on a Mac's bash 3.2, which is the whole reason that row exists. A `while IFS= read -r` accumulator over the same process substitution reads it in every shell. `no-appeal-to-authority` caught an action's own name in `mise-tasks/sbom-actions.tsv`. Every term on that row's `exclude` line exempts the same thing — a hit that is a COORDINATE rather than a third party cited as justification — and `@[0-9a-f]{40}` already exempts the identical identifier one column over, in the `uses:` line the table is keyed to. What the table does is write the pin as a tab-separated column, a syntax the exclusion had no term for, so `\t[0-9a-f]{40}\t` joins it. This is a wrongly-refusing gate repaired in the session that hit it, not a row filed. The exemption ships with a case: `attribution-coordinate` gains an `sbom-actions.tsv.in` carrying that fourth syntax, and its `expected.in` stays exit 0. The fail direction is `attribution-appeal`'s, unchanged. Recorded because it cost two rounds: the first attempt at the comment explaining the exemption NAMED the action in prose, which is exactly the appeal the row forbids, and the row refused that too — surfaced by `batten-check` and by `a_tracked_instruction_may_not_prescribe_the_denied_commit_identity`, which reads this repo's real `batten.toml` into a fixture tree. The paragraph now states the reason without the identifier and says why it does not name it. Refs: CLOUD-667 --- batten.toml | 13 ++++++++++++- .../repos/attribution-coordinate/batten.toml.in | 5 +++-- .../attribution-coordinate/sbom-actions.tsv.in | 2 ++ mise-tasks/sbom.sh | 9 +++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in diff --git a/batten.toml b/batten.toml index 5a7b6a333..662d25777 100644 --- a/batten.toml +++ b/batten.toml @@ -780,12 +780,23 @@ no_fix_reason = "resolve the conflict by hand: choosing a side is a judgement no # POINTER-ONLY IMPROVES HERE. The task printed grep's whole `path:line:content` # hit, carrying the offending line into the finding; a `forbid` finding is # `path:line` and a rule id (non-negotiable rule 4). +# +# `\t[0-9a-f]{40}\t` is the SAME exemption `@[0-9a-f]{40}` already states, for the +# other syntax a pin is written in. Every term on the exclude line shares one +# predicate: the hit is a COORDINATE — a URL, an install scheme, a `uses:` line, a +# sha-pinned action — rather than a third party cited as justification. A row of +# `mise-tasks/sbom-actions.tsv` is an action's own name beside the sha a workflow +# pins it at, and that identifier is already exempt one column over in the `uses:` +# line itself. Refusing it in the table was the exclusion missing a syntax, not the +# rule catching an appeal (found by `batten-check` on CLOUD-667's table). Writing +# the identifier into THIS comment to explain that is the appeal the row means, and +# the row refused that too, which is why this paragraph does not name it. [[rule]] id = "no-appeal-to-authority" kind = "forbid" glob = "**" regex = "jdx|semgrep|opengrep|ast-grep" -exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|^(regex|exclude) = " +exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|\\t[0-9a-f]{40}\\t|^(regex|exclude) = " severity = "deny" scope = "tree" no_fix_reason = "state the rule and the evidence in this repo's terms; naming who else does it is not evaluable by a reader (mem:prior-art-and-issue-hygiene)" diff --git a/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in b/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in index f00f4d1ee..47d8a4dc3 100644 --- a/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in +++ b/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in @@ -4,12 +4,13 @@ version = 1 # is mem:prior-art-and-issue-hygiene's: an adopted practice is justified on this # repo's terms, not by naming who else does it. A COORDINATE — the same name # inside a URL, a package path or a tool pin — is how we address a dependency and -# is exempt; a bare mention in prose is not. +# is exempt; a bare mention in prose is not. `sbom-actions.tsv` carries the fourth +# coordinate syntax: a sha pin as a tab-separated column rather than after an `@`. [[rule]] id = "no-appeal-to-authority" kind = "forbid" glob = "**" regex = "jdx|semgrep|opengrep|ast-grep" -exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|^(regex|exclude) = " +exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|\\t[0-9a-f]{40}\\t|^(regex|exclude) = " severity = "deny" scope = "tree" diff --git a/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in b/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in new file mode 100644 index 000000000..1972f47bd --- /dev/null +++ b/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in @@ -0,0 +1,2 @@ +# A pin written as a data-table column rather than as `uses:` (CLOUD-667). +jdx/mise-action 9dda3952d607125725deac9ec10a5f0e245d266b MIT NONE diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index a4951d3bc..634004698 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -424,8 +424,13 @@ cargo_entities() { # `NOASSERTION` for it would make the document's contents depend on how warm # this machine's cache is, which is the property-of-the-world failure the # admissibility argument above turns on. - local -a roots - mapfile -t roots < <(cargo_src_roots) + # `mapfile` would read this in one line and is bash 4 only, which + # `no-bash4-mapfile` refuses: these programs run on a Mac's bash 3.2. + local -a roots=() + local root_line + while IFS= read -r root_line; do + roots+=("$root_line") + done < <(cargo_src_roots) local copyrights="{}" missing=0 name version dir found while IFS=$'\t' read -r name version; do found="" From b792c1933960b424e0171af26e4254a99a702dfa Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 10:03:13 +0000 Subject: [PATCH 09/12] fix(sbom): key the action table the way the pin exemption already spells it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config-lint` refused the previous commit with two `rule-predicate-changed` smells, and the refusal was right both times. The first was mine to design away rather than to admit. `no-appeal-to-authority` exempts a hit that is a COORDINATE rather than a third party cited as justification, and the term it already carries for a pin is `@[0-9a-f]{40}`. The table split its key across two tab-separated columns, which put every row outside that term, and the fix reached for was widening the exclusion — a relaxation `config-lint` admits only from a Ready block groomed before the work started, by a mechanism deliberately built so it cannot be asserted afterwards: `claim-check` copies the clause into the receipt at claim time, and this branch's claim predates any such clause. Writing the key as one `owner/repo@sha` field, spelled exactly as the workflow's `uses:` line spells it, needs no policy change at all. The exclusion and the fixture rule are back to `origin/main`'s bytes. The parser gains a refusal with the shape: a key carrying no 40-hex pin is rejected, because the pin is the whole reason a recorded license is a property of this commit rather than of whatever the action's default branch says today. Two `#MUTANT` rows cover it, and the short-row one is re-aimed at the new arity. The second smell was `sbom-ntia-conformance.no_fix_reason`, rewritten by the promotion commit, and reverting it costs nothing true: the original — the missing fields do not exist in a cargo lockfile, so the SBOM has to be enriched from `cargo metadata` first — still states exactly why no autofix exists, and the enrichment this bundle added is that enrichment. The `warn` to `deny` promotion, which is what the row was filed for, is not a smell and is untouched. `attribution-coordinate` gains an `sbom-actions.tsv.in` carrying the table's real shape, keyed on an action whose name the rule's own `regex` matches — a corpus file naming an action the regex does not match would have asserted nothing. 2724 bats cases green. Refs: CLOUD-667 --- batten.toml | 15 ++------ .../attribution-coordinate/batten.toml.in | 5 ++- .../sbom-actions.tsv.in | 6 ++-- mise-tasks/sbom-actions.tsv | 36 ++++++++++++------- mise-tasks/sbom-check.sh | 5 +-- mise-tasks/sbom.sh | 30 +++++++++++----- tests/sbom-check.bats | 2 +- tests/sbom.bats | 33 +++++++++++++---- 8 files changed, 83 insertions(+), 49 deletions(-) diff --git a/batten.toml b/batten.toml index 662d25777..82eb29ce7 100644 --- a/batten.toml +++ b/batten.toml @@ -780,23 +780,12 @@ no_fix_reason = "resolve the conflict by hand: choosing a side is a judgement no # POINTER-ONLY IMPROVES HERE. The task printed grep's whole `path:line:content` # hit, carrying the offending line into the finding; a `forbid` finding is # `path:line` and a rule id (non-negotiable rule 4). -# -# `\t[0-9a-f]{40}\t` is the SAME exemption `@[0-9a-f]{40}` already states, for the -# other syntax a pin is written in. Every term on the exclude line shares one -# predicate: the hit is a COORDINATE — a URL, an install scheme, a `uses:` line, a -# sha-pinned action — rather than a third party cited as justification. A row of -# `mise-tasks/sbom-actions.tsv` is an action's own name beside the sha a workflow -# pins it at, and that identifier is already exempt one column over in the `uses:` -# line itself. Refusing it in the table was the exclusion missing a syntax, not the -# rule catching an appeal (found by `batten-check` on CLOUD-667's table). Writing -# the identifier into THIS comment to explain that is the appeal the row means, and -# the row refused that too, which is why this paragraph does not name it. [[rule]] id = "no-appeal-to-authority" kind = "forbid" glob = "**" regex = "jdx|semgrep|opengrep|ast-grep" -exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|\\t[0-9a-f]{40}\\t|^(regex|exclude) = " +exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|^(regex|exclude) = " severity = "deny" scope = "tree" no_fix_reason = "state the rule and the evidence in this repo's terms; naming who else does it is not evaluable by a reader (mem:prior-art-and-issue-hygiene)" @@ -1577,7 +1566,7 @@ glob = "Cargo.lock" check = "mise run ntia-check" severity = "deny" scope = "tree" -no_fix_reason = "a nonconformant component is a gap in the produced document, not in this tree's text: re-run `mise run sbom` and read which field the finding names — the sources are the lockfile's resolution, `cargo metadata`, the pinned registry sources and `mise-tasks/sbom-actions.tsv`" +no_fix_reason = "the missing fields do not exist in a cargo lockfile, so no command over this tree can add them: the SBOM has to be enriched from `cargo metadata` first" # Release provenance, adopted as `gh attestation verify` (CLOUD-583, CLOUD-279 # verdict 1 — `slsa-verifier` is redundant against the same absent provenance and diff --git a/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in b/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in index 47d8a4dc3..f00f4d1ee 100644 --- a/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in +++ b/crates/batten/tests/fixtures/repos/attribution-coordinate/batten.toml.in @@ -4,13 +4,12 @@ version = 1 # is mem:prior-art-and-issue-hygiene's: an adopted practice is justified on this # repo's terms, not by naming who else does it. A COORDINATE — the same name # inside a URL, a package path or a tool pin — is how we address a dependency and -# is exempt; a bare mention in prose is not. `sbom-actions.tsv` carries the fourth -# coordinate syntax: a sha pin as a tab-separated column rather than after an `@`. +# is exempt; a bare mention in prose is not. [[rule]] id = "no-appeal-to-authority" kind = "forbid" glob = "**" regex = "jdx|semgrep|opengrep|ast-grep" -exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|\\t[0-9a-f]{40}\\t|^(regex|exclude) = " +exclude = "https?://|package://|aqua:|ubi:|npm:|pipx:|github\\.com|\\.dev|submodule|amends|uses:|@[0-9a-f]{40}|^(regex|exclude) = " severity = "deny" scope = "tree" diff --git a/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in b/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in index 1972f47bd..cba60d636 100644 --- a/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in +++ b/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in @@ -1,2 +1,4 @@ -# A pin written as a data-table column rather than as `uses:` (CLOUD-667). -jdx/mise-action 9dda3952d607125725deac9ec10a5f0e245d266b MIT NONE +# A pinned action written as a data-table key rather than as a `uses:` line +# (CLOUD-667). ONE field, so the `@[0-9a-f]{40}` term already exempts it — which +# is why the table is keyed this way and the exclusion needed no widening. +jdx/mise-action@9dda3952d607125725deac9ec10a5f0e245d266b MIT NONE diff --git a/mise-tasks/sbom-actions.tsv b/mise-tasks/sbom-actions.tsv index b70d2202f..887ae728a 100644 --- a/mise-tasks/sbom-actions.tsv +++ b/mise-tasks/sbom-actions.tsv @@ -48,16 +48,26 @@ # repository ships; CONTRIBUTING.md's Apache-2.0 compatibility column tracks # ADOPTED and VENDORED tools, which these are not. # -# Columns, tab-separated: repo, pinned sha, SPDX license expression, copyright. -# The sha is the authority for drift; enrichment matches on the repo, because the -# document keys components by the `# vX` comment beside the pin rather than by the -# sha itself, and a comment is a label a human can get wrong. -actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1 MIT Copyright (c) 2018 GitHub, Inc. and contributors -jdx/mise-action 9dda3952d607125725deac9ec10a5f0e245d266b MIT Copyright (c) 2018 GitHub, Inc. and contributors -Swatinem/rust-cache 6323deb102c322ba6fcbdcafc7e3dddab59af2b6 LGPL-3.0-only NONE -actions/cache 55cc8345863c7cc4c66a329aec7e433d2d1c52a9 MIT Copyright (c) 2018 GitHub, Inc. and contributors -actions/upload-artifact 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a MIT Copyright (c) 2018 GitHub, Inc. and contributors -linear/linear-release-action 17b8c24f8ceb2b98cabaf1965ff83c55dd596fac MIT Copyright (c) 2026 Linear -sequoia-pgp/fast-forward ea7628bedcb0b0b96e94383ada458d812fca4979 LGPL-2.0-or-later NONE -taiki-e/install-action 91ddec75689c4c78665b598d188dc821c5a43e5c Apache-2.0 OR MIT NONE -actions/attest-build-provenance 4d101475d8b20a2381f78447822ac1eab6504dd8 MIT Copyright GitHub +# Columns, tab-separated: `owner/repo@sha`, SPDX license expression, copyright. +# The sha is the authority for drift; enrichment matches on the owner/repo half, +# because the document keys components by the `# vX` comment beside the pin rather +# than by the sha itself, and a comment is a label a human can get wrong. +# +# THE KEY IS ONE COLUMN, spelled exactly as the workflow's `uses:` line spells it, +# and that is not cosmetic. `no-appeal-to-authority` exempts a hit that is a +# COORDINATE rather than a third party cited as justification, and the term it +# already carries for a pin is `@[0-9a-f]{40}`. Splitting the key across two +# tab-separated columns put this table outside that term and the row refused every +# line of it — so the first attempt widened the exclusion, which `config-lint` +# correctly reported as `rule-predicate-changed`, a relaxation admissible only from +# a Ready block groomed before the work. Writing the key the way the exemption +# already spells it needs no policy change at all (CLOUD-667). +actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 MIT Copyright (c) 2018 GitHub, Inc. and contributors +jdx/mise-action@9dda3952d607125725deac9ec10a5f0e245d266b MIT Copyright (c) 2018 GitHub, Inc. and contributors +Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 LGPL-3.0-only NONE +actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 MIT Copyright (c) 2018 GitHub, Inc. and contributors +actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a MIT Copyright (c) 2018 GitHub, Inc. and contributors +linear/linear-release-action@17b8c24f8ceb2b98cabaf1965ff83c55dd596fac MIT Copyright (c) 2026 Linear +sequoia-pgp/fast-forward@ea7628bedcb0b0b96e94383ada458d812fca4979 LGPL-2.0-or-later NONE +taiki-e/install-action@91ddec75689c4c78665b598d188dc821c5a43e5c Apache-2.0 OR MIT NONE +actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 MIT Copyright GitHub diff --git a/mise-tasks/sbom-check.sh b/mise-tasks/sbom-check.sh index 2c02022b8..f95b9ecb3 100755 --- a/mise-tasks/sbom-check.sh +++ b/mise-tasks/sbom-check.sh @@ -361,8 +361,9 @@ while IFS= read -r pin; do ref="${pin##*:}" where="${pin%:*}" repo="${ref%@*}" - sha="${ref#*@}" - if ! grep -qF "$(printf '%s %s ' "$repo" "$sha")" "$ACTIONS_TABLE"; then + # The table's key column is spelled exactly as this `uses:` line spells it, + # so the comparison is the whole reference against a key followed by a tab. + if ! grep -qF "$(printf '%s ' "$ref")" "$ACTIONS_TABLE"; then echo "$where sbom-action-unmapped ($repo)" >&2 unmapped=$((unmapped + 1)) fi diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index 634004698..d400d2ded 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -65,10 +65,13 @@ #MUTANT sbom-keeps-the-slash-license-form|s@gsub("\[\[:space:\]\]\*/\[\[:space:\]\]\*"; " OR ")@.@|the deprecated slash spelling is rewritten to OR #MUTANT sbom-invents-a-missing-license|s@if . == "" then "NOASSERTION"@if . == "" then "Apache-2.0"@|HONEST ABSENCE # And CLOUD-667. Skipping the actions pass returns all 9 to NOASSERTION, which is -# the gap that made the promotion impossible; a short table row must be refused -# rather than writing an empty license into a published document. +# the field state the row was filed about; a short table row must be refused rather +# than writing an empty license into a published document, and a key carrying no +# 40-hex pin must be refused because the pin is the only thing tying a license +# verdict to the commit this repository actually builds against. #MUTANT sbom-skips-the-actions-table|s@^\tif ! enrich_actions "\$spdx" "\$SPDX_ACTIONS" "\$actions"; then@\tif false; then@|a mapped action carries its license and copyright -#MUTANT sbom-accepts-a-short-action-row|s@if (NF < 4)@if (NF < 0)@|a table row with fewer than four fields is refused +#MUTANT sbom-accepts-a-short-action-row|s@if (NF < 3)@if (NF < 0)@|a table row with fewer than three fields is refused +#MUTANT sbom-accepts-an-unpinned-action-key|s@length(\$1) < 42@length($1) < 0@|a key carrying no 40-hex pin is refused set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" @@ -357,20 +360,31 @@ action_entities() { echo "::error:: sbom: cannot read ${ACTIONS_TABLE##*/}, so no pinned action can be given its license or copyright" >&2 return 1 fi - # Comments and blank lines skipped by shape. A row short of four fields is a + # Comments and blank lines skipped by shape. A row short of three fields is a # malformed table rather than a missing row, and is refused: a partial row - # would silently write an empty license into the document. + # would silently write an empty license into the document. The key column is + # `owner/repo@sha` — one field, spelled as the workflow's `uses:` line spells + # it — so a key carrying no 40-hex pin is refused too: the pin is the whole + # drift authority, and a keyless row would map an action to whatever it says + # today rather than to what this commit builds against. local out if ! out=$(awk -F'\t' ' /^[[:space:]]*#/ { next } /^[[:space:]]*$/ { next } { - if (NF < 4) { print "MALFORMED:" NR > "/dev/stderr"; bad = 1; next } - printf "%s\t%s\t%s\n", $1, $3, $4 + if (NF < 3) { print "MALFORMED:" NR > "/dev/stderr"; bad = 1; next } + if ($1 !~ /^[^@\t]+@[0-9a-f][0-9a-f]*$/ || length($1) < 42) { + print "UNPINNED:" NR > "/dev/stderr" + bad = 1 + next + } + repo = $1 + sub(/@.*$/, "", repo) + printf "%s\t%s\t%s\n", repo, $2, $3 } END { if (bad) exit 1 } ' "$ACTIONS_TABLE"); then - echo "::error:: sbom: ${ACTIONS_TABLE##*/} carries a row with fewer than four tab-separated fields, so an action would be given an empty license" >&2 + echo "::error:: sbom: ${ACTIONS_TABLE##*/} carries a row that is not \`owner/repo@<40-hex>\` plus a license and a copyright, so an action would be given an empty or unpinned license" >&2 return 1 fi jq -Rn '[inputs diff --git a/tests/sbom-check.bats b/tests/sbom-check.bats index 20658d0f6..9040f7ba8 100644 --- a/tests/sbom-check.bats +++ b/tests/sbom-check.bats @@ -313,7 +313,7 @@ EOF # matches. mkdir -p "$ROOT/.github/workflows" printf 'jobs:\n a:\n steps:\n - uses: some/action@%040d\n' 1 >"$ROOT/.github/workflows/w.yml" - printf 'some/action\t%040d\tMIT\tCopyright (c) 2020 Someone\n' 2 >"$ROOT/actions.tsv" + printf 'some/action@%040d\tMIT\tCopyright (c) 2020 Someone\n' 2 >"$ROOT/actions.tsv" SBOM_ACTIONS_TABLE="$ROOT/actions.tsv" run "$CHECK" [ "$status" -eq 1 ] [[ "$output" == *"sbom-action-unmapped"* ]] diff --git a/tests/sbom.bats b/tests/sbom.bats index d035ca47b..8cc5b7b95 100644 --- a/tests/sbom.bats +++ b/tests/sbom.bats @@ -584,6 +584,14 @@ declared_of() { jq -r --arg n "$1" '[.packages[] | select(.name == $n) | .licens # SBOM_ACTIONS_TABLE so a fixture can carry an unmapped pin without editing the # repository's own table. +# The key column is `owner/repo@sha`, ONE field, spelled as a workflow's `uses:` +# line spells it. A real 40-hex pin rather than a short stand-in, because the parser +# refuses an unpinned key — `deadbeef` would exercise that refusal in every case +# that means to exercise something else. +# Not `readonly`: bats sources this file once per test, so a readonly assignment +# fails on the second one. +PIN=3d3c42e5aac5ba805825da76410c181273ba90b1 + action_table() { printf '%s\n' "$@" >"$BATS_TEST_TMPDIR/actions.tsv" export SBOM_ACTIONS_TABLE="$BATS_TEST_TMPDIR/actions.tsv" @@ -599,7 +607,7 @@ action_fixture() { # and no local source can supply them. printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" action_fixture - action_table "$(printf 'actions/checkout\tdeadbeef\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors')" + action_table "$(printf 'actions/checkout@%s\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors' "$PIN")" run "$SBOM" [ "$status" -eq 0 ] [ "$(license_of actions/checkout)" = "MIT" ] @@ -614,7 +622,7 @@ action_fixture() { # the determined answer and is conformant where NOASSERTION is not. printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" action_fixture - action_table "$(printf 'actions/checkout\tdeadbeef\tLGPL-3.0-only\tNONE')" + action_table "$(printf 'actions/checkout@%s\tLGPL-3.0-only\tNONE' "$PIN")" run "$SBOM" [ "$status" -eq 0 ] [ "$(copyright_of actions/checkout)" = "NONE" ] @@ -625,27 +633,38 @@ action_fixture() { @test "an action absent from the table keeps NOASSERTION rather than borrowing a row" { printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" action_fixture - action_table "$(printf 'some/other-action\tdeadbeef\tMIT\tCopyright (c) 2020 Someone')" + action_table "$(printf 'some/other-action@%s\tMIT\tCopyright (c) 2020 Someone' "$PIN")" run "$SBOM" [ "$status" -eq 0 ] [ "$(license_of actions/checkout)" = "NOASSERTION" ] } -@test "a table row with fewer than four fields is refused, not silently partial" { +@test "a table row with fewer than three fields is refused, not silently partial" { # A short row would write an empty license into the document — a field that # parses as present and says nothing. printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" action_fixture - action_table "$(printf 'actions/checkout\tdeadbeef\tMIT')" + action_table "$(printf 'actions/checkout@%s\tMIT' "$PIN")" + run "$SBOM" + [ "$status" -eq 1 ] + [[ "$output" == *"owner/repo@"* ]] +} + +@test "a key carrying no 40-hex pin is refused — the pin is the drift authority" { + # Without the pin a row maps an action to whatever its default branch says + # today, so the document would stop being a function of this commit. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_fixture + action_table "$(printf 'actions/checkout\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors')" run "$SBOM" [ "$status" -eq 1 ] - [[ "$output" == *"fewer than four"* ]] + [[ "$output" == *"owner/repo@"* ]] } @test "comments and blank lines in the table are skipped by shape" { printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" action_fixture - action_table "# a comment" "" "$(printf 'actions/checkout\tdeadbeef\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors')" + action_table "# a comment" "" "$(printf 'actions/checkout@%s\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors' "$PIN")" run "$SBOM" [ "$status" -eq 0 ] [ "$(license_of actions/checkout)" = "MIT" ] From 6ccdc892cfac83070abace1a7d377fb73b666922 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 10:09:19 +0000 Subject: [PATCH 10/12] test(sbom): cover the pin-length arm, which the absent-pin case could not reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise run mutant sbom` reported `sbom-accepts-an-unpinned-action-key` SURVIVED, and the survivor was right: the refusal has two arms — a key with no `@` at all, and a key whose pin is short of 40 hex — and the case written for it omitted the `@` entirely, so the shape arm caught every mutation and the length arm was covered by nothing. `actions/checkout@deadbeef` is the shape a typo actually produces: an `@` present, the value hex, and naming no commit. The mutant is re-aimed at that case, which is the one that discriminates. 151 declared mutations, 149 caught. The two that remain are CLOUD-941's, in `mise-tasks/ready-lint.sh` and `mise-tasks/board-write-record.sh` — files this branch does not touch and is dispatched not to, both pre-existing on an unmodified tree, with the diagnosis and the reason recorded on that row. Refs: CLOUD-667 --- mise-tasks/sbom.sh | 2 +- tests/sbom.bats | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/mise-tasks/sbom.sh b/mise-tasks/sbom.sh index d400d2ded..8a0fde450 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -71,7 +71,7 @@ # verdict to the commit this repository actually builds against. #MUTANT sbom-skips-the-actions-table|s@^\tif ! enrich_actions "\$spdx" "\$SPDX_ACTIONS" "\$actions"; then@\tif false; then@|a mapped action carries its license and copyright #MUTANT sbom-accepts-a-short-action-row|s@if (NF < 3)@if (NF < 0)@|a table row with fewer than three fields is refused -#MUTANT sbom-accepts-an-unpinned-action-key|s@length(\$1) < 42@length($1) < 0@|a key carrying no 40-hex pin is refused +#MUTANT sbom-accepts-an-unpinned-action-key|s@length(\$1) < 42@length($1) < 0@|a key whose pin is SHORT of 40 hex is refused too set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" diff --git a/tests/sbom.bats b/tests/sbom.bats index 8cc5b7b95..69c316d7d 100644 --- a/tests/sbom.bats +++ b/tests/sbom.bats @@ -661,6 +661,20 @@ action_fixture() { [[ "$output" == *"owner/repo@"* ]] } +@test "a key whose pin is SHORT of 40 hex is refused too, not just an absent one" { + # The two refusals are separate arms and this is the one a typo produces: an + # `@` is present, the value is hex, and it names no commit. Without this case + # the length arm is covered by nothing — measured, `mise run mutant sbom` + # reported `sbom-accepts-an-unpinned-action-key` SURVIVED, because the case + # below it omits the `@` entirely and the shape arm catches that one. + printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" + action_fixture + action_table "$(printf 'actions/checkout@deadbeef\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors')" + run "$SBOM" + [ "$status" -eq 1 ] + [[ "$output" == *"owner/repo@"* ]] +} + @test "comments and blank lines in the table are skipped by shape" { printf 'version = "9.9.9"\nauthors = ["Button Inc."]\n' >"$ROOT/Cargo.toml" action_fixture From 6fda6af69ee15d8eba10ab4e8a3fa11f50a15e50 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 15:02:15 +0000 Subject: [PATCH 11/12] chore(bench): record the cost of tests/sbom.bats, from a complete run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `suite-bench-check` refused: `tests/sbom.bats` is tracked and absent from the record, so nothing says what editing it costs. This bundle adds that suite, so the row is this branch's to write. Regenerated from a full `mise run test:bats` — 2846 cases, 158 suites, all green — and that ordering is the point rather than ceremony. `suite-bench` derives from the report the runner writes, so regenerating on top of a partial report produces a partial record: the first attempt here wrote 151 rows against 158 tracked suites, which would have DELETED six rows main had just recorded while satisfying the author's sense that the gate had been answered. `suite-bench-check` caught it and named the missing suites, which is the gate working in the direction it is hardest to notice. 158 suites, 1494.8s serial. Refs: CLOUD-667 --- bench/suites/RESULTS.md | 285 ++++++++++++++++++++-------------------- 1 file changed, 143 insertions(+), 142 deletions(-) diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index cd10423f9..be22e9b06 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,165 +6,166 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 157 -- serial total: 1206.8s +- suites: 158 +- serial total: 1494.8s | seconds | share | suite | | ---: | ---: | --- | -| 144.3 | 12.0% | `tests/land-lock.bats` | -| 140.6 | 11.7% | `tests/derived-check.bats` | -| 102.4 | 8.5% | `tests/ci-wait.bats` | -| 92.2 | 7.6% | `tests/land.bats` | -| 88.8 | 7.4% | `tests/session-start.bats` | -| 72.1 | 6.0% | `tests/hooks-wiring-check.bats` | -| 44.0 | 3.6% | `tests/ci-local-parity.bats` | -| 36.1 | 3.0% | `tests/helpers.bats` | -| 34.7 | 2.9% | `tests/main-watch.bats` | -| 24.3 | 2.0% | `tests/hook-latency-drift.bats` | -| 24.2 | 2.0% | `tests/config-lint.bats` | -| 20.5 | 1.7% | `tests/commit-convention.bats` | -| 20.0 | 1.7% | `tests/token-bench.bats` | -| 17.7 | 1.5% | `tests/claim-check.bats` | -| 14.9 | 1.2% | `tests/board-diff-overlap.bats` | -| 13.2 | 1.1% | `tests/prebuilt-lint.bats` | -| 11.2 | 0.9% | `tests/run-shape-guard.bats` | -| 11.2 | 0.9% | `tests/graph-check.bats` | -| 10.4 | 0.9% | `tests/target-race.bats` | -| 9.1 | 0.8% | `tests/board-write-record.bats` | -| 8.0 | 0.7% | `tests/ready-guard.bats` | -| 8.0 | 0.7% | `tests/stop-guard.bats` | -| 7.9 | 0.7% | `tests/mcp-allow-check.bats` | -| 7.9 | 0.7% | `tests/renovate-config-validator.bats` | -| 7.7 | 0.6% | `tests/ready-lint.bats` | -| 7.7 | 0.6% | `tests/mutant.bats` | -| 7.3 | 0.6% | `tests/released.bats` | -| 7.2 | 0.6% | `tests/filed-here-check.bats` | -| 6.7 | 0.6% | `tests/sbom-check.bats` | -| 6.6 | 0.5% | `tests/replay.bats` | -| 6.4 | 0.5% | `tests/lock-complete.bats` | -| 6.2 | 0.5% | `tests/step-receipt.bats` | -| 6.2 | 0.5% | `tests/in-progress-drain.bats` | -| 5.6 | 0.5% | `tests/task-registry.bats` | -| 5.4 | 0.4% | `tests/release-assets-check.bats` | -| 4.8 | 0.4% | `tests/schema-check.bats` | -| 4.8 | 0.4% | `tests/hk-selection.bats` | -| 4.7 | 0.4% | `tests/singleton.bats` | -| 4.4 | 0.4% | `tests/land-divergence.bats` | -| 4.3 | 0.4% | `tests/reference-check.bats` | -| 4.0 | 0.3% | `tests/board-move-guard.bats` | -| 3.9 | 0.3% | `tests/with-lock.bats` | -| 3.7 | 0.3% | `tests/unlanded-check.bats` | -| 3.7 | 0.3% | `tests/tree-clean.bats` | -| 3.7 | 0.3% | `tests/semver.bats` | -| 3.6 | 0.3% | `tests/doctor-race.bats` | -| 3.6 | 0.3% | `tests/verify.bats` | -| 3.6 | 0.3% | `tests/pre-commit-staging.bats` | -| 3.5 | 0.3% | `tests/issue-read-check.bats` | -| 3.5 | 0.3% | `tests/board-sweep.bats` | -| 3.4 | 0.3% | `tests/target-ensure.bats` | -| 3.0 | 0.2% | `tests/landed-check.bats` | -| 2.9 | 0.2% | `tests/spec-ref-check.bats` | -| 2.9 | 0.2% | `tests/issue-read-guard.bats` | -| 2.8 | 0.2% | `tests/ready-cites-check.bats` | -| 2.5 | 0.2% | `tests/skill-check.bats` | -| 2.4 | 0.2% | `tests/timeout-drift.bats` | -| 2.4 | 0.2% | `tests/suite-select.bats` | -| 2.3 | 0.2% | `tests/closing-key-check.bats` | -| 2.3 | 0.2% | `tests/fanout-guard.bats` | -| 2.3 | 0.2% | `tests/signing-posture.bats` | -| 2.2 | 0.2% | `tests/finding-sink-check.bats` | -| 2.0 | 0.2% | `tests/claim-race-check.bats` | -| 2.0 | 0.2% | `tests/bot-issue.bats` | -| 1.8 | 0.2% | `tests/issue-search-guard.bats` | -| 1.8 | 0.2% | `tests/reclaim-census.bats` | -| 1.7 | 0.1% | `tests/ci-tools-check.bats` | -| 1.7 | 0.1% | `tests/claimed-keys.bats` | -| 1.6 | 0.1% | `tests/run-shape.bats` | -| 1.6 | 0.1% | `tests/memories-check.bats` | -| 1.6 | 0.1% | `tests/ci-slow-needed.bats` | -| 1.6 | 0.1% | `tests/ci-lease-precondition.bats` | -| 1.6 | 0.1% | `tests/ready-lint-deferral.bats` | -| 1.6 | 0.1% | `tests/target-prune.bats` | -| 1.5 | 0.1% | `tests/install-check.bats` | -| 1.5 | 0.1% | `tests/mutant-census.bats` | -| 1.5 | 0.1% | `tests/spawn-census.bats` | -| 1.5 | 0.1% | `tests/awk-regex-check.bats` | -| 1.5 | 0.1% | `tests/alive.bats` | -| 1.4 | 0.1% | `tests/land-divergence-assert.bats` | -| 1.4 | 0.1% | `tests/nonverdict-scan.bats` | -| 1.4 | 0.1% | `tests/deferral-check.bats` | -| 1.2 | 0.1% | `tests/perf-record.bats` | -| 1.2 | 0.1% | `tests/linear-check.bats` | -| 1.2 | 0.1% | `tests/rules-drift.bats` | -| 1.2 | 0.1% | `tests/done-check.bats` | -| 1.1 | 0.1% | `tests/ntia-check.bats` | -| 1.1 | 0.1% | `tests/verified.bats` | -| 1.0 | 0.1% | `tests/transcript-corpus-check.bats` | -| 1.0 | 0.1% | `tests/attestation-check.bats` | -| 1.0 | 0.1% | `tests/release-tracking-check.bats` | -| 1.0 | 0.1% | `tests/perf-assert.bats` | +| 194.4 | 13.0% | `tests/land-lock.bats` | +| 147.6 | 9.9% | `tests/derived-check.bats` | +| 132.0 | 8.8% | `tests/session-start.bats` | +| 102.2 | 6.8% | `tests/ci-wait.bats` | +| 97.8 | 6.5% | `tests/land.bats` | +| 71.0 | 4.7% | `tests/sbom-check.bats` | +| 63.6 | 4.3% | `tests/hooks-wiring-check.bats` | +| 54.8 | 3.7% | `tests/commit-convention.bats` | +| 51.9 | 3.5% | `tests/config-lint.bats` | +| 45.8 | 3.1% | `tests/signing-posture.bats` | +| 36.2 | 2.4% | `tests/ci-local-parity.bats` | +| 36.1 | 2.4% | `tests/helpers.bats` | +| 34.6 | 2.3% | `tests/main-watch.bats` | +| 24.3 | 1.6% | `tests/hook-latency-drift.bats` | +| 23.5 | 1.6% | `tests/claim-check.bats` | +| 17.7 | 1.2% | `tests/pkl-check.bats` | +| 17.6 | 1.2% | `tests/perf-record.bats` | +| 17.4 | 1.2% | `tests/token-bench.bats` | +| 13.0 | 0.9% | `tests/prebuilt-lint.bats` | +| 13.0 | 0.9% | `tests/board-diff-overlap.bats` | +| 10.5 | 0.7% | `tests/graph-check.bats` | +| 9.7 | 0.7% | `tests/stop-guard.bats` | +| 8.5 | 0.6% | `tests/run-shape-guard.bats` | +| 8.5 | 0.6% | `tests/sbom.bats` | +| 8.2 | 0.5% | `tests/ready-guard.bats` | +| 8.1 | 0.5% | `tests/board-write-record.bats` | +| 8.1 | 0.5% | `tests/target-race.bats` | +| 7.2 | 0.5% | `tests/renovate-config-validator.bats` | +| 7.1 | 0.5% | `tests/filed-here-check.bats` | +| 7.0 | 0.5% | `tests/released.bats` | +| 6.9 | 0.5% | `tests/ready-lint.bats` | +| 6.7 | 0.4% | `tests/mutant.bats` | +| 6.7 | 0.4% | `tests/mcp-allow-check.bats` | +| 6.6 | 0.4% | `tests/step-receipt.bats` | +| 6.6 | 0.4% | `tests/replay.bats` | +| 6.3 | 0.4% | `tests/singleton.bats` | +| 5.9 | 0.4% | `tests/tree-clean.bats` | +| 5.7 | 0.4% | `tests/lock-complete.bats` | +| 5.7 | 0.4% | `tests/unlanded-check.bats` | +| 5.2 | 0.3% | `tests/schema-check.bats` | +| 5.1 | 0.3% | `tests/in-progress-drain.bats` | +| 5.0 | 0.3% | `tests/task-registry.bats` | +| 4.7 | 0.3% | `tests/release-assets-check.bats` | +| 4.1 | 0.3% | `tests/hk-selection.bats` | +| 3.9 | 0.3% | `tests/target-ensure.bats` | +| 3.9 | 0.3% | `tests/reference-check.bats` | +| 3.8 | 0.3% | `tests/land-divergence.bats` | +| 3.7 | 0.2% | `tests/board-move-guard.bats` | +| 3.6 | 0.2% | `tests/pre-commit-staging.bats` | +| 3.5 | 0.2% | `tests/deferral-check.bats` | +| 3.5 | 0.2% | `tests/doctor-race.bats` | +| 3.5 | 0.2% | `tests/suite-select.bats` | +| 3.4 | 0.2% | `tests/ntia-check.bats` | +| 3.3 | 0.2% | `tests/semver.bats` | +| 3.2 | 0.2% | `tests/issue-read-check.bats` | +| 3.1 | 0.2% | `tests/with-lock.bats` | +| 2.9 | 0.2% | `tests/board-sweep.bats` | +| 2.9 | 0.2% | `tests/spawn-census.bats` | +| 2.7 | 0.2% | `tests/verify.bats` | +| 2.7 | 0.2% | `tests/ready-cites-check.bats` | +| 2.6 | 0.2% | `tests/issue-read-guard.bats` | +| 2.5 | 0.2% | `tests/landed-check.bats` | +| 2.4 | 0.2% | `tests/spec-ref-check.bats` | +| 2.0 | 0.1% | `tests/fanout-guard.bats` | +| 2.0 | 0.1% | `tests/finding-sink-check.bats` | +| 2.0 | 0.1% | `tests/claim-race-check.bats` | +| 2.0 | 0.1% | `tests/target-prune.bats` | +| 2.0 | 0.1% | `tests/skill-check.bats` | +| 1.9 | 0.1% | `tests/closing-key-check.bats` | +| 1.8 | 0.1% | `tests/timeout-drift.bats` | +| 1.8 | 0.1% | `tests/evaluator-closure-check.bats` | +| 1.8 | 0.1% | `tests/reclaim-census.bats` | +| 1.7 | 0.1% | `tests/issue-search-guard.bats` | +| 1.6 | 0.1% | `tests/bot-issue.bats` | +| 1.6 | 0.1% | `tests/pr-unsubscribed.bats` | +| 1.5 | 0.1% | `tests/claimed-keys.bats` | +| 1.5 | 0.1% | `tests/ci-tools-check.bats` | +| 1.5 | 0.1% | `tests/ci-slow-needed.bats` | +| 1.5 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.4 | 0.1% | `tests/run-shape.bats` | +| 1.4 | 0.1% | `tests/memories-check.bats` | +| 1.3 | 0.1% | `tests/ci-lease-precondition.bats` | +| 1.3 | 0.1% | `tests/install-check.bats` | +| 1.2 | 0.1% | `tests/mutant-census.bats` | +| 1.2 | 0.1% | `tests/land-divergence-assert.bats` | +| 1.1 | 0.1% | `tests/done-check.bats` | +| 1.1 | 0.1% | `tests/rules-drift.bats` | +| 1.1 | 0.1% | `tests/awk-regex-check.bats` | +| 1.1 | 0.1% | `tests/alive.bats` | +| 1.1 | 0.1% | `tests/linear-check.bats` | +| 1.0 | 0.1% | `tests/nonverdict-scan.bats` | | 1.0 | 0.1% | `tests/hook-pin-check.bats` | -| 1.0 | 0.1% | `tests/install.bats` | -| 1.0 | 0.1% | `tests/gh-guard.bats` | -| 1.0 | 0.1% | `tests/checks-green.bats` | -| 1.0 | 0.1% | `tests/prose-only-check.bats` | -| 1.0 | 0.1% | `tests/render-cli.bats` | -| 1.0 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.9 | 0.1% | `tests/verified.bats` | +| 0.9 | 0.1% | `tests/release-tracking-check.bats` | +| 0.9 | 0.1% | `tests/perf-assert.bats` | +| 0.9 | 0.1% | `tests/gh-guard.bats` | | 0.9 | 0.1% | `tests/done-pr-check.bats` | -| 0.9 | 0.1% | `tests/issue-search-check.bats` | -| 0.9 | 0.1% | `tests/sbom-binary.bats` | +| 0.9 | 0.1% | `tests/prose-only-check.bats` | | 0.9 | 0.1% | `tests/module-map-check.bats` | -| 0.9 | 0.1% | `tests/doctor.bats` | -| 0.8 | 0.1% | `tests/stop-posture-check.bats` | -| 0.8 | 0.1% | `tests/timeout-check.bats` | -| 0.8 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.8 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.8 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.8 | 0.1% | `tests/hook-matcher-check.bats` | -| 0.7 | 0.1% | `tests/perf-compare.bats` | -| 0.7 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.7 | 0.1% | `tests/hook-profile-check.bats` | -| 0.7 | 0.1% | `tests/checksums.bats` | -| 0.6 | 0.1% | `tests/connector-verb-guard.bats` | -| 0.6 | 0.1% | `tests/macos-link-check.bats` | +| 0.8 | 0.1% | `tests/render-cli.bats` | +| 0.8 | 0.1% | `tests/issue-search-check.bats` | +| 0.8 | 0.1% | `tests/checks-green.bats` | +| 0.8 | 0.1% | `tests/attestation-check.bats` | +| 0.8 | 0.1% | `tests/doctor.bats` | +| 0.7 | 0.0% | `tests/timeout-check.bats` | +| 0.7 | 0.0% | `tests/install.bats` | +| 0.7 | 0.0% | `tests/mcp-timeout-budget.bats` | +| 0.7 | 0.0% | `tests/sbom-binary.bats` | +| 0.7 | 0.0% | `tests/merged-pr-keys.bats` | +| 0.7 | 0.0% | `tests/perf-compare.bats` | +| 0.7 | 0.0% | `tests/hook-matcher-check.bats` | +| 0.7 | 0.0% | `tests/mcp-attach-check.bats` | +| 0.7 | 0.0% | `tests/stop-posture-check.bats` | +| 0.6 | 0.0% | `tests/macos-link-check.bats` | +| 0.6 | 0.0% | `tests/hook-profile-check.bats` | | 0.6 | 0.0% | `tests/publish-credential-check.bats` | -| 0.6 | 0.0% | `tests/land-lock-check.bats` | -| 0.6 | 0.0% | `tests/sonar-gate.bats` | -| 0.6 | 0.0% | `tests/serena-mcp.bats` | -| 0.5 | 0.0% | `tests/abandon-matrix.bats` | +| 0.6 | 0.0% | `tests/checksums.bats` | +| 0.5 | 0.0% | `tests/connector-verb-guard.bats` | +| 0.5 | 0.0% | `tests/land-lock-check.bats` | +| 0.5 | 0.0% | `tests/sonar-gate.bats` | | 0.5 | 0.0% | `tests/pipefail-grep-check.bats` | -| 0.5 | 0.0% | `tests/pkl-check.bats` | -| 0.5 | 0.0% | `tests/digest-major-agreement.bats` | -| 0.5 | 0.0% | `tests/branch-age-check.bats` | -| 0.5 | 0.0% | `tests/report-only-check.bats` | -| 0.5 | 0.0% | `tests/msrv-pin-agreement.bats` | -| 0.5 | 0.0% | `tests/token-bench-check.bats` | -| 0.5 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.5 | 0.0% | `tests/connector-allow-guard.bats` | -| 0.4 | 0.0% | `tests/release-due.bats` | +| 0.4 | 0.0% | `tests/msrv-pin-agreement.bats` | +| 0.4 | 0.0% | `tests/digest-major-agreement.bats` | +| 0.4 | 0.0% | `tests/run-shape-guard-quoting.bats` | +| 0.4 | 0.0% | `tests/connector-allow-guard.bats` | +| 0.4 | 0.0% | `tests/serena-mcp.bats` | | 0.4 | 0.0% | `tests/suite-bench-check.bats` | +| 0.4 | 0.0% | `tests/abandon-matrix.bats` | +| 0.4 | 0.0% | `tests/transcript-corpus-check.bats` | +| 0.4 | 0.0% | `tests/branch-age-check.bats` | | 0.4 | 0.0% | `tests/license-table-check.bats` | | 0.4 | 0.0% | `tests/no-doctests.bats` | -| 0.4 | 0.0% | `tests/batten-glob-check.bats` | -| 0.4 | 0.0% | `tests/board-payloads.bats` | -| 0.4 | 0.0% | `tests/nonverdict-assert.bats` | -| 0.4 | 0.0% | `tests/cap-drift.bats` | -| 0.4 | 0.0% | `tests/task-fail-closed.bats` | -| 0.4 | 0.0% | `tests/container-preflight.bats` | +| 0.4 | 0.0% | `tests/release-due.bats` | +| 0.4 | 0.0% | `tests/report-only-check.bats` | +| 0.3 | 0.0% | `tests/board-payloads.bats` | +| 0.3 | 0.0% | `tests/nonverdict-assert.bats` | +| 0.3 | 0.0% | `tests/cap-drift.bats` | +| 0.3 | 0.0% | `tests/test-bats-parallel.bats` | +| 0.3 | 0.0% | `tests/mise-pin-agreement.bats` | | 0.3 | 0.0% | `tests/ci-drift.bats` | | 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | -| 0.3 | 0.0% | `tests/rust-paths-check.bats` | +| 0.3 | 0.0% | `tests/batten-glob-check.bats` | | 0.3 | 0.0% | `tests/commit-attribution.bats` | -| 0.3 | 0.0% | `tests/mise-pin-agreement.bats` | -| 0.3 | 0.0% | `tests/test-bats-parallel.bats` | -| 0.3 | 0.0% | `tests/coderabbit-config-check.bats` | -| 0.3 | 0.0% | `tests/mise-action-floor.bats` | -| 0.3 | 0.0% | `tests/git-hook.bats` | +| 0.3 | 0.0% | `tests/token-bench-check.bats` | +| 0.3 | 0.0% | `tests/container-preflight.bats` | +| 0.3 | 0.0% | `tests/task-fail-closed.bats` | +| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.2 | 0.0% | `tests/mise-action-floor.bats` | +| 0.2 | 0.0% | `tests/git-hook.bats` | +| 0.2 | 0.0% | `tests/rust-paths-check.bats` | | 0.2 | 0.0% | `tests/perf-gate.bats` | -| 0.2 | 0.0% | `tests/dist.bats` | +| 0.1 | 0.0% | `tests/dist.bats` | | 0.1 | 0.0% | `tests/evaluator-io-check.bats` | -| 0.1 | 0.0% | `tests/egress-check.bats` | | 0.1 | 0.0% | `tests/perf-pair.bats` | +| 0.1 | 0.0% | `tests/egress-check.bats` | | 0.1 | 0.0% | `tests/zizmor-split.bats` | | 0.1 | 0.0% | `tests/darwin-link.bats` | | 0.1 | 0.0% | `tests/cross-check.bats` | From 5c78456895b538f329621d1444a11a965632b526 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 17:31:34 +0000 Subject: [PATCH 12/12] fix(gate): a receipt that cannot be written is not a nonconformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was red on `ci` with one finding — `Cargo.lock sbom-ntia-conformance` — and the document was conformant the whole time. `ntia-check` ends with `batten receipt record sbom-ntia` under `set -e`. That command exits 1 where the configured transcript is unreadable, which is a runner's ordinary state: no `.claude/.transcript.jsonl` exists on one. So `sbomcheck` answered conformant, `violations` reached 0, and then the receipt write became the gate's exit status — and exit 1 from this file means the document is nonconformant. A false verdict, produced by the environment, about a document nothing was wrong with. Measured three ways rather than reasoned. A `bash -x` trace on a pristine clone of this branch shows the conformant path reaching the record as its last command (`sbomcheck` 0, `violations` 0, gate 1). The record alone exits 1 in that clone. Dropping an empty transcript file in place makes the identical record exit 0. Two things kept it invisible, and this bundle owns both. The row was `warn` until CLOUD-631 promoted it, so the exit status was never blocking; and the suite's `batten` stub could only succeed, so a failing record had no case. The stub can now refuse, which is what made the case writable. So the write is reported and not obeyed, and the asymmetry is the argument: a receipt that was not written costs the next `batten hook` a syft scan, while a receipt that decides conformance costs a verdict. `verify`'s receipts stay fail-closed for the reason that distinguishes them — theirs attest that a check RAN, this one caches an answer already printed. `#MUTANT receipt-failure-decides-conformance` restores the shipped defect, and the new case is what it kills. 25 cases green in `tests/ntia-check.bats`; the pristine clone that exited 1 now exits 0 and says why. Refs: CLOUD-631 --- mise-tasks/ntia-check.sh | 29 ++++++++++++++++++++++++++--- tests/ntia-check.bats | 25 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/mise-tasks/ntia-check.sh b/mise-tasks/ntia-check.sh index ccc073f6c..4d63184f8 100755 --- a/mise-tasks/ntia-check.sh +++ b/mise-tasks/ntia-check.sh @@ -60,6 +60,9 @@ # document nobody has fixed. #MUTANT precondition-ignores-the-spec|s/^\t\tif \[\[ "\$doc_spec" = spdx3 \]\]; then$/\t\tif true; then/|THE DURABLE HALF #MUTANT precondition-guesses-an-absent-spec|s/^\tif \[\[ -z "\$doc_version" \]\]; then$/\tif false; then/|a document declaring no spdxVersion is could-not-look, never a pass +# And the receipt's demotion to advisory. The mutation restores the shipped defect +# — a failed record deciding conformance — which is the false verdict CI reported. +#MUTANT receipt-failure-decides-conformance|s@^\techo "ntia-check: \$\{spdx##\*/\} conforms, but the replay receipt.*$@\texit 1@|a receipt that cannot be written is reported, never a nonconformance set -euo pipefail # Resolved BEFORE the cd: `$0` may be relative, and moving first would leave this @@ -266,7 +269,27 @@ fi # The receipt is the binary's job (CLOUD-203), keyed to the exact commit whose # document conformed — so an amend or a rebase leaves no receipt, which is the # point: `batten hook` can then answer from the receipt instead of paying a syft -# scan inside the p95 < 100ms budget. `set -e` above makes a failed record fail -# this gate and leave no receipt. -"${batten_bin[@]}" receipt record sbom-ntia +# scan inside the p95 < 100ms budget. +# +# ITS FAILURE IS NOT A VERDICT ABOUT THE DOCUMENT, and letting `set -e` make it one +# is how CLOUD-631's promotion turned a green branch red. `batten receipt record` +# exits 1 where the configured transcript is unreadable, and that is a RUNNER's +# ordinary state — no `.claude/.transcript.jsonl` exists on one — so the unguarded +# call reported `sbom-ntia-conformance` over a document `sbomcheck` had just +# judged conformant. Measured on a pristine clone of this branch: `sbomcheck` +# exits 0, `violations` is 0, the record exits 1, the gate exits 1; dropping an +# empty transcript file in place makes the same record exit 0. It stayed invisible +# because the row was `warn` until this bundle promoted it, and because the suite's +# stub could only succeed. +# +# So it is reported and not obeyed. The asymmetry is the point: a receipt that was +# not written costs the next `batten hook` a syft scan, while a receipt that +# decides conformance costs a false verdict — and this file's whole contract is +# that exit 1 means the document is nonconformant. `verify`'s own receipts are the +# precedent for the other direction, and they differ in exactly the way that +# matters: theirs attest a check RAN, this one only caches an answer already +# printed. +if ! "${batten_bin[@]}" receipt record sbom-ntia; then + echo "ntia-check: ${spdx##*/} conforms, but the replay receipt could not be recorded — the next hook pays a scan for it. This is not a verdict about the document." >&2 +fi echo "ntia-check: ${spdx##*/} conforms to ${STANDARDS[*]}" diff --git a/tests/ntia-check.bats b/tests/ntia-check.bats index 1bc2ede50..b2a5905e3 100644 --- a/tests/ntia-check.bats +++ b/tests/ntia-check.bats @@ -180,9 +180,18 @@ EOF # A `batten` that records the receipt call instead of taking one, so the suite # never builds the workspace (hk serialises the cargo target-dir lock). +# A `batten` that records the receipt call instead of taking one, so the suite +# never builds the workspace (hk serialises the cargo target-dir lock). It can also +# REFUSE, which the previous version could not — and a stub that can only succeed +# is why a failing record went unexercised until CI reported one as a +# nonconformance. stub_batten() { cat >"$STUB/batten" <&2 + exit 1 +fi echo "\$*" >>"$BATS_TEST_TMPDIR/receipts" EOF chmod +x "$STUB/batten" @@ -195,6 +204,22 @@ EOF [ "$(cat "$BATS_TEST_TMPDIR/receipts")" = "receipt record sbom-ntia" ] } +@test "a receipt that cannot be written is reported, never a nonconformance" { + # THE FALSE VERDICT CI REPORTED (CLOUD-631). `batten receipt record` exits 1 + # where the configured transcript is unreadable, which is a runner's ordinary + # state, and `set -e` made that the document's verdict — `sbom-ntia-conformance` + # red over a document sbomcheck had just passed. The receipt is a cache written + # after the answer, so its failure costs the next hook a scan and nothing else. + : >"$BATS_TEST_TMPDIR/receipt.fails" + run "$CHECK" + [ "$status" -eq 0 ] + [[ "$output" == *"conforms to ntia"* ]] + [[ "$output" == *"replay receipt could not be recorded"* ]] + # And it is reported rather than swallowed: silence here would hide a hook + # paying a syft scan on every call. + [[ "$output" == *"not a verdict about the document"* ]] +} + @test "a nonconformant document fails, and leaves NO receipt" { : >"$BATS_TEST_TMPDIR/check.ntia.fails" run "$CHECK"