diff --git a/batten.toml b/batten.toml index 28add167d..82eb29ce7 100644 --- a/batten.toml +++ b/batten.toml @@ -1518,22 +1518,53 @@ 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" 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` | 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..cba60d636 --- /dev/null +++ b/crates/batten/tests/fixtures/repos/attribution-coordinate/sbom-actions.tsv.in @@ -0,0 +1,4 @@ +# 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/ntia-check.sh b/mise-tasks/ntia-check.sh index 05f1959e7..4d63184f8 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,16 @@ # 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 +# 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 @@ -57,10 +71,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 +162,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,17 +243,53 @@ 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 # 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/mise-tasks/sbom-actions.tsv b/mise-tasks/sbom-actions.tsv new file mode 100644 index 000000000..887ae728a --- /dev/null +++ b/mise-tasks/sbom-actions.tsv @@ -0,0 +1,73 @@ +# 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: `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 0405e2de0..f95b9ecb3 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,233 @@ 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 + +# --- 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), + # 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), + # 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) \(.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 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. +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 + +# --- 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 + +# --- 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 + +# --- 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%@*}" + # 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 +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, 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 20dc5373f..8a0fde450 100755 --- a/mise-tasks/sbom.sh +++ b/mise-tasks/sbom.sh @@ -32,6 +32,46 @@ # 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 +# 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 +# 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 +# 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 +# And CLOUD-667. Skipping the actions pass returns all 9 to NOASSERTION, which is +# 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 < 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 whose pin is SHORT of 40 hex is refused too set -euo pipefail cd "${SBOM_ROOT:-$(git rev-parse --show-toplevel)}" @@ -68,6 +108,547 @@ 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 +' + +# --- 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' + +# --- 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 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 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. 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 < 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 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 + | 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. +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 --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 + + # 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. + # `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="" + 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" \ + --arg owncopyright "$WORKSPACE_COPYRIGHT" \ + --argjson copyrights "$copyrights" ' + [.packages[] + | "\(.name)@\(.version)" as $key + | {key: $key, + 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), + # `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. + # 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" + 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. +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 + | .copyrightText = $entities[$key].copyright + | .licenseDeclared = $entities[$key].license + | .licenseConcluded = $entities[$key].license + 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) + | (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] +' + +# 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" +} + +# 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 + 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 @@ -88,6 +669,12 @@ 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 + WORKSPACE_COPYRIGHT=$(workspace_copyright) + export WORKSPACE_COPYRIGHT mkdir -p "$OUT_DIR" # One scan, both formats: the catalogers run once and each output is a @@ -99,6 +686,43 @@ 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 + + # 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 + + # 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/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..b2a5905e3 100644 --- a/tests/ntia-check.bats +++ b/tests/ntia-check.bats @@ -43,10 +43,40 @@ setup() { stub_syft stub_sbomcheck stub_batten + stub_cargo } -# 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 +# `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 +# `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"],"license":"Apache-2.0 OR MIT"}]} +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: +# 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" @@ -146,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" @@ -161,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" @@ -279,3 +338,119 @@ 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"* ]] +} + +# ─── 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" ] +} diff --git a/tests/sbom-check.bats b/tests/sbom-check.bats index 7ab96e109..9040f7ba8 100644 --- a/tests/sbom-check.bats +++ b/tests/sbom-check.bats @@ -24,19 +24,65 @@ 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 } -# A Cargo.lock declaring $1 packages — the number the cargo purl count must equal. +# `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 +# `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"],"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 + 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 +# 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 +123,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 "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@%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 # 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 new file mode 100644 index 000000000..69c316d7d --- /dev/null +++ b/tests/sbom.bats @@ -0,0 +1,713 @@ +#!/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 + # 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 +# 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)" ] +} + +# ─── 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), +# 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" <"$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" ] +} + +# ─── CLOUD-629: copyright, read from the bytes the lockfile pins ────────────── +# +# These point `CARGO_HOME` at a synthetic registry cache, which is the only way to +# reach the cases that matter: the real cache contains whatever crates this tree +# happens to depend on, so a fixture built from it would assert this week's +# dependency set rather than the rule. + +# An unpacked crate source under a fake CARGO_HOME, with $2 as the contents of the +# file named $3 (default LICENSE). +fake_crate() { + local nameversion="$1" body="$2" file="${3:-LICENSE}" + local dir="$BATS_TEST_TMPDIR/cargo/registry/src/index.crates.io-fixture/$nameversion" + mkdir -p "$dir/$(dirname "$file")" + printf '%s' "$body" >"$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" ] +} + +# ─── 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" { + # 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" + [ "$status" -eq 0 ] + [ "$(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. + +# 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" +} + +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@%s\tMIT\tCopyright (c) 2018 GitHub, Inc. and contributors' "$PIN")" + 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@%s\tLGPL-3.0-only\tNONE' "$PIN")" + 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@%s\tMIT\tCopyright (c) 2020 Someone' "$PIN")" + run "$SBOM" + [ "$status" -eq 0 ] + [ "$(license_of actions/checkout)" = "NOASSERTION" ] +} + +@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@%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" == *"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 + 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" ] +} + +@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" + 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 +exit 1 +EOF + chmod +x "$STUB/syft" + run "$SBOM" + [ "$status" -eq 1 ] + [[ "$output" == *"could not scan"* ]] +}