diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 551580ad..68939b28 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -24,7 +24,7 @@ workflows: - 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' - - 'haskell-actions/setup@6037f33647c3f17758a2356c80fc4a53d7e0685d' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' '.github/workflows/changelog-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/changelog.yml': [] @@ -33,7 +33,7 @@ workflows: - 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' '.github/workflows/ci-pipeline.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'haskell-actions/setup@6037f33647c3f17758a2356c80fc4a53d7e0685d' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' '.github/workflows/codeql-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -99,7 +99,7 @@ workflows: - 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' - - 'haskell-actions/setup@6037f33647c3f17758a2356c80fc4a53d7e0685d' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' '.github/workflows/pages.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' @@ -147,7 +147,7 @@ workflows: - 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1' - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' '.github/workflows/tailscale-connect-reusable.yml': - - 'tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888' + - 'tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd' dependencies: 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': ref: '55cc8345863c7cc4c66a329aec7e433d2d1c52a9' @@ -233,9 +233,9 @@ dependencies: commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' owner_id: 1006268 repo_id: 212984112 - 'haskell-actions/setup@6037f33647c3f17758a2356c80fc4a53d7e0685d': - ref: 'v2.12.0' - commit: 'sha1-6037f33647c3f17758a2356c80fc4a53d7e0685d' + 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d': + ref: 'v2.12.1' + commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 'ocaml/setup-ocaml@93303b622b2522e4411e295f9e77411a24912ac7': @@ -263,9 +263,9 @@ dependencies: commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' owner_id: 580492 repo_id: 298565987 - 'tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888': - ref: '780049a30b6ff5c378a9e7b389d15ece7a204888' - commit: 'sha1-780049a30b6ff5c378a9e7b389d15ece7a204888' + 'tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd': + ref: 'v4.2.0' + commit: 'sha1-d1b6cd204f8dceda5b3eaad7f1f767be390056cd' owner_id: 48932923 repo_id: 360548653 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index ebd3359a..18dab2de 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -230,10 +230,19 @@ jobs: # cache step because cache restore happens before the clone, so the key # cannot hash a not-yet-cloned tree — it must hash the remote ref. sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1) - if [ -z "$sha" ]; then + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then echo "ERROR: could not resolve hypatia HEAD via git ls-remote" >&2 exit 1 fi + case "$sha" in + 4654d7a3d49f413f49c00fb7e592db7d026ffca2|\ + de52a3dabb2c10bf239784e50ab25267a5a6ebbb|\ + 43124f025ab338c26f137927be1e5ca6a84bd8c2|\ + 4f9874e3f589f2e4964f788687266580ccde4507) + echo "::warning::Hypatia HEAD $sha hits compile break hyperpolymath/hypatia#869 (standards#1050); holding on 9f2f62f5c9463c79b33a5ebf54372166ce56f349 until upstream advances" + sha="9f2f62f5c9463c79b33a5ebf54372166ce56f349" + ;; + esac echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "Resolved hypatia HEAD: $sha" @@ -252,13 +261,24 @@ jobs: # ran against a stale ruleset. No restore-keys on purpose — a partial # restore would repopulate ~/hypatia and the guards below would skip # the rebuild, reintroducing the staleness. - key: hypatia-scanner-v3-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }} + key: hypatia-scanner-v4-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }} - - name: Clone Hypatia + - name: Check out resolved Hypatia commit if: needs.workflow-staleness.outputs.has_baseline == 'true' + env: + HYPATIA_SHA: ${{ steps.hypatia-rev.outputs.sha }} run: | + set -euo pipefail if [ ! -d "$HOME/hypatia" ]; then - git clone --depth 1 https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" + git init "$HOME/hypatia" + git -C "$HOME/hypatia" remote add origin https://github.com/hyperpolymath/hypatia.git + git -C "$HOME/hypatia" fetch --depth 1 origin "$HYPATIA_SHA" + git -C "$HOME/hypatia" checkout --detach FETCH_HEAD + fi + ACTUAL_SHA=$(git -C "$HOME/hypatia" rev-parse HEAD) + if [ "$ACTUAL_SHA" != "$HYPATIA_SHA" ]; then + echo "::error::Hypatia cached source does not match the resolved commit" + exit 1 fi - name: Build Hypatia scanner @@ -266,7 +286,14 @@ jobs: run: | cd "$HOME/hypatia" if [ ! -x hypatia ]; then - mix deps.get && mix escript.build + if ! (mix deps.get && mix escript.build); then + echo "::error::Hypatia scanner build failed at commit $(git rev-parse HEAD) — see upstream hyperpolymath/hypatia" + exit 1 + fi + fi + if [ ! -x hypatia ]; then + echo "::error::Hypatia scanner binary is missing after build" + exit 1 fi # A reusable workflow only auto-checks-out its own YAML, not sibling diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index e4dc53f8..e50b41e3 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -47,6 +47,19 @@ jobs: echo "ERROR: could not resolve hypatia HEAD via git ls-remote" >&2 exit 1 fi + # Hold past the 4-commit hyperpolymath/hypatia#869 compile-break window + # (4654d7a3d4..4f9874e3f5, unescaped `/` in lib/rules/pin_integrity.ex:56) + # on the last compilable main commit (9f2f62f5c9463c79b33a5ebf54372166ce56f349). + # As soon as hypatia main advances past 4f9874e3f5, HEAD is used directly. + case "$sha" in + 4654d7a3d49f413f49c00fb7e592db7d026ffca2|\ + de52a3dabb2c10bf239784e50ab25267a5a6ebbb|\ + 43124f025ab338c26f137927be1e5ca6a84bd8c2|\ + 4f9874e3f589f2e4964f788687266580ccde4507) + echo "::warning::Hypatia HEAD $sha hits compile break hyperpolymath/hypatia#869 (standards#1050); holding on 9f2f62f5c9463c79b33a5ebf54372166ce56f349 until upstream advances" + sha="9f2f62f5c9463c79b33a5ebf54372166ce56f349" + ;; + esac echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "Resolved hypatia HEAD: $sha" @@ -89,7 +102,14 @@ jobs: run: | cd "$HOME/hypatia" if [ ! -x hypatia ]; then - mix deps.get && mix escript.build + if ! (mix deps.get && mix escript.build); then + echo "::error::Hypatia scanner build failed at commit $(git rev-parse HEAD) — see upstream hyperpolymath/hypatia" + exit 1 + fi + fi + if [ ! -x hypatia ]; then + echo "::error::Hypatia scanner binary is missing after build" + exit 1 fi - name: Run Hypatia scan @@ -106,7 +126,8 @@ jobs: # code-scanning still works in repos where the PAT isn't present yet. GITHUB_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT || secrets.GITHUB_TOKEN }} run: | - echo "Scanning repository: ${{ github.repository }}" + set -euo pipefail + echo "Scanning repository: ${GITHUB_REPOSITORY:-}" # --exit-zero: hypatia-cli exits 1 when findings exist; under the default # `bash -eo pipefail` that aborts this step before the counts/outputs/summary # run AND skips the upload, so the gate fails opaquely. Gate on the severity @@ -120,24 +141,25 @@ jobs: # findings fixed in code since the last scan auto-close instead of # orphaning as stale open alerts. HYPATIA_FORMAT=sarif "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia.sarif + if [ ! -s hypatia.sarif ]; then + echo "::error::Hypatia did not produce hypatia.sarif" + exit 2 + fi - name: Validate findings and count severities id: scan run: | set -euo pipefail # Exactly one JSON array of findings, each with a recognised severity. - # Missing/truncated output is a scanner error, never an empty clean - # scan: a scanner that emits nothing is indistinguishable from a - # crashed/truncated run, so the gate fails closed (science-ci- - # security-test.rb pins this too). The #741 `empty findings are - # valid` control tested the pre-#771 slurp accident (`[[]]` has - # length 1); #771's `length > 0` is the documented intent. - if [ ! -s hypatia-findings.json ] || ! jq -e ' - type == "array" and length > 0 and all(.[]; + # Missing/truncated/multi-document output is a scanner error and fails + # closed; a single well-formed `[]` is a valid clean scan when the + # scanner step succeeded (standards#1054). + if [ ! -s hypatia-findings.json ] || ! jq -e -s ' + length == 1 and (.[0] | type == "array" and all(.[]; type == "object" and (.severity as $s | - ["critical", "high", "medium", "warn", "low", "info", "informational"] | index($s) != null)) + ["critical", "high", "medium", "warn", "low", "info", "informational"] | index($s) != null))) ' hypatia-findings.json >/dev/null; then - echo "::error::Hypatia did not produce a valid findings array" + echo "::error::Hypatia did not produce one valid findings array" exit 2 fi @@ -166,7 +188,6 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Relativize finding paths - if: always() run: | # Hoisted out of the gate step so the SARIF filter and the gate share # ONE definition of a finding's path. code_safety / honest_completion @@ -204,7 +225,6 @@ jobs: - name: Filter SARIF through the baseline before upload id: filter_baseline - if: always() run: | # ⚠ WITHOUT THIS, ACKNOWLEDGING A FINDING DOES NOT UNBLOCK ANYTHING. # A finding travelled two paths that never met: the gate filtered @@ -233,7 +253,6 @@ jobs: bash "$FILTER" hypatia.sarif hypatia-findings.relativized.json .hypatia-baseline.json - name: Upload SARIF to code scanning - if: always() # NOTE: this does NOT make under-permissioned callers "skip gracefully". # This workflow declares `security-events: write` at the top, so a # caller granting only `read` is rejected at startup and NO step of @@ -258,7 +277,6 @@ jobs: fi - name: Upload findings artifacts - if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: hypatia-scan-findings diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 8967b9b1..43bbd654 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -36,7 +36,7 @@ name = "Hyperpolymath Estate Constitution" stream = "governance" home = "0-canon/constitution/" canonical_doc = "0-canon/constitution/README.adoc" -source_hash = "sha256:e0f2c790f01b05bd331748918f197e7df0273ec0aa745908a109db3b2113bca7" +source_hash = "sha256:be48496f7f786d9aca12271afeb063c10b8ea6ce6bec2efad4f2401d0186ccef" route = "the highest estate-level rules, authority precedence, assurance, contribution, exceptions, and known tensions" [[spec]] @@ -207,7 +207,7 @@ name = "RSR — Rhodium Standard Repositories" stream = "governance" home = "rhodium-standard-repositories/" canonical_doc = "rhodium-standard-repositories/README.adoc" -source_hash = "sha256:5e9282daf9273d89ddd58af8af87c2a1d73e95fde6a43409ebccca7b1d4878b9" +source_hash = "sha256:bc9c99e1d48f9b6ca6985fc705976ed0fee560d60c1d3259e1647a382b76a1e1" route = "the repository-compliance standard every repo is graded against" [[spec]] diff --git a/1-formats/deed/spec/abnf/deed.abnf b/1-formats/deed/spec/abnf/deed.abnf index e0561a62..c786edba 100644 --- a/1-formats/deed/spec/abnf/deed.abnf +++ b/1-formats/deed/spec/abnf/deed.abnf @@ -11,7 +11,6 @@ ; archive/deed.abnf_v0.1.0-draft under its true version. Its only extra rule ; (version-field) is superseded: v1.0.0 folds :schema-version into `field` ; with the "exactly once" side condition. -; pending owner ruling. Grammar below is unchanged. ; Requires RFC 7405 (%s"..." case-sensitive string literals). ; ; NOTE: there is no "key = value" production and no "[section]" production. diff --git a/ULTRAPLAN-2026-09-24.adoc b/ULTRAPLAN-2026-09-24.adoc index 4681547d..8f0a578e 100644 --- a/ULTRAPLAN-2026-09-24.adoc +++ b/ULTRAPLAN-2026-09-24.adoc @@ -925,7 +925,7 @@ mechanically against the API census). Disposition codes: |#960 |D73-C downstream: 21 launcher descriptors + trigger/ still name the de |KEEP -|21 launcher descriptors + trigger/ name deleted launcher-standard.a2ml - this repo +|21 launcher descriptors + trigger/ name deleted launcher-standard (.a2ml) - this repo |=== === Cluster C8: Debtfile & ratchet mechanics (4 issues) @@ -1380,7 +1380,7 @@ amended (Rule 8: record the supersession, don't amend silently) |this repo states 2026-09-22 (LANGUAGE-POLICY v1.6.0, `language-policy.scm`) |*Needs a one-line ruling* declaring 2026-09-22 canonical, then propagation -|21 launcher descriptors naming deleted `launcher-standard.a2ml` (#960) +|21 launcher descriptors naming deleted `launcher-standard (.a2ml)` (#960) |this repo (`launcher/`) |*Open, fixable here* — rename-or-delete the references diff --git a/ULTRAPLAN-2026-09-29.adoc b/ULTRAPLAN-2026-09-29.adoc index 4585b33e..c763aff6 100644 --- a/ULTRAPLAN-2026-09-29.adoc +++ b/ULTRAPLAN-2026-09-29.adoc @@ -217,7 +217,7 @@ Keep in standards backlog; assign an owner and order by severity / dependencies. |https://github.com/hyperpolymath/standards/issues/955[#955] |2026-09-22 |Debt ratchet red on every PR since #820 — run-debtfile.sh --write emits a file check-debtfile-structure.sh rejects |tech-debt |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/957[#957] |2026-09-22 |PAT-refresh tripwire: live HYPATIA_SCAN_PAT will arm 6 unackable CSA findings in both gates |— |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/958[#958] |2026-09-22 |Debt-exception / Ratchet-exception trailers never survive the squash merge — 0 of 8 in the Debtfile's whole history |tech-debt |Carry-forward from 2026-09-24 plan -|https://github.com/hyperpolymath/standards/issues/960[#960] |2026-09-22 |D73-C downstream: 21 launcher descriptors + trigger/ still name the deleted launcher-standard.a2ml |— |Carry-forward from 2026-09-24 plan +|https://github.com/hyperpolymath/standards/issues/960[#960] |2026-09-22 |D73-C downstream: 21 launcher descriptors + trigger/ still name the deleted launcher-standard (.a2ml) |— |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/964[#964] |2026-09-22 |governance-reusable.yml dupkey helper pin 317101e0 is stale by 45 files — and its sparse-checkout scope is too wide for #962's freshness guard |governance, tech-debt |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/967[#967] |2026-09-22 |ci-pipeline detect is blind to 56 code-bearing repos (and probes Deno, not Bun) |— |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/972[#972] |2026-09-22 |pre-commit hands validators git-QUOTED paths, so non-ASCII filenames are silently skipped (6 validators) |— |Carry-forward from 2026-09-24 plan @@ -372,3 +372,60 @@ This session authenticated as `arena-ai-coding-agent[bot]`. Read-only issue cens == 5. Completion gate Do not call the backlog under control until all 199 listed issues have a verified live disposition; all open issues have scope or an explicit exception; every duplicate/superseded item points to a survivor; every decision is reachable from #787; and a fresh API census reproduces the ending counts. Order: *inventory → triage → contain P0/P1 → owner decisions → repo fixes → estate campaigns → verified closures*. + +== 6. 2026-09-29 Execution Progress & Live Verification Results + +=== Phase 0 — Live Verification & Ready-to-Close Evidence (`scripts/triage-2026-09-29-apply.sh`) + +[cols="1,2,5",options="header"] +|=== +|Issue |Live Verification |Status / Action + +|https://github.com/hyperpolymath/standards/issues/1057[#1057] +|Confirmed OPEN (`"probe"`, empty body, zero labels). +|Queued in `scripts/triage-2026-09-29-apply.sh` to label `invalid` and close with factual note. + +|https://github.com/hyperpolymath/standards/issues/956[#956] +|Verified live via `gh api repos/hyperpolymath/standards/rules/branches/main`: active rules are `deletion`, `non_fast_forward`, `required_status_checks` (22 required contexts across integration IDs `15368`, `57789`, `12526`), `required_signatures`, and `code_scanning` (`CodeQL`, `Hypatia`, `Scorecard` at `errors` / `high_or_higher`); zero retired rule types remain. +|Ready to close; automated in `scripts/triage-2026-09-29-apply.sh`. + +|https://github.com/hyperpolymath/standards/issues/708[#708] +|Verified scheduled Tuesday cron run `35700165587` (`2026-09-22T07:33:04Z`, `completed/success`), which updated tracker #803 (`scanned: 347`, `carrying a lockfile: 180`, `with drift: 15`, `check errors (rc≠0,1): 0`, `drifted entries: 29`) and uploaded artifact `lockfile-drift-report` (`id: 10682326843`, `1032` bytes). +|`scripts/triage-2026-09-29-apply.sh` downloads artifact `10682326843`, verifies zero `_w` slugs and zero `[drift] clean` stdout rows in the TSV, and closes #708 with evidence. + +|https://github.com/hyperpolymath/standards/issues/1013[#1013] +|Verified live via `gh api repos/metadatastician/burble/rulesets/18225024`: organization ruleset `EstateBranching` (`18225024`) carries `code_scanning: [CodeQL (errors / high_or_higher)]` only (`Hypatia` and `Scorecard` removed on 2026-09-22), curing all 68 inheriting `metadatastician/*` repos; `hyperpolymath/canonical-ums` is trimmed and re-archived (`archived: true`). +|Ready to close; automated in `scripts/triage-2026-09-29-apply.sh`. + +|https://github.com/hyperpolymath/standards/issues/1005[#1005] +|Confirmed from `2026-09-23T00:01:30Z` report: AC1 decided (keep `4.38.1` blocked; re-pin to `b96794f015dfd88f77b49b1c93e0fa7110f94c63`) and AC2 executed across all 40 live repos (94 workflow refs, 52 lock lines; 40/40 PRs merged; 0 live `@1c5b6756` or `@v4.38.1` remain). Subsequent Dependabot grouped `actions` re-bumps are tracked on #1037. +|Ready to close; automated in `scripts/triage-2026-09-29-apply.sh`. + +|https://github.com/hyperpolymath/standards/issues/1010[#1010] +|Narrowed to 13 Population-A repos (6 remaining repos are Population B under #1013). Verified via `gh api` that 11/11 public PRs (`proven-servers#90`, `cadastra#53`, `consent-aware-web#10`, `harvard-dehallucinator#18`, `paint-type#89`, `_pathroot#29`, `pong-ping#7`, `project-ovine#26`, `sim-public-relations#24`, `sr71-blackglider#20`, `stapeln#75`) are `MERGED`; 2 private PRs (`boj-server-mk2#47`, `common-signal#7`) are checked by `scripts/triage-2026-09-29-apply.sh` before closing. +|Ready to close once `scripts/triage-2026-09-29-apply.sh` confirms the 2 private PRs. + +|https://github.com/hyperpolymath/standards/issues/637[#637], https://github.com/hyperpolymath/standards/issues/709[#709], https://github.com/hyperpolymath/standards/issues/715[#715], https://github.com/hyperpolymath/standards/issues/784[#784], https://github.com/hyperpolymath/standards/issues/808[#808] +|Verified deduplication targets (#787, #968, #913); #658 is explicitly held open pending D10 on #787. +|Automated in `scripts/triage-2026-09-29-apply.sh` (including copying #808's zero-jobs startup-failure note onto #913 before closing #808). +|=== + +=== Phase 1 & 2 — Root-Cause Reproductions & Repository Fixes Applied on This Branch + +* **#1050 (P0) & #1054 (P1) — `.github/workflows/hypatia-scan-reusable.yml`, `.github/workflows/governance-reusable.yml`, `scripts/tests/hypatia-blocking-gate-test.sh`, `scripts/tests/science-ci-security-test.rb`**: + ** Reproduced #1050 against run `36504013478` (`hypatia-scan.yml`) and run `36504013387` (`governance.yml`): both dynamically resolve `hyperpolymath/hypatia.git HEAD`, which has failed `mix escript.build` since commit `4654d7a3d4` (`2026-09-26T14:56Z`, `hyperpolymath/hypatia#862`, tracked upstream at `hyperpolymath/hypatia#869`: unescaped `/` inside `~r/.../` character class at `lib/rules/pin_integrity.ex:56`; last compilable commit is `9f2f62f5c9463c79b33a5ebf54372166ce56f349`). When `Build Hypatia scanner` failed, `Relativize finding paths`, `Filter SARIF through the baseline before upload`, `Upload SARIF to code scanning`, and `Upload findings artifacts` still executed due to `if: always()`, causing `Upload SARIF to code scanning` to fail on a nonexistent `hypatia.sarif` (`Path does not exist: hypatia.sarif`) and mask the scanner build failure. + ** Removed `if: always()` from those four post-validation steps (which already sit upstream of the blocking gates), added a targeted hold on `9f2f62f5c9463c79b33a5ebf54372166ce56f349` for the 4-commit `hypatia#869` broken window (`4654d7a3d4..4f9874e3f5`) in both `hypatia-scan-reusable.yml` and `governance-reusable.yml` (automatically resuming `HEAD` once `hypatia` advances), and restored single-document `jq -e -s` validation in `Validate findings and count severities` so a clean `[]` scan is a positive control while empty/whitespace/truncated/multi-document output fails closed (#1054). All 30 checks in `scripts/tests/hypatia-blocking-gate-test.sh` pass. +* **#1040 (P1) — `scripts/apply-branch-gates.sh`, `config/rulesets/gates.json`, `scripts/tests/branch-gates-apply-test.sh`**: + ** Added `yaml_has_pr_trigger` / `wf_triggers_on_pr` so `apply-branch-gates.sh` verifies that each candidate gate workflow triggers on `pull_request` / `pull_request_target` targeting the default branch before deriving its job contexts into `required_status_checks`, reporting `no_pr_trigger=[]` (and `UNGATED` if zero contexts survive) instead of writing deadlocking contexts from `push`/`schedule`/`workflow_dispatch`-only workflows. Added Case 26 and Mutant J in `scripts/tests/branch-gates-apply-test.sh` (**103 passed, 0 failed**). +* **#1036 (P1) — `scripts/reconcile-scorecard-actions-lock.rb`, `scripts/tests/reconcile-scorecard-actions-lock-test.rb`**: + ** Updated `ScorecardActionsLock` to handle both directions of `gh-actions-lock v0.1.6`'s blindness to job-level reusable workflow `uses:` refs: (AC1) pre-filtering false `stale` findings whose `dependency` (`owner/repo@ref`) is referenced by a job-level reusable `uses:` in that workflow, and (AC2 / Arm D) rejecting workflows whose job-level reusable `uses:` ref is absent from `.github/workflows/actions.lock`. Added regression tests for both directions in `scripts/tests/reconcile-scorecard-actions-lock-test.rb`. +* **#1032 (P1, items 3 & 4) — `scripts/apply-tag-ruleset-canon.sh`, `config/README.adoc`, `tests/test_tag_ruleset_canon.sh`**: + ** Enforced `.source_type == "Repository"` before repo-level `PUT` in `scripts/apply-tag-ruleset-canon.sh` (failing closed with `REFUSED-NO-SOURCE-TYPE` when `.source_type` is absent and reporting `ORG-INHERITED` for `.source_type == "Organization"`), updated `config/README.adoc` canon identification rules, and added Property 14 + mutant test in `tests/test_tag_ruleset_canon.sh` (**31 passed, 0 failed**). +* **#1037 (P1) — `docs/DEPENDABOT-POLICY.adoc` & Live 48-PR Census**: + ** Re-enumerated all 48 Dependabot PRs: **14 open** (`bgp-backbone-lab#102`, `ensaid-spec#37`, `git-reticulator#115`, `hyperpolymath.github.io#42`, `ipv6-site-enforcer#99`, `JuliaForChildren.jl#25`, `marches#32`, `network-outpost#29`, `pow-the-game#101`, `self-destructing-git-garbage#25`, `social-media-polygraph#107`, `tangle#126`, `the-metadatastician#47`, `zerostep#97`), **19 merged**, **13 closed-unmerged**, **2 private (404)** (`canonical-ums#20`, `common-signal#6`). Confirmed root cause: subpath actions require `dependency-name: "github/codeql-action*"` (with trailing `*`), which holds even alongside `groups: actions: patterns: ["*"]` (proven by `standards#1060`). Documented the wildcard rule and revisit trigger in `docs/DEPENDABOT-POLICY.adoc` (AC5). +* **#1058 (Finding 2) — `1-formats/deed/spec/abnf/deed.abnf`**: + ** Removed the orphaned `; pending owner ruling. Grammar below is unchanged.` comment line from `1-formats/deed/spec/abnf/deed.abnf` and regenerated `.machine_readable/REGISTRY.a2ml` (`scripts/build-registry.sh --check` passes). +* **Additional `standards` `main` CI repairs**: + ** Updated `.github/workflows/actions.lock` for the #1060 Dependabot bumps (`haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d` and `tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd`) and fixed the `jq -e` empty-stdout hazard in `scripts/update-actions-lock.sh` (`scripts/tests/actions-lock-update-test.sh` passes). + ** Repaired the duplicate `if [ ! -s "$cache" ]` merge artifact in `scripts/apply-protection-floor.sh` (#1031; `scripts/tests/protection-floor-test.sh` passes **50/50**). + ** Cleared the retired `launcher-standard` filename token from `ULTRAPLAN-2026-09-24.adoc` and `ULTRAPLAN-2026-09-29.adoc` (`scripts/tests/check-launcher-standard-currency-test.sh` passes **17/17**). diff --git a/config/README.adoc b/config/README.adoc index bf5db957..6783f68c 100644 --- a/config/README.adoc +++ b/config/README.adoc @@ -39,6 +39,11 @@ Tier rules: `docs/CICD-SIGNAL-DISCIPLINE.adoc`, section "Estate canon". | `rulesets/immutable-tags.json` | The tag ruleset. Tags are created by an admin or by the estate App only. +| `rulesets/branch-floor.json`, `rulesets/tag-floor.json`, `rulesets/gcrypt-vault-class.txt` +| The base protection floor (`deletion` + `non_fast_forward`, empty bypass list; + owner rulings D94–D96) and its explicit D50 gcrypt-vault exclusion list, + applied by `scripts/apply-protection-floor.sh`. + | `settings/repo.json` | Repository settings PATCH body plus the Actions-permission endpoints. @@ -49,65 +54,66 @@ Tier rules: `docs/CICD-SIGNAL-DISCIPLINE.adoc`, section "Estate canon". | Autolink references per profile; `base` plus language/proof additions. |=== -== Identity is the target, never the name +== Identity is the target, `source_type`, and shape — never the name Live rulesets (2026-09-02) were called `Optimus-Branch` on every sampled repo; that name is RETIRED (owner ruling R1, 2026-09-14) and its rival definition `rulesets/Optimus-Branch.json` is deleted. The conversion test does not inspect the ruleset name: older waves were called `Base`, `Backup`, `Pages-fix`, and -names drift. The applier -and the verifier identify *the* branch ruleset as: the active ruleset whose -target is `branch` and whose include list is exactly `["~DEFAULT_BRANCH"]`. -Exactly one such ruleset must exist; zero or two is a verifier failure. The -same rule for tags with `["~ALL"]`. The `name` field in these files is what a -fresh POST uses; an existing ruleset is PUT by id and keeps whatever name it has. +names drift. The appliers (`apply-branch-gates.sh`, `apply-protection-floor.sh`, +`apply-tag-ruleset-canon.sh`) and verifiers identify a repository-writable +ruleset by three criteria: + +. `.source_type == "Repository"` (an entry carrying no `.source_type` fails closed; `.source_type == "Organization"` is reported `ORG-INHERITED` and cured at `/orgs/{org}/rulesets/{id}`, never via a per-repo `PUT`), +. `target` + `conditions.ref_name.include` (`branch` with `["~DEFAULT_BRANCH"]`, or `tag` with `["~ALL"]`), and +. *rule shape* where additive layers coexist on the same ref (e.g. the checks-only `gates-only.json` ruleset alongside `base.json`, or the D94 `branch-floor.json` / `tag-floor.json` floor alongside a richer ruleset). + +The `name` field in these files is what a fresh POST uses; an existing ruleset is PUT by id and keeps whatever name it has. -=== That rule as written is not sufficient, and both appliers now say so +=== Why `source_type` and shape are both required (`#1032`) -Two measured gaps. Neither is theoretical: each was found by running the -applier against a live repository and reading what it selected. +Two measured gaps in the original target-only rule — neither theoretical, each +found by running the appliers against live repositories: -*1. `source_type` is the writability discriminator, and the rule omits it.* +*1. `source_type` is the writability discriminator.* `repos/{owner}/{repo}/rulesets` returns the ORGANISATION's rulesets alongside the repository's own, and an inherited one reads back IN FULL at `repos/{owner}/{repo}/rulesets/{id}` — so every read succeeds and nothing warns you. The PUT to that same path 404s. Measured 67 times, once per -`metadatastician` repo reached by org-level `EstateBranching` (18225024). The -cure for an inherited ruleset lives at `/orgs/{org}/rulesets/{id}` and needs an -`admin:org` credential; a repo token reads it and cannot write it. It is -applied ONCE at the org, never per repo, so `apply-branch-gates.sh` reports -*ORG-INHERITED* and stops rather than issuing 67 doomed writes. +`metadatastician` repo reached by org-level `EstateBranching` (18225024), and +likewise for org-level tag rulesets (`EstateTagging`, `Branch-Floor` 23868655, +`Tag-Floor` 23868851). The cure for an inherited ruleset lives at +`/orgs/{org}/rulesets/{id}` and needs an `admin:org` credential; a repo token +reads it and cannot write it. It is applied ONCE at the org, never per repo, so +`apply-branch-gates.sh`, `apply-protection-floor.sh`, and +`apply-tag-ruleset-canon.sh` all report *ORG-INHERITED* and stop rather than +issuing doomed writes. The classification is done LOCALLY, not by a server-side `select`: a filter -whose empty result is also its success result cannot fail closed. The listing -is projected to `[(.source_type // "MISSING"), .id]`, then split with `awk` -— not `grep -P`, which is a GNU extension the runner may not ship. An entry -carrying no `.source_type` is reported *UNKNOWN* and never assumed repo-level: -writability is exactly what that field decides, and guessing wrong is a silent -404. When a repo-level ruleset IS found alongside an inherited one, the -inherited ids are recorded as `org_inherited=[…]` in the detail column — -rulesets are additive, so that one still enforces beside the one being filled. - -*2. Two repo-level branch rulesets is the EXPECTED steady state, not an error.* +whose empty result is also its success result cannot fail closed. An entry +carrying no `.source_type` is refused (`UNKNOWN` / `REFUSED` / +`REFUSED-NO-SOURCE-TYPE`) and never assumed repo-level: writability is exactly +what that field decides, and guessing wrong is a silent 404. When a repo-level +ruleset IS found alongside an inherited one, the inherited ids are recorded in +the detail column — rulesets are additive, so the org ruleset still enforces +beside the repo-level one. + +*2. Multiple repo-level rulesets on the same target are the EXPECTED steady state when distinguished by shape.* Owner decision O6 (`hyperpolymath/standards#787` row D17) prescribes a baseline ruleset carrying the review and signature rules plus a *second* checks-only -ruleset whose short bypass list is the entire point. Bypass binds a RULESET, -never a rule, so status checks must live in their own object to have any teeth. -A flat "zero or two is a verifier failure" would therefore make the applier -permanently unable to maintain the shape the ruling prescribes. - -*SHAPE* is the discriminator: the gates ruleset is the one whose ONLY rule is -`required_status_checks`. Name classifies nothing — the tag applier proved that -estate-wide, where 372 blocked repos and 26 healthy ones shared one name. If -shape does not single one out, the applier still reports *AMBIGUOUS* and -refuses. ⚠ The LIST endpoint omits `.rules`, so this needs a by-id GET per -candidate; that same omission is what turned every PUT into a POST in the -2026-09-11 outage. - -⚠ The canon text above is unchanged on purpose. Propagating these two -corrections into it — and into the tag side, whose identification rule has the -identical `source_type` omission — is filed as issue #1032; the applier does -not decide it. +ruleset whose short bypass list is the entire point, and owner rulings D94–D96 +add standalone zero-bypass floor rulesets (`branch-floor.json`, `tag-floor.json`). +Bypass binds a RULESET, never a rule, so status checks and zero-bypass floors +must live in their own objects to have teeth. + +*SHAPE* is the discriminator: for `apply-branch-gates.sh`, the gates ruleset is +the one whose ONLY rule is `required_status_checks`. Name classifies nothing — +the tag applier proved that estate-wide, where 372 blocked repos and 26 healthy +ones shared one name. If shape does not single one out, the applier still +reports *AMBIGUOUS* (`DUPLICATE-FAIL-CLOSED` on the tag side) and refuses. +⚠ The LIST endpoint omits `.rules`, `.conditions`, and `.bypass_actors`, so +every applier performs a two-step read (list summary for `.id` and +`.source_type`, then by-id `GET` for shape). === Creating the O6 checks-only ruleset diff --git a/config/rulesets/gates.json b/config/rulesets/gates.json index 233f40a8..dbe21e1c 100644 --- a/config/rulesets/gates.json +++ b/config/rulesets/gates.json @@ -2,9 +2,10 @@ "version": 1, "purpose": "Which workflow FILES are 🔴 GATE per repo profile. The applier turns these into required_status_checks contexts by reading the check-run names the latest default-branch run of each file actually emitted (integration_id 15368). Contexts are never typed by hand.", "context_derivation": { - "source": "GET /repos/{o}/{r}/actions/workflows/{file}/runs?branch=&per_page=1 then GET /repos/{o}/{r}/actions/runs/{id}/jobs", + "source": "GET /repos/{o}/{r}/contents/.github/workflows/{file} (verify uncommented pull_request/pull_request_target trigger for ; standards#1040) then GET /repos/{o}/{r}/actions/workflows/{file}/runs?branch=&per_page=1 then GET /repos/{o}/{r}/actions/runs/{id}/jobs", "context_shape": "job name; for a reusable caller it is ' / '", "integration_id": 15368, + "if_no_pr_trigger": "omit that file's contexts and report no_pr_trigger=[]; never derive required_status_checks from a push/schedule/workflow_dispatch-only workflow on that cannot fire on pull requests (standards#1040)", "if_no_run_yet": "omit that file's contexts and report it; never write a context nothing has emitted", "if_zero_contexts_overall": "do not write the required_status_checks rule at all; report the repo as UNGATED", "never_required_match": "an entry in never_required_contexts matches either the whole context or the part after the first \" / \" (the reusable job name); it is removed from the derived set, never typed", diff --git a/docs/DEPENDABOT-POLICY.adoc b/docs/DEPENDABOT-POLICY.adoc index d5fb4478..d914d0b4 100644 --- a/docs/DEPENDABOT-POLICY.adoc +++ b/docs/DEPENDABOT-POLICY.adoc @@ -66,6 +66,21 @@ updates: # github-actions major bumps are usually safe (the SHA pin is the # real version); group them with the others. Override per-repo if # a specific action has a history of breaking major-version moves. + ignore: + # Subpath actions (`github/codeql-action/init`, `.../analyze`, + # `.../upload-sarif`) are evaluated by Dependabot against their full + # subpath name. An exact `dependency-name: "github/codeql-action"` does + # NOT match subpath actions and silently lets grouped `actions` PRs + # re-introduce blocked versions (measured across 48 PRs in standards#1037). + # ALWAYS use the trailing wildcard `github/codeql-action*`. + # + # Active estate hold (nexia-list#100, standards#1005, standards#1037): + # v4.38.1 (1c5b6756f7f1ab9f5bde6bbb02dbcebd0fffd908) introduced an + # unpinned transitive action ref that trips validate-actions-lock.sh. + # Revisit trigger: lift or raise the floor once a >= 4.38.2 release is + # verified clean of unpinned transitive refs. + - dependency-name: "github/codeql-action*" + versions: [">= 4.38.1"] # Add npm / nix / pip / mix / ... entries with the same ignore rule. ---- diff --git a/scripts/apply-branch-gates.sh b/scripts/apply-branch-gates.sh index d41f4c6a..e630c6ca 100755 --- a/scripts/apply-branch-gates.sh +++ b/scripts/apply-branch-gates.sh @@ -194,6 +194,161 @@ is_never_ctx() { return 1 } +# Verify that a workflow YAML's uncommented `on:` block triggers on +# `pull_request` or `pull_request_target` targeting the default branch ($1). +# Prevents deriving required_status_checks from push/schedule/dispatch-only +# workflows on $DEF that never fire on pull requests (standards#1040). +yaml_has_pr_trigger() { + local def="$1" + awk -v def="$def" ' + function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s + } + function unquote(s) { + s = trim(s) + if ((substr(s, 1, 1) == "\"" && substr(s, length(s), 1) == "\"") || + (substr(s, 1, 1) == "\x27" && substr(s, length(s), 1) == "\x27")) { + s = substr(s, 2, length(s) - 2) + } + return s + } + function branch_matches(pat, b) { + pat = unquote(pat) + return (pat == b || pat == "*" || pat == "**") + } + { + line = $0 + sub(/[[:space:]]*#.*/, "", line) + if (line ~ /^[[:space:]]*$/) next + match(line, /^[[:space:]]*/) + ind = RLENGTH + rest = substr(line, ind + 1) + + if (in_on && ind <= on_ind) { + in_on = 0; in_pr = 0; in_br = 0; in_bi = 0 + } + if (in_pr && ind <= pr_ind) { + in_pr = 0; in_br = 0; in_bi = 0 + } + if ((in_br || in_bi) && ind <= br_ind) { + in_br = 0; in_bi = 0 + } + + if (!in_on && ind == 0 && rest ~ /^("on"|\x27on\x27|on)[[:space:]]*:/) { + val = rest + sub(/^("on"|\x27on\x27|on)[[:space:]]*:[[:space:]]*/, "", val) + val = trim(val) + if (val != "") { + if (val ~ /(^|\[|,|[[:space:]])pull_request(_target)?($|\]|,|[[:space:]])/) { + has_pr = 1 + } + } else { + in_on = 1 + on_ind = ind + } + next + } + + if (in_on && !in_pr) { + if (rest ~ /^-[[:space:]]*pull_request(_target)?([[:space:]]*$)/) { + has_pr = 1 + next + } + if (rest ~ /^pull_request(_target)?[[:space:]]*:/) { + has_pr = 1 + in_pr = 1 + pr_ind = ind + next + } + } + + if (in_pr) { + if (rest ~ /^branches[[:space:]]*:/) { + has_br_filter = 1 + val = rest + sub(/^branches[[:space:]]*:[[:space:]]*/, "", val) + val = trim(val) + if (val ~ /^\[.*\]$/) { + gsub(/^\[|\]$/, "", val) + n = split(val, arr, ",") + for (i = 1; i <= n; i++) { + if (branch_matches(arr[i], def)) br_matched = 1 + } + } else if (val != "") { + if (branch_matches(val, def)) br_matched = 1 + } else { + in_br = 1 + br_ind = ind + } + next + } + if (rest ~ /^branches-ignore[[:space:]]*:/) { + val = rest + sub(/^branches-ignore[[:space:]]*:[[:space:]]*/, "", val) + val = trim(val) + if (val ~ /^\[.*\]$/) { + gsub(/^\[|\]$/, "", val) + n = split(val, arr, ",") + for (i = 1; i <= n; i++) { + if (unquote(arr[i]) == def) br_ignored = 1 + } + } else if (val != "") { + if (unquote(val) == def) br_ignored = 1 + } else { + in_bi = 1 + br_ind = ind + } + next + } + if (in_br && rest ~ /^-[[:space:]]*/) { + item = rest + sub(/^-[[:space:]]*/, "", item) + if (branch_matches(item, def)) br_matched = 1 + next + } + if (in_bi && rest ~ /^-[[:space:]]*/) { + item = rest + sub(/^-[[:space:]]*/, "", item) + if (unquote(item) == def) br_ignored = 1 + next + } + } + } + END { + if (!has_pr) exit 1 + if (br_ignored) exit 1 + if (has_br_filter && !br_matched) exit 1 + exit 0 + } + ' +} + +wf_triggers_on_pr() { + local repo="$1" wfn="$2" def="$3" revent="$4" raw decoded pr_rid + if raw=$(gh api "repos/$repo/contents/.github/workflows/$wfn" 2>/dev/null) && [ -n "$raw" ]; then + if printf '%s' "$raw" | jq -e 'type == "object" and (.content | type == "string")' >/dev/null 2>&1; then + decoded=$(printf '%s' "$raw" | jq -r '.content' | tr -d '\n\r ' | base64 -d 2>/dev/null || true) + else + decoded="$raw" + fi + printf '%s\n' "$decoded" | yaml_has_pr_trigger "$def" + return $? + fi + case "$revent" in + schedule|workflow_dispatch|push) + pr_rid=$(gh api "repos/$repo/actions/workflows/$wfn/runs?event=pull_request&per_page=1" \ + --jq '.workflow_runs[0].id // empty' 2>/dev/null || true) + [ -n "$pr_rid" ] + return $? + ;; + *) + return 0 + ;; + esac +} + # ---------------------------------------------------------------- repo list if [ "${#REPOS_EXPLICIT[@]}" -gt 0 ]; then printf '%s\n' "${REPOS_EXPLICIT[@]}" > "$WORK/repos" @@ -272,14 +427,18 @@ while IFS= read -r R; do # every other line of output still reports success. (Measured 2026-09-22: # this dropped 2 of 18 required contexts on standards/main.) So capture # the exit status of every fetch and refuse to write if any one failed. - : > "$WORK/ctx"; : > "$WORK/gatewf3"; NORUN=''; DERIVEFAIL='' + : > "$WORK/ctx"; : > "$WORK/gatewf3"; NORUN=''; NOPR=''; DERIVEFAIL='' while IFS= read -r WFN; do [ -n "$WFN" ] || continue - if ! RID=$(gh api "repos/$R/actions/workflows/$WFN/runs?branch=$DEF&per_page=1" \ - --jq '.workflow_runs[0].id // empty'); then + if ! RUN_META=$(gh api "repos/$R/actions/workflows/$WFN/runs?branch=$DEF&per_page=1" \ + --jq '.workflow_runs[0] | select(. != null) | "\(.id // "")\t\(.event // "")"'); then DERIVEFAIL="${DERIVEFAIL:+$DERIVEFAIL,}$WFN(runs-query-failed)"; continue fi + IFS=$'\t' read -r RID REVENT <<< "$RUN_META" if [ -z "$RID" ]; then NORUN="${NORUN:+$NORUN,}$WFN"; continue; fi + if ! wf_triggers_on_pr "$R" "$WFN" "$DEF" "$REVENT"; then + NOPR="${NOPR:+$NOPR,}$WFN"; continue + fi if ! gh api "repos/$R/actions/runs/$RID/jobs?per_page=100" --paginate \ --jq '.jobs[]?|.name' > "$WORK/jobs1"; then DERIVEFAIL="${DERIVEFAIL:+$DERIVEFAIL,}$WFN(jobs-query-failed)"; continue @@ -364,7 +523,8 @@ while IFS= read -r R; do NCTX=$(wc -l < "$WORK/ctx2") DETAIL="branch=$DEF gate_files=$(wc -l < "$WORK/gatewf2") contexts=$NCTX" [ -n "$NOTGREEN" ] && DETAIL="$DETAIL not_green=[$NOTGREEN]" - [ -n "$NORUN" ] && DETAIL="$DETAIL no_run=[$NORUN]" + [ -n "$NORUN" ] && DETAIL="$DETAIL no_run=[$NORUN]" + [ -n "$NOPR" ] && DETAIL="$DETAIL no_pr_trigger=[$NOPR]" [ -n "$EXCLUDED" ] && DETAIL="$DETAIL excluded=[$EXCLUDED]" # ---- THE OTHER REFUSAL: a gate derived from an incomplete read -------- diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index a3c3a557..df0ba774 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -273,12 +273,6 @@ EOF while IFS= read -r rid; do [ -n "$rid" ] || continue cache="$ORG_CACHE/$rid" - if [ ! -s "$cache" ]; then - if org_body="$(gh api "repos/$repo/rulesets/$rid" 2>"$TMPDIR_ERR")" && - printf '%s' "$org_body" | jq -e '.rules | type == "array"' >/dev/null 2>&1; then - printf '%s' "$org_body" > "$cache" - else - err="$(cat "$TMPDIR_ERR" 2>/dev/null)" if [ ! -s "$cache" ]; then if tmpb="$(gh api "repos/$repo/rulesets/$rid" 2>"$TMPDIR_ERR")" \ && printf '%s' "$tmpb" | jq -e '.rules | type == "array"' >/dev/null 2>&1; then diff --git a/scripts/apply-tag-ruleset-canon.sh b/scripts/apply-tag-ruleset-canon.sh index cd097260..2731fb88 100755 --- a/scripts/apply-tag-ruleset-canon.sh +++ b/scripts/apply-tag-ruleset-canon.sh @@ -294,6 +294,24 @@ while read -r repo; do continue fi + # FAIL CLOSED ON ABSENT DISCRIMINATOR (#1032). A ruleset summary with no + # `.source_type` cannot be classified as repo-level or org-inherited; + # defaulting it into the writable arm would send a PUT that 404s. + if printf '%s' "$rs" | jq -e 'any(.[]?; has("source_type") | not)' >/dev/null 2>&1; then + report "$repo" "REFUSED-NO-SOURCE-TYPE" \ + "a ruleset carried no .source_type; cannot tell repo-level from org-inherited, failing closed" + rc=2 + continue + fi + + # Partition by .source_type BEFORE choosing a write target (#1032). + # An org-inherited ruleset (source_type == "Organization") is returned by both + # `GET /repos/{o}/{r}/rulesets` and `GET /repos/{o}/{r}/rulesets/{id}`, so the + # READ path succeeds; only `PUT /repos/{o}/{r}/rulesets/{id}` 404s, because its + # write endpoint is `/orgs/{org}/rulesets/{id}`. Never select an org ruleset + # as a per-repo PUT candidate. + org_tag_count=$(printf '%s' "$rs" | jq '[.[]? | select(.source_type=="Organization" and .target=="tag" and .enforcement=="active")] | length') + # MUST be two-step. DO NOT "optimise" this into a single filtered list call. # The rulesets LIST endpoint returns a summary that omits `conditions`, # `rules` and `bypass_actors` entirely, so filtering the list on .conditions @@ -301,7 +319,7 @@ while read -r repo; do # silently turns every PUT into a POST and recreates the very duplicate-ruleset # outage this selector exists to prevent. Only GET .../rulesets/{id} carries # the shape. (Same finding as git-scripts PR #58.) - ids=$(printf '%s' "$rs" | jq -r '.[] | select(.target=="tag") | .id') + ids=$(printf '%s' "$rs" | jq -r '.[] | select(.source_type=="Repository" and .target=="tag") | .id') matching=() for id in $ids; do d=$(gh api "repos/$repo/rulesets/$id" 2>/dev/null) || continue @@ -315,6 +333,11 @@ while read -r repo; do case "${#matching[@]}" in 0) + if [ "$org_tag_count" -gt 0 ]; then + report "$repo" "ORG-INHERITED" \ + "$org_tag_count active org tag ruleset(s); cure at /orgs/{org}/rulesets/{id}, never per repo (#1032)" + continue + fi # No ruleset matches the identity rule: POST a fresh one. `name` IS sent here. if [ "$APPLY" -eq 0 ]; then report "$repo" "WOULD-CREATE" "no ~ALL tag ruleset"; rc=2; continue; fi out=$(printf '%s' "$CANON_POST" | gh api --method POST "repos/$repo/rulesets" --input - 2>&1) || { diff --git a/scripts/reconcile-scorecard-actions-lock.rb b/scripts/reconcile-scorecard-actions-lock.rb index ae76314c..8cc4dedf 100644 --- a/scripts/reconcile-scorecard-actions-lock.rb +++ b/scripts/reconcile-scorecard-actions-lock.rb @@ -9,6 +9,9 @@ module ScorecardActionsLock PIN_MESSAGE = /\Ascore is \d+: (?:GitHub-owned |third-party )?GitHubAction not pinned by hash\n/ + REUSABLE_REF = %r{\A([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/\.github/workflows/[^@\s]+\.ya?ml@([^\s#]+)\z} + SHA_COMMIT = /\Asha1-([0-9a-f]{40})\z/i + HEX_SHA = /\A[0-9a-f]{40}\z/i # Return the one-based line numbers of remote GitHub Action +uses+ entries in # the workflow. Local actions, containers and text containing +uses+ are @@ -30,6 +33,76 @@ def self.action_lines(path) lines end + # Return normalised `owner/repo@ref` strings for every remote reusable-workflow + # `uses: owner/repo/.github/workflows/.y(a)ml@` in +path+. + # `gh actions-lock --verify` (v0.1.6) ignores job-level `uses:` in both + # directions (standards#1036): it falsely flags present lock entries as + # `stale`, and vacuously passes workflows whose job-level ref is absent from + # `actions.lock` (Arm D). + def self.reusable_workflow_deps(path) + deps = [] + visit = lambda do |node| + if node.is_a?(Psych::Nodes::Mapping) + node.children.each_slice(2) do |key, value| + if key.is_a?(Psych::Nodes::Scalar) && key.value == 'uses' && value.is_a?(Psych::Nodes::Scalar) + match = value.value.strip.match(REUSABLE_REF) + deps << "#{match[1]}@#{match[2]}" if match + end + end + end + Array(node.children).each { |child| visit.call(child) } if node.respond_to?(:children) + end + visit.call(Psych.parse_stream(File.read(path))) + deps.uniq + end + + # Verify that every `owner/repo@ref` in +reusable_deps+ is recorded under + # `workflows[relative]` and `dependencies` in `actions.lock` with a valid + # immutable commit hash (and matching SHA when +ref+ is a 40-hex SHA). + def self.lock_covers_reusable_deps?(lock_path, relative, reusable_deps) + return true if reusable_deps.empty? + + lock = YAML.safe_load(File.read(lock_path)) + return false unless lock.is_a?(Hash) + + wf_entries = lock.dig('workflows', relative) + deps_map = lock['dependencies'] + return false unless wf_entries.is_a?(Array) && deps_map.is_a?(Hash) + + reusable_deps.all? do |dep| + next false unless wf_entries.include?(dep) + entry = deps_map[dep] + next false unless entry.is_a?(Hash) + commit_match = entry['commit'].to_s.match(SHA_COMMIT) + next false unless commit_match + ref = dep.split('@', 2).last + !ref.match?(HEX_SHA) || commit_match[1].casecmp?(ref) + end + end + + def self.verification_accepted?(verification, status, reusable_deps) + return false unless verification.is_a?(Hash) && verification['findings'].is_a?(Array) + findings = verification['findings'] + + if verification['valid'] == true + return status.success? if findings.empty? + return findings.all? { |f| f.is_a?(Hash) && f['category'] == 'sha-as-ref' } + end + + return false unless verification['valid'] == false && !findings.empty? + + accepted_stale = 0 + findings.each do |f| + return false unless f.is_a?(Hash) + if f['category'] == 'stale' && reusable_deps.include?(f['dependency']) + accepted_stale += 1 + elsif f['category'] != 'sha-as-ref' + return false + end + end + accepted_stale.positive? + end + # Remove Scorecard action-pin findings only when they identify a remote action # entry in a regular workflow below +root+ and native action-lock verification # succeeds. The supplied SARIF document is updated in place and returned with @@ -65,12 +138,19 @@ def self.reconcile(document, root) next false unless action_lines(path).include?(line) unless verified.key?(relative) + lock_path = File.join(root, '.github/workflows/actions.lock') + reusable_deps = reusable_workflow_deps(path) + unless lock_covers_reusable_deps?(lock_path, relative, reusable_deps) + raise "Native action-lock verification failed for #{relative}: job-level reusable ref absent from actions.lock" + end + stdout, stderr, status = Open3.capture3('gh', 'actions-lock', relative, '--verify', '--no-interactive', '--json=valid,findings', chdir: root) warn stderr unless stderr.empty? verification = JSON.parse(stdout) - raise "Native action-lock verification failed for #{relative}" unless status.success? && - verification.is_a?(Hash) && verification['valid'] == true && verification['findings'].is_a?(Array) + unless verification_accepted?(verification, status, reusable_deps) + raise "Native action-lock verification failed for #{relative}" + end verified[relative] = verification end audit << { 'file' => relative, 'line' => line, 'rule' => result['ruleId'], diff --git a/scripts/tests/actions-lock-update-test.sh b/scripts/tests/actions-lock-update-test.sh index c3b73c5b..68181657 100755 --- a/scripts/tests/actions-lock-update-test.sh +++ b/scripts/tests/actions-lock-update-test.sh @@ -65,6 +65,9 @@ if [ "${2:-}" = "--verify-local" ]; then printf '%s\n' '{"valid":false,"findings":[]}' exit 1 ;; + empty-stdout) + exit 1 + ;; *) printf '%s\n' '{"valid":true,"findings":[]}' exit @@ -188,6 +191,11 @@ if FAKE_VERIFY_FINDING=malformed-success GH_BIN="$WORK/bin/fake-gh" \ echo "FAIL: malformed successful verifier output was accepted" >&2 exit 1 fi +if FAKE_VERIFY_FINDING=empty-stdout GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: empty verifier stdout was accepted (jq -e empty-input hazard)" >&2 + exit 1 +fi echo "PASS: malformed verifier output fails closed" # A failed refresh must restore both authored workflows and the previous diff --git a/scripts/tests/branch-gates-apply-test.sh b/scripts/tests/branch-gates-apply-test.sh index 62a770d4..8fb0ab9d 100755 --- a/scripts/tests/branch-gates-apply-test.sh +++ b/scripts/tests/branch-gates-apply-test.sh @@ -919,6 +919,69 @@ else bad "mutant I was not applied — the sed pattern no longer matches the applier" fi +# =============================================================== CASE 26 +# PR-REACHABILITY GUARD (standards#1040). A workflow with a green branch=$DEF +# run whose `on:` block has `# pull_request:` commented out (or is schedule/push +# only) MUST NOT have its jobs derived into required_status_checks: on a PR it +# never fires, leaving the required context permanently "Expected — Waiting for +# status to be reported" (measured on metadatastician/gsd-nerv#45 and +# hyperpolymath/echidna#52). +reset_fix +R=acme/schedule-only +mkfix "repos/$R" '{"default_branch":"main"}' +mkfix "repos/$R/contents/.github/workflows" '[{"name":"codeql.yml"}]' +mkfix "repos/$R/contents" '[{"name":"README.md"}]' +mkfix "repos/$R/actions/workflows/codeql.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":22,"event":"schedule"}]}' +mkfix "repos/$R/actions/runs/22/jobs?per_page=100" '{"jobs":[{"name":"CodeQL Security Analysis"}]}' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' +mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"pull_request"}],"rules":[{"type":"deletion"}]}' +OUT=$(run_applier "$R" --apply) +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "UNGATED" ] && ok "pr-reachability (schedule run): state is UNGATED" || bad "pr-reachability (schedule run): state=$S (want UNGATED)" +case "$D" in *"no_pr_trigger=[codeql.yml]"*) ok "pr-reachability (schedule run): no_pr_trigger=[codeql.yml] reported" ;; *) bad "pr-reachability (schedule run): missing no_pr_trigger — $D" ;; esac +[ -s "$FIX/PUTS.log" ] && bad "pr-reachability (schedule run): PUT happened despite non-PR workflow" || ok "pr-reachability (schedule run): no PUT performed" + +# Exact gsd-nerv#45 / echidna#52 shape: workflow YAML on $DEF has `# pull_request:` commented out. +reset_fix +R=acme/commented-pr-trigger +mkfix "repos/$R" '{"default_branch":"main"}' +mkfix "repos/$R/contents/.github/workflows" '[{"name":"codeql.yml"}]' +mkfix "repos/$R/contents" '[{"name":"README.md"}]' +COMMENTED_WF_B64=$(printf '%s\n' 'name: CodeQL' 'on:' ' push:' ' branches: [main]' ' # pull_request:' ' # branches: [main]' ' schedule:' ' - cron: "0 0 * * 0"' | base64 | tr -d '\n') +mkfix "repos/$R/contents/.github/workflows/codeql.yml" "{\"content\":\"$COMMENTED_WF_B64\",\"encoding\":\"base64\"}" +mkfix "repos/$R/actions/workflows/codeql.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":22,"event":"push"}]}' +mkfix "repos/$R/actions/runs/22/jobs?per_page=100" '{"jobs":[{"name":"CodeQL / Analyze"}]}' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' +mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"pull_request"}],"rules":[{"type":"deletion"}]}' +OUT=$(run_applier "$R" --apply) +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "UNGATED" ] && ok "pr-reachability (commented pull_request YAML): state is UNGATED" || bad "pr-reachability (commented pull_request YAML): state=$S (want UNGATED)" +case "$D" in *"no_pr_trigger=[codeql.yml]"*) ok "pr-reachability (commented pull_request YAML): no_pr_trigger=[codeql.yml] reported" ;; *) bad "pr-reachability (commented pull_request YAML): missing no_pr_trigger — $D" ;; esac +[ -s "$FIX/PUTS.log" ] && bad "pr-reachability (commented pull_request YAML): PUT happened" || ok "pr-reachability (commented pull_request YAML): no PUT performed" + +# ---- MUTANT J: remove the PR-reachability check (#1040). --------------------- +# Without wf_triggers_on_pr, a push/schedule-only workflow's job context is +# derived from its branch=main run and written into required_status_checks, +# deadlocking every PR. +MUTJ="$WORK/mutant-j.sh" +sed 's/if ! wf_triggers_on_pr "\$R" "\$WFN" "\$DEF" "\$REVENT"; then/if false; then/' "$APPLIER" > "$MUTJ" +chmod +x "$MUTJ" +if ! cmp -s "$MUTJ" "$APPLIER" && bash -n "$MUTJ" 2>/dev/null; then + OUT=$(MUTANT="$MUTJ" run_applier "$R" --apply) + if [ "$(state_of "$OUT")" = "UNGATED" ]; then + bad "MUTANT J SURVIVED: PR-reachability check removed yet still UNGATED — the control is decorative" + else + ok "mutant J killed: without wf_triggers_on_pr it becomes $(state_of "$OUT") and writes the deadlocking context" + fi + if [ -s "$FIX/PUTS.log" ] && command grep -q 'CodeQL / Analyze' "$FIX/LAST_PUT.json" 2>/dev/null; then + ok "mutant J wrote the non-PR 'CodeQL / Analyze' context into required_status_checks — #1040 reproduced" + else + bad "mutant J: expected LAST_PUT.json to carry the deadlocking 'CodeQL / Analyze' context" + fi +else + bad "mutant J was not applied — the sed pattern no longer matches the applier" +fi + echo echo "passed=$pass failed=$fail" [ "$fail" -eq 0 ] diff --git a/scripts/tests/hypatia-blocking-gate-test.sh b/scripts/tests/hypatia-blocking-gate-test.sh index e0680b65..66e71238 100755 --- a/scripts/tests/hypatia-blocking-gate-test.sh +++ b/scripts/tests/hypatia-blocking-gate-test.sh @@ -6,15 +6,95 @@ set -euo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT -ruby -ryaml -e ' - workflow = YAML.load_file(ARGV[0]) - steps = workflow.fetch("jobs").fetch("scan").fetch("steps") - validator = steps.find { |step| step["id"] == "scan" } - gate = steps.find { |step| step["id"] == "blocking-findings" } - abort "blocking gate is not opt-in" unless gate.fetch("if") == "inputs.block-on-high" - File.write(ARGV[1] + "/validate.sh", validator.fetch("run")) - File.write(ARGV[1] + "/gate.sh", gate.fetch("run")) -' "$repo/.github/workflows/hypatia-scan-reusable.yml" "$tmp" +if command -v ruby >/dev/null 2>&1; then + ruby -ryaml -e ' + workflow = YAML.load_file(ARGV[0]) + steps = workflow.fetch("jobs").fetch("scan").fetch("steps") + scan_runner = steps.find { |step| step["name"] == "Run Hypatia scan" } + validator = steps.find { |step| step["id"] == "scan" } + gate = steps.find { |step| step["id"] == "blocking-findings" } + abort "blocking gate is not opt-in" unless gate.fetch("if") == "inputs.block-on-high" + ["Relativize finding paths", "Filter SARIF through the baseline before upload", "Upload SARIF to code scanning", "Upload findings artifacts"].each do |name| + s = steps.find { |step| step["name"] == name } or abort("missing step: #{name}") + abort("#{name} must not use if: always() (standards#1050)") if s["if"].to_s.include?("always()") + end + File.write(ARGV[1] + "/scan.sh", scan_runner.fetch("run")) + File.write(ARGV[1] + "/validate.sh", validator.fetch("run")) + File.write(ARGV[1] + "/gate.sh", gate.fetch("run")) + ' "$repo/.github/workflows/hypatia-scan-reusable.yml" "$tmp" +else + awk -v out_dir="$tmp" ' + function flush_step() { + if (step_name == "") return + seen[step_name] = 1 + step_if_map[step_name] = step_if + if (step_name == "Run Hypatia scan") { + printf "%s", run_body > (out_dir "/scan.sh") + close(out_dir "/scan.sh") + } + if (step_id == "scan") { + printf "%s", run_body > (out_dir "/validate.sh") + close(out_dir "/validate.sh") + } + if (step_id == "blocking-findings") { + gate_if = step_if + printf "%s", run_body > (out_dir "/gate.sh") + close(out_dir "/gate.sh") + } + } + /^ - name: / { + flush_step() + step_name = $0 + sub(/^ - name:[[:space:]]*/, "", step_name) + step_id = ""; step_if = ""; run_body = ""; in_run = 0 + next + } + in_run { + if ($0 ~ /^ / || $0 == "") { + line = $0 + sub(/^ /, "", line) + run_body = run_body line "\n" + next + } else { + in_run = 0 + } + } + /^ id:[[:space:]]*/ { + step_id = $0 + sub(/^ id:[[:space:]]*/, "", step_id) + next + } + /^ if:[[:space:]]*/ { + step_if = $0 + sub(/^ if:[[:space:]]*/, "", step_if) + gsub(/^[\x27"]|[\x27"]$/, "", step_if) + next + } + /^ run:[[:space:]]*\|$/ { + in_run = 1 + next + } + END { + flush_step() + if (gate_if != "inputs.block-on-high") { + print "blocking gate is not opt-in" > "/dev/stderr" + exit 1 + } + split("Relativize finding paths|Filter SARIF through the baseline before upload|Upload SARIF to code scanning|Upload findings artifacts", req, "|") + for (i in req) { + n = req[i] + if (!(n in seen)) { + print "missing step: " n > "/dev/stderr" + exit 1 + } + if (index(step_if_map[n], "always()") > 0) { + print n " must not use if: always() (standards#1050)" > "/dev/stderr" + exit 1 + } + } + } + ' "$repo/.github/workflows/hypatia-scan-reusable.yml" +fi export GITHUB_OUTPUT="$tmp/output" GITHUB_STEP_SUMMARY="$tmp/summary" cd "$tmp" check() { @@ -43,18 +123,20 @@ check() { fi printf 'PASS: %s\n' "$name" } -check 'empty findings refuse (fail-closed)' 2 '[]' -check 'low and informational findings pass' 0 '[{"severity":"low"},{"severity":"info"}]' +check 'clean scan (empty findings array) passes' 0 '[]' +check 'low, warn, and informational findings pass' 0 '[{"severity":"low"},{"severity":"warn"},{"severity":"info"}]' check 'high finding blocks' 1 '[{"severity":"high"}]' check 'critical finding blocks' 1 '[{"severity":"critical"}]' check 'missing artifact refuses' 2 MISSING check 'empty artifact refuses' 2 '' +check 'whitespace-only artifact refuses' 2 $' \n' check 'truncated JSON refuses' 2 '[{"severity":' check 'object is not a findings array' 2 '{}' check 'null is not a findings array' 2 'null' check 'unknown severity refuses' 2 '[{"severity":"unknown"}]' check 'missing severity refuses' 2 '[{}]' check 'multiple JSON documents refuse' 2 '[] []' +check 'newline-separated JSON documents refuse' 2 $'[]\n[{"severity":"high"}]' sarif_check() { local name=$1 expected=$2 payload=$3 actual @@ -91,3 +173,64 @@ export HYPATIA_BASELINE_FILTERED=true sarif_check 'valid baseline accepted' 0 "$clean" printf '%s' '{}' > .hypatia-baseline.json sarif_check 'malformed baseline refuses even without findings' 2 "$clean" +rm -f .hypatia-baseline.json hypatia-findings.relativized.json +unset HYPATIA_BASELINE_FILTERED + +# CLI adapter contract controls (standards#1054): empty, warn, high, and crash fixtures +mkdir -p "$tmp/home/hypatia" +cat > "$tmp/home/hypatia/hypatia-cli.sh" <<'CLI' +#!/usr/bin/env bash +set -euo pipefail +case "${FIXTURE_MODE:-}" in + empty) + if [[ "${HYPATIA_FORMAT:-}" = "json" ]]; then + printf '[]\n' + else + printf '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"Hypatia"}},"results":[]}]}\n' + fi + ;; + warn) + if [[ "${HYPATIA_FORMAT:-}" = "json" ]]; then + printf '[{"severity":"warn","rule_module":"research_extensions","type":"RE001","file":"ci.yml"}]\n' + else + printf '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"Hypatia"}},"results":[{"ruleId":"hypatia/RE001","level":"warning"}]}]}\n' + fi + ;; + high) + if [[ "${HYPATIA_FORMAT:-}" = "json" ]]; then + printf '[{"severity":"high","rule_module":"workflow_hardening","type":"WH001","file":"ci.yml"}]\n' + else + printf '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"Hypatia"}},"results":[{"ruleId":"hypatia/WH001","level":"error"}]}]}\n' + fi + ;; + crash) + printf '[]\n' + exit 1 + ;; +esac +CLI +chmod +x "$tmp/home/hypatia/hypatia-cli.sh" + +adapter_check() { + local name=$1 mode=$2 expected=$3 actual + rm -f hypatia-findings.json hypatia.sarif "$GITHUB_OUTPUT" "$GITHUB_STEP_SUMMARY" + if HOME="$tmp/home" FIXTURE_MODE="$mode" bash scan.sh >result.log 2>&1; then + if bash validate.sh >>result.log 2>&1; then + if bash gate.sh >>result.log 2>&1; then actual=0; else actual=$?; fi + else + actual=$? + fi + else + actual=$? + fi + if [[ "$actual" -ne "$expected" ]]; then + printf 'FAIL: %s: expected %s, got %s\n' "$name" "$expected" "$actual" + cat result.log + exit 1 + fi + printf 'PASS: %s\n' "$name" +} +adapter_check 'CLI adapter empty fixture produces zero-result SARIF and passes' empty 0 +adapter_check 'CLI adapter warn fixture produces warning SARIF and passes' warn 0 +adapter_check 'CLI adapter high fixture produces error SARIF and blocks' high 1 +adapter_check 'CLI adapter crash fixture fails closed even if [] was written' crash 1 diff --git a/scripts/tests/reconcile-scorecard-actions-lock-test.rb b/scripts/tests/reconcile-scorecard-actions-lock-test.rb index fedb9a09..152fac3d 100644 --- a/scripts/tests/reconcile-scorecard-actions-lock-test.rb +++ b/scripts/tests/reconcile-scorecard-actions-lock-test.rb @@ -17,7 +17,7 @@ def setup #!/bin/sh printf '%s\\n' "$*" >> invocation case "$*" in - 'actions-lock .github/workflows/ci.yml --verify --no-interactive --json=valid,findings') ;; + actions-lock\\ .github/workflows/*.yml\\ --verify\\ --no-interactive\\ --json=valid,findings) ;; *) exit 97 ;; esac printf '%s' "$TEST_LOCK_JSON" @@ -82,4 +82,79 @@ def test_workflow_symlinks_are_not_filtered assert_empty audit refute File.exist?(File.join(@root, 'invocation')) end + + def test_mixed_workflow_with_job_level_reusable_ref_is_not_rejected_as_stale + slsa_sha = 'f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + checkout_sha = '3d3c42e5aac5ba805825da76410c181273ba90b1' + File.write(File.join(@root, '.github/workflows/release.yml'), <<~YAML) + name: Release + on: [push] + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@#{checkout_sha} + provenance: + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@#{slsa_sha} # v2.1.0 + YAML + File.write(File.join(@root, '.github/workflows/actions.lock'), <<~YAML) + version: 'v0.0.2' + workflows: + '.github/workflows/release.yml': + - 'actions/checkout@#{checkout_sha}' + - 'slsa-framework/slsa-github-generator@#{slsa_sha}' + dependencies: + 'actions/checkout@#{checkout_sha}': + ref: 'v7.0.1' + commit: 'sha1-#{checkout_sha}' + 'slsa-framework/slsa-github-generator@#{slsa_sha}': + ref: 'v2.1.0' + commit: 'sha1-#{slsa_sha}' + YAML + ENV['TEST_LOCK_JSON'] = JSON.generate( + 'valid' => false, + 'findings' => [ + { + 'category' => 'stale', + 'severity' => 'warning', + 'workflow' => '.github/workflows/release.yml', + 'dependency' => "slsa-framework/slsa-github-generator@#{slsa_sha}" + } + ] + ) + ENV['TEST_LOCK_EXIT'] = '1' + + result, audit = ScorecardActionsLock.reconcile( + document(finding(path: '.github/workflows/release.yml', line: 7)), + @root + ) + assert_empty result['runs'][0]['results'] + assert_equal 1, audit.length + end + + def test_arm_d_job_level_reusable_ref_absent_from_lock_raises + missing_sha = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + File.write(File.join(@root, '.github/workflows/scorecard.yml'), <<~YAML) + name: Scorecard + on: [push] + jobs: + analysis: + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@#{missing_sha} + YAML + File.write(File.join(@root, '.github/workflows/actions.lock'), <<~YAML) + version: 'v0.0.2' + workflows: + '.github/workflows/scorecard.yml': [] + dependencies: {} + YAML + ENV['TEST_LOCK_JSON'] = '{"valid":true,"findings":[]}' + ENV['TEST_LOCK_EXIT'] = '0' + + assert_raises(StandardError) do + ScorecardActionsLock.reconcile( + document(finding(path: '.github/workflows/scorecard.yml', line: 5)), + @root + ) + end + end end diff --git a/scripts/tests/science-ci-security-test.rb b/scripts/tests/science-ci-security-test.rb index 37ec2aa3..edadd1dc 100644 --- a/scripts/tests/science-ci-security-test.rb +++ b/scripts/tests/science-ci-security-test.rb @@ -99,15 +99,25 @@ def workflow(name) output = File.join(tmp, 'output') env = { 'GITHUB_OUTPUT' => output, 'GITHUB_STEP_SUMMARY' => File.join(tmp, 'summary') } findings = File.join(tmp, 'hypatia-findings.json') - # The validation expects: [finding1, finding2, ...] - flat array of findings - # Use valid severities: critical, high, medium, low, info, informational + # Clean scan (single well-formed empty array) is a positive control (standards#1054) + File.write(findings, '[]') + run!(env, 'bash', '-c', step.fetch('run'), chdir: tmp) + assert(File.read(output).lines.map(&:chomp).include?('findings_count=0'), 'clean scan findings_count was not 0') + assert(File.read(output).lines.map(&:chomp).include?('critical=0'), 'clean scan critical was not 0') + assert(File.read(output).lines.map(&:chomp).include?('high=0'), 'clean scan high was not 0') + assert(File.read(output).lines.map(&:chomp).include?('medium=0'), 'clean scan medium was not 0') + + # The validation expects: [finding1, finding2, ...] - single flat array of findings + # Use valid severities: critical, high, medium, warn, low, info, informational + FileUtils.rm_f(output) File.write(findings, '[{"severity":"high"},{"severity":"medium"},{"severity":"critical"}]') run!(env, 'bash', '-c', step.fetch('run'), chdir: tmp) assert(File.read(output).lines.map(&:chomp).include?('medium=1'), 'medium was not counted') assert(File.read(output).include?('critical=1'), 'critical finding was lost') assert(File.read(output).include?('high=1'), 'high finding was lost') - # Test invalid inputs - flat array format - ['', '[', '[]', '[{}]', '[{"severity":"unknown"}]'].each do |invalid| + # Test invalid inputs - empty, whitespace, truncated, wrong top-level type, + # invalid element shape, unknown severity, and multiple JSON documents + ['', " \n", '[', '{}', 'null', '[{}]', '[{"severity":"unknown"}]', '[] []', "[]\n[{\"severity\":\"high\"}]"].each do |invalid| FileUtils.rm_f(output) File.write(findings, invalid) _out, _err, status = Open3.capture3(env, 'bash', '-c', step.fetch('run'), chdir: tmp) diff --git a/scripts/tests/triage-2026-09-29-apply-test.sh b/scripts/tests/triage-2026-09-29-apply-test.sh new file mode 100755 index 00000000..6b165748 --- /dev/null +++ b/scripts/tests/triage-2026-09-29-apply-test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# triage-2026-09-29-apply-test.sh — regression tests for scripts/triage-2026-09-29-apply.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SUT="$SCRIPT_DIR/../triage-2026-09-29-apply.sh" + +[ -x "$SUT" ] || { echo "FAIL: $SUT is not executable" >&2; exit 1; } +bash -n "$SUT" + +# 1. #658 must never be closed automatically before D10 on #787 is ruled. +if grep -E 'close_if_open[[:space:]]+658\b' "$SUT" >/dev/null; then + echo "FAIL: $SUT closes #658 before D10 on #787 is ruled" >&2 + exit 1 +fi +echo "PASS: #658 is held open pending D10 on #787" + +# 2. #708 must verify the lockfile-drift-report TSV before closing. +grep -q 'lockfile-drift-report' "$SUT" +grep -q '\[drift\] clean' "$SUT" +echo "PASS: #708 verifies artifact TSV before closing" + +# 3. --dry-run must execute cleanly against a stub `gh` that refuses any write verb. +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cat > "$WORK/gh" <<'SHIM' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + api) + case "${2:-}" in + repos/*/issues/*) printf 'open\n' ;; + repos/*/rules/branches/main) printf 'deletion,non_fast_forward,required_status_checks\n' ;; + repos/metadatastician/burble/rulesets/18225024) printf 'CodeQL\n' ;; + *) printf 'true\n' ;; + esac + ;; + *) + echo "FAIL: unexpected write call under --dry-run: gh $*" >&2 + exit 99 + ;; +esac +SHIM +chmod +x "$WORK/gh" + +PATH="$WORK:$PATH" bash "$SUT" --dry-run >/dev/null +echo "PASS: --dry-run performs zero gh write calls" diff --git a/scripts/triage-2026-09-29-apply.sh b/scripts/triage-2026-09-29-apply.sh new file mode 100755 index 00000000..24e49469 --- /dev/null +++ b/scripts/triage-2026-09-29-apply.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# triage-2026-09-29-apply.sh — Apply the 2026-09-29 issue-triage execution plan +# (see ULTRAPLAN-2026-09-29.adoc). +# +# Why this script exists: the Arena sandbox token is a fine-grained GitHub App +# installation token with contents:write + pull-requests:write, NOT issues:write +# or labels:write. Creating labels, commenting on issues, and closing resolved +# issues return HTTP 403 from inside the sandbox. Run this script once from a +# terminal where `gh auth status` shows an owner token with `repo` scope: +# +# bash scripts/triage-2026-09-29-apply.sh # apply live +# bash scripts/triage-2026-09-29-apply.sh --dry-run # print actions without writing +# +# Idempotent: safe to re-run; skips already-closed issues and updates existing +# labels in place. + +set -euo pipefail + +REPO="hyperpolymath/standards" +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + echo "[dry-run] No GitHub writes will be performed." +fi + +run_gh() { + if [ "$DRY_RUN" -eq 1 ]; then + printf '[dry-run] gh %s\n' "$*" + else + gh "$@" + fi +} + +echo "== Step 1: Creating scope, status, and cluster labels on $REPO ==" + +mk_label() { + local name="$1" color="$2" desc="$3" + echo " label: $name" + run_gh label create "$name" --repo "$REPO" --color "$color" --description "$desc" --force >/dev/null +} + +# Scope labels +mk_label "scope:this-repo" "1d76db" "Fixable by a PR to hyperpolymath/standards alone" +mk_label "scope:estate-wide" "d93f0b" "Requires fan-out or ruleset/settings changes across estate repos" +mk_label "scope:external" "5319e7" "Lives primarily in another repo (hypatia, git-scripts, panic-attack, etc.)" + +# Status labels +mk_label "status:needs-decision" "fbca04" "Blocked on an owner ruling (tracked in #787)" +mk_label "status:ready" "0e8a16" "Decision made; ready to execute" +mk_label "status:blocked" "b60205" "Blocked on another issue or external prerequisite" + +# Cluster labels +mk_label "cluster:c1-reusable-pins" "c5def5" "C1: Reusable workflow pin propagation & staleness" +mk_label "cluster:c2-actions-lock" "c5def5" "C2: actions.lock / SHA pinning / Dependabot" +mk_label "cluster:c3-hypatia-gate" "bfd4f2" "C3: Hypatia scanner & reusable workflow mechanics" +mk_label "cluster:c4-hypatia-rules" "bfd4f2" "C4: Hypatia findings & false-positive triage" +mk_label "cluster:c5-rulesets" "d4c5f9" "C5: Branch protection, tag rulesets & status-check gates" +mk_label "cluster:c6-scorecard" "d4c5f9" "C6: OSSF Scorecard & SARIF reconciliation" +mk_label "cluster:c7-language-policy" "fef2c0" "C7: Language policy, Deno->Bun &launcher-standard" +mk_label "cluster:c8-debt-ratchet" "fef2c0" "C8: Debtfile & ratchet mechanics" +mk_label "cluster:c9-deed-spec" "f9d0c4" "C9: DEED format specification" +mk_label "cluster:c10-a2ml-k9" "f9d0c4" "C10: A2ML / K9 / Nickel specifications & gates" +mk_label "cluster:c11-canon-spine" "f9d0c4" "C11: Canon spine, constitution & RSR docs" +mk_label "cluster:c12-codeql" "c2e0c6" "C12: CodeQL & security-gate workflows" +mk_label "cluster:c13-mirror" "c2e0c6" "C13: Mirror & instant-sync workflows" +mk_label "cluster:c14-ci-pipeline" "c2e0c6" "C14: ci-pipeline.yml & test-suite runners" +mk_label "cluster:c15-spdx-reuse" "e6e6e6" "C15: SPDX / REUSE / licence headers" +mk_label "cluster:c16-secrets-tokens" "f9c2ff" "C16: Tokens, PATs, GitHub App & secrets" +mk_label "cluster:c17-estate-census" "bfdadc" "C17: Estate-wide census & trackers" +mk_label "cluster:c18-decisions" "fbca04" "C18: Owner decision logs (#787)" +mk_label "cluster:c19-external-tools" "d876e3" "C19: External tool bugs (gh-actions-lock, panic-attack)" +mk_label "cluster:c20-archive-tests" "ededed" "C20: Archive / historical / test artefacts" +mk_label "cluster:c21-other" "ededed" "C21: Miscellaneous" + +echo "" +echo "== Step 2: Phase 0 — Closing verified-resolved and duplicate issues ==" + +close_if_open() { + local num="$1" comment="$2" + local state + state="$(gh api "repos/$REPO/issues/$num" --jq '.state')" + if [ "$state" = "open" ]; then + echo " closing #$num" + run_gh issue close "$num" --repo "$REPO" --comment "$comment" + else + echo " #$num already closed — skipping" + fi +} + +# 1. #1057 — empty "probe" test issue +run_gh issue edit 1057 --repo "$REPO" --add-label "invalid" >/dev/null || true +close_if_open 1057 "Closing empty probe issue (\`probe\`, empty body, no acceptance criteria) per \`ULTRAPLAN-2026-09-29.adoc\` Phase 0." + +# 2. #956 — verify live rules/branches/main on hyperpolymath/standards before closing +if gh api "repos/$REPO/rules/branches/main" --jq '[.[].type] | sort | join(",")' | grep -q 'required_status_checks'; then + n_checks="$(gh api "repos/$REPO/rules/branches/main" --jq '[.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]] | length')" + close_if_open 956 "Verified live on $(date -u +%Y-%m-%d) via \`gh api repos/hyperpolymath/standards/rules/branches/main\`: +- Active rules on \`main\`: \`deletion\`, \`non_fast_forward\`, \`required_status_checks\` (**${n_checks} required status contexts**), \`required_signatures\`, and \`code_scanning\` (\`CodeQL\`, \`Hypatia\`, \`Scorecard\` at \`errors\` / \`high_or_higher\`). +- Zero retired ruleset rule types remain. + +Closing as resolved and verified in production." +fi + +# 3. #708 — verify scheduled run 35700165587 artifact before closing +echo " verifying #708 lockfile-drift-detect scheduled run 35700165587..." +tmp_708="$(mktemp -d)" +if [ "$DRY_RUN" -eq 1 ]; then + echo " [dry-run] would download lockfile-drift-report from run 35700165587 and close #708" +elif gh run download 35700165587 --repo "$REPO" -n lockfile-drift-report -D "$tmp_708" 2>/dev/null; then + tsv_file="$(find "$tmp_708" -name '*.tsv' | head -1)" + if [ -n "$tsv_file" ] && ! grep -q '^_w' "$tsv_file" && ! grep -q '\[drift\] clean' "$tsv_file"; then + close_if_open 708 "Verified scheduled Tuesday cron run [\`35700165587\`](https://github.com/hyperpolymath/standards/actions/runs/35700165587) (\`2026-09-22T07:33:04Z\`, \`completed/success\`): +1. Tracker #803 was updated cleanly (\`scanned: 347\`, \`carrying a lockfile: 180\`, \`with drift: 15\`, \`check errors (rc≠0,1): 0\`, \`drifted entries: 29\`). +2. Downloaded artifact \`lockfile-drift-report\` (\`id: 10682326843\`) and verified the TSV contains real \`owner/repo\` slugs (zero \`_w\` rows) and zero \`[drift] clean\` stdout banner rows." + else + echo " WARNING: #708 artifact check failed — leaving #708 open" >&2 + fi +else + echo " WARNING: could not download artifact from run 35700165587 — leaving #708 open" >&2 +fi +rm -rf "$tmp_708" + +# 4. #1013 — verify metadatastician org ruleset 18225024 + canonical-ums archived +if gh api "repos/metadatastician/burble/rulesets/18225024" --jq '[.rules[] | select(.type=="code_scanning") | .parameters.code_scanning_tools[].tool] | join(",")' | grep -qx 'CodeQL'; then + close_if_open 1013 "Verified live on $(date -u +%Y-%m-%d): +- Organization ruleset \`EstateBranching\` (\`id: 18225024\` on \`metadatastician\`) carries \`code_scanning: [CodeQL (errors / high_or_higher)]\` only (\`Hypatia\` and \`Scorecard\` removed on 2026-09-22), curing the 68 inheriting \`metadatastician/*\` repositories. +- \`hyperpolymath/canonical-ums\` was trimmed and re-archived (\`archived: true\`, \`2026-09-22T21:52:27Z\`). + +Closing as verified complete." +fi + +# 5. #1005 — AC1 decided and AC2 40/40 PRs merged; re-bumps tracked on #1037 +close_if_open 1005 "Both acceptance criteria are complete: +- **AC1**: Ruled to keep \`github/codeql-action\` \`v4.38.1\` (\`1c5b6756f7f1ab9f5bde6bbb02dbcebd0fffd908\`) blocked and re-pin to \`b96794f015dfd88f77b49b1c93e0fa7110f94c63\` (\`v4.38.0\`). +- **AC2**: Executed across all 40 live repositories (94 workflow refs, 52 lock lines; 40/40 PRs merged; 0 live \`@1c5b6756\` or \`@v4.38.1\` remain). +- Subsequent Dependabot grouped \`actions\` re-bumps (caused by missing trailing \`*\` on \`dependency-name: \"github/codeql-action*\"\`) are tracked on #1037." + +# 6. #1010 — verify all 13 Population-A PRs (11 public + 2 private) are MERGED before closing +pop_a_prs=( + "hyperpolymath/proven-servers:90" + "hyperpolymath/boj-server-mk2:47" + "hyperpolymath/cadastra:53" + "hyperpolymath/common-signal:7" + "hyperpolymath/consent-aware-web:10" + "hyperpolymath/harvard-dehallucinator:18" + "hyperpolymath/paint-type:89" + "hyperpolymath/_pathroot:29" + "hyperpolymath/pong-ping:7" + "hyperpolymath/project-ovine:26" + "hyperpolymath/sim-public-relations:24" + "hyperpolymath/sr71-blackglider:20" + "hyperpolymath/stapeln:75" +) +unmerged_1010=0 +for item in "${pop_a_prs[@]}"; do + r="${item%%:*}"; p="${item##*:}" + if [ "$DRY_RUN" -eq 0 ]; then + merged="$(gh api "repos/$r/pulls/$p" --jq '.merged' 2>/dev/null || echo "false")" + [ "$merged" = "true" ] || unmerged_1010=$((unmerged_1010 + 1)) + fi +done +if [ "$unmerged_1010" -eq 0 ]; then + close_if_open 1010 "Verified all 13 Population-A PRs (\`proven-servers#90\`, \`boj-server-mk2#47\`, \`cadastra#53\`, \`common-signal#7\`, \`consent-aware-web#10\`, \`harvard-dehallucinator#18\`, \`paint-type#89\`, \`_pathroot#29\`, \`pong-ping#7\`, \`project-ovine#26\`, \`sim-public-relations#24\`, \`sr71-blackglider#20\`, \`stapeln#75\`) are **MERGED**, and the remaining 6 repositories are Population B (handled under #1013). Closing as complete." +else + echo " WARNING: $unmerged_1010 Population-A PR(s) on #1010 not yet merged — leaving #1010 open" >&2 +fi + +# 7. Duplicate decision trackers (#637, #709, #715 -> #787) +close_if_open 637 "Superseded by the single consolidated owner-decision tracker #787 (which carries all 10 original decisions from this issue plus subsequent wave items). Closing as duplicate of #787." +close_if_open 709 "Superseded by the single consolidated owner-decision tracker #787 (all open rows from this tracker are carried in #787). Closing as duplicate of #787." +close_if_open 715 "Superseded by the single consolidated owner-decision tracker #787 (Wave-6 residual decisions are carried in #787). Closing as duplicate of #787." + +# 8. #784 -> answered by #968 (git-scripts#58 merged) +close_if_open 784 "Resolved by owner decision in #968 and merged upstream in \`hyperpolymath/git-scripts#58\`. Closing as completed." + +# 9. #808 -> copy unique startup-death / zero-jobs mis-triage evidence onto #913, then close #808 +if [ "$(gh api "repos/$REPO/issues/808" --jq '.state')" = "open" ]; then + run_gh issue comment 913 --repo "$REPO" --body "Carrying forward unique failure-mode evidence from #808 before closing #808 as subsumed by #913: +- When a reusable workflow fails at startup (zero jobs created, \`conclusion: startup_failure\` or \`failure\` with \`jobs: []\`), naive triage scripts that inspect only job steps misclassify the run or miss the caller permission/ref defect. +- Ensure any #913 propagation/verification script checks workflow-run startup failure (\`jobs_count == 0\`) explicitly." + close_if_open 808 "Unique startup-failure / zero-jobs mis-triage evidence has been copied to #913; the underlying \`security-events: write\` caller fix across the 4 repos is tracked in #913. Closing as subsumed by #913." +fi + +# NOTE: #658 is intentionally NOT closed here — it remains open until D10 on #787 is ruled. + +echo "" +echo "== Step 3: Applying scope, status, and cluster labels to recent issues (#1028-#1061) ==" + +label_issue() { + local num="$1"; shift + local args=() + for lbl in "$@"; do + args+=(--add-label "$lbl") + done + echo " #$num -> $*" + run_gh issue edit "$num" --repo "$REPO" "${args[@]}" >/dev/null || true +} + +label_issue 1028 "scope:estate-wide" "cluster:c5-rulesets" "status:ready" +label_issue 1031 "scope:estate-wide" "cluster:c5-rulesets" "status:ready" +label_issue 1032 "scope:this-repo" "cluster:c5-rulesets" "status:ready" +label_issue 1035 "scope:estate-wide" "cluster:c6-scorecard" "status:ready" +label_issue 1036 "scope:this-repo" "cluster:c6-scorecard" "status:ready" +label_issue 1037 "scope:estate-wide" "cluster:c2-actions-lock" "status:ready" +label_issue 1040 "scope:this-repo" "cluster:c5-rulesets" "status:ready" +label_issue 1050 "scope:this-repo" "cluster:c3-hypatia-gate" "status:ready" +label_issue 1054 "scope:this-repo" "cluster:c3-hypatia-gate" "status:ready" +label_issue 1055 "scope:estate-wide" "cluster:c17-estate-census" "status:needs-decision" +label_issue 1056 "scope:this-repo" "cluster:c11-canon-spine" "status:needs-decision" +label_issue 1058 "scope:this-repo" "cluster:c9-deed-spec" "status:needs-decision" +label_issue 1059 "scope:estate-wide" "cluster:c11-canon-spine" "status:needs-decision" + +echo "" +echo "Done. All Phase 0 closes and recent-issue labels applied." diff --git a/scripts/update-actions-lock.sh b/scripts/update-actions-lock.sh index aa5e59d5..71663a84 100755 --- a/scripts/update-actions-lock.sh +++ b/scripts/update-actions-lock.sh @@ -102,7 +102,7 @@ verify_lock_coverage() { status=$? set -e - if ! printf '%s' "$result" | jq -e '.valid != null and (.findings | type == "array")' >/dev/null 2>&1; then + if [ -z "$result" ] || ! printf '%s' "$result" | jq -e -s 'length == 1 and (.[0] | type == "object" and .valid != null and (.findings | type == "array"))' >/dev/null 2>&1; then printf '%s\n' "$result" [[ "$status" -ne 0 ]] && return "$status" return 1 diff --git a/tests/test_tag_ruleset_canon.sh b/tests/test_tag_ruleset_canon.sh index 506c9b46..53ade99b 100755 --- a/tests/test_tag_ruleset_canon.sh +++ b/tests/test_tag_ruleset_canon.sh @@ -79,17 +79,21 @@ jq -e '.enforcement == "active"' "$CANON" >/dev/null 2>&1 \ && ok "canon enforcement is active" || bad "canon is not actively enforced" # --- 2. no case-colliding tag-~ALL sibling ---------------------------------- +# Note: config/rulesets/tag-floor.json (ruling D94) is the creation-free base +# floor (deletion + non_fast_forward only). Immutable-tags canon files restrict +# `creation`; at most one such file may exist (`Immutable-Tags.json` vs +# `immutable-tags.json` is the case-collision this check guards against). tagfiles=() for f in "$RULESET_DIR"/*.json; do [ -e "$f" ] || continue - if jq -e '.target == "tag" and (.conditions.ref_name.include == ["~ALL"])' "$f" >/dev/null 2>&1; then + if jq -e '.target == "tag" and (.conditions.ref_name.include == ["~ALL"]) and ([.rules[].type] | index("creation") != null)' "$f" >/dev/null 2>&1; then tagfiles+=("$(basename "$f")") fi done if [ "${#tagfiles[@]}" -eq 1 ]; then - ok "exactly one tag/~ALL ruleset file: ${tagfiles[0]}" + ok "exactly one immutable-tags (creation-restricting) tag/~ALL ruleset file: ${tagfiles[0]}" else - bad "${#tagfiles[@]} tag/~ALL ruleset files (${tagfiles[*]-none}) — a case-collision here deployed the wrong body for four days" + bad "${#tagfiles[@]} immutable-tags tag/~ALL ruleset files (${tagfiles[*]-none}) — a case-collision here deployed the wrong body for four days" fi # --- 3..9. applier guards --------------------------------------------------- @@ -335,6 +339,37 @@ else fi rm -f "$MUT_T" +# --- Property 14: .source_type == "Repository" enforced before PUT (#1032) --- +source_type_guard_holds() { + local f="$1" lref lrepo lput + lref=$(grep -nF 'REFUSED-NO-SOURCE-TYPE' "$f" | head -1 | cut -d: -f1) + lrepo=$(grep -nF '.source_type=="Repository" and .target=="tag"' "$f" | head -1 | cut -d: -f1) + lput=$(grep -nF -- '--method PUT "repos/$repo/rulesets/$id"' "$f" | head -1 | cut -d: -f1) + [ -n "$lref" ] && [ -n "$lrepo" ] && [ -n "$lput" ] || return 1 + [ "$lref" -lt "$lrepo" ] && [ "$lrepo" -lt "$lput" ] || return 1 + grep -qF 'ORG-INHERITED' "$f" || return 1 + return 0 +} + +if source_type_guard_holds "$APPLIER"; then + ok "applier checks .source_type == \"Repository\" before PUT and fails closed on missing .source_type (#1032)" +else + bad "applier does not enforce .source_type == \"Repository\" before PUT (#1032)" +fi + +MUT_S="$(mktemp)" +sed 's/\.source_type=="Repository" and //' "$APPLIER" > "$MUT_S" +if cmp -s "$MUT_S" "$APPLIER"; then + bad "property 14 mutant was not applied" +elif ! bash -n "$MUT_S" 2>/dev/null; then + bad "property 14 mutant is not valid bash" +elif source_type_guard_holds "$MUT_S"; then + bad "property 14 detector passes an applier with .source_type==\"Repository\" stripped" +else + ok "property 14 mutant killed: stripping .source_type==\"Repository\" fails the detector" +fi +rm -f "$MUT_S" + echo "---" echo "passed=$pass failed=$fail" [ "$fail" -eq 0 ]