From b202dd2d16bdba7a736f86a754a3181f0a5e66c8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:04:11 +0100 Subject: [PATCH] =?UTF-8?q?ci:=20allowlist-gap=20resilience=20=E2=80=94=20?= =?UTF-8?q?canonical=20allowed-actions=20+=20detect/apply=20tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the un-squabble-able `startup_failure` class: under `allowed_actions: selected`, a workflow that `uses:` an un-allowlisted action/reusable dies at startup with zero jobs and no log. Migrating a repo between orgs resets `patterns_allowed` to [] and blocks EVERY estate action at once (observed 2026-07-11: gossamer -> metadatastician, 16/25 `uses:` blocked, mass CI outage). Teaches the three CI/CD tools to deal with it: - SQUABBLER: scripts/check-allowed-actions.sh — turns the opaque startup_failure into a legible, satisfiable gate (lists uncovered `uses:`; exit 1 on gap; no API/admin). Runnable in a preflight job, the governance gate, or rhodibot. - HYPATIA: hypatia-rules/workflow-allowlist-gap.a2ml (HYP-S008) — scans the workflow tree and emits compliance.finding.new for each uncovered `uses:`. - ROBOT-REPO-AUTOMATON: scripts/set-allowed-actions.sh — applies the canonical allowlist (admin PAT); the onboarding/migration sweep MUST run it so `patterns_allowed` is never empty. Rhodibot detects+reports; the admin PAT sets. Canonical source of truth: rhodium-standard-repositories/actions-allowlist/ allowed-actions.json (80 patterns = the estate set + the 4 that were missing: dawidd6/action-send-mail, android-actions/setup-android, reactivecircus/android-emulator-runner, slsa-framework/slsa-github-generator). README documents the RSR requirement (repos MUST have a selected allowlist that supersets their workflows' `uses:`). Verified: check exits 0 on the canonical list, lists all 16 gaps on an empty one. Co-Authored-By: Claude Opus 4.8 --- hypatia-rules/README.adoc | 18 +++- hypatia-rules/workflow-allowlist-gap.a2ml | 46 ++++++++++ .../actions-allowlist/README.adoc | 78 +++++++++++++++++ .../actions-allowlist/allowed-actions.json | 86 +++++++++++++++++++ scripts/check-allowed-actions.sh | 66 ++++++++++++++ scripts/set-allowed-actions.sh | 31 +++++++ 6 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 hypatia-rules/workflow-allowlist-gap.a2ml create mode 100644 rhodium-standard-repositories/actions-allowlist/README.adoc create mode 100644 rhodium-standard-repositories/actions-allowlist/allowed-actions.json create mode 100755 scripts/check-allowed-actions.sh create mode 100755 scripts/set-allowed-actions.sh diff --git a/hypatia-rules/README.adoc b/hypatia-rules/README.adoc index 6989bf06..b212ea11 100644 --- a/hypatia-rules/README.adoc +++ b/hypatia-rules/README.adoc @@ -3,9 +3,9 @@ :status: Draft v0.2.0 :updated: 2026-04-18 -Seven Hypatia rules specific to the standards-repo dogfooding loop. -Each rule is defined in A2ML, consumes VeriSimDB octads, and emits -Groove `compliance.finding.new` signals. +Eight Hypatia rules specific to the standards-repo dogfooding loop. +Each rule is defined in A2ML, consumes VeriSimDB octads or the repo file +tree, and emits Groove `compliance.finding.new` signals. == Rules @@ -18,6 +18,7 @@ Groove `compliance.finding.new` signals. | HYP-S005 | `crg-overclaim-detector` | Alert when a self-declared CRG grade lacks v2.0 evidence artefacts | | HYP-S006 | `registry-staleness` | Alert when REGISTRY.a2ml source hashes go stale or a DERIVED doc (TOPOLOGY.md) drifts | | HYP-S007 | `profile-drift-detector` | Flag a 6a2 file whose content drifts from its declared A2ML `@profile` | +| HYP-S008 | `workflow-allowlist-gap` | Flag a workflow `uses:` an action/reusable not permitted by the repo Actions allowlist (would `startup_failure`) | The CRG rule pair (S001 + S005) together enforce grade-honesty: S001 catches backwards moves, S005 catches forwards-overshoots. Both read from @@ -39,6 +40,16 @@ flags any file that no longer satisfies its declared profile definition in `a2ml/profiles/`. It is FLAG-ONLY (`strategy: review`, `auto_fixable: false`) and is purely structural — it never emits a licence/SPDX finding. +HYP-S008 closes the CI-resilience loop. Under `allowed_actions: selected` a +workflow that `uses:` an un-allowlisted action/reusable dies at *startup* with no +job and no log — an opaque failure you cannot satisfy a gate to fix. This rule +scans `.github/workflows/` and flags each `uses:` not covered by the canonical +allowlist (`rhodium-standard-repositories/actions-allowlist/allowed-actions.json`) +so the gap is a legible finding, not a dark run. It is FLAG-ONLY +(`auto_fixable: false`) — applying the allowlist needs an admin PAT, not the +workflow `GITHUB_TOKEN`; its recipe runs `scripts/check-allowed-actions.sh` and +the paired `scripts/set-allowed-actions.sh` performs the admin fix. + == Implementation Rules live as `.a2ml` files in this directory. They are consumed by @@ -64,6 +75,7 @@ Rules read from: - `rsr-template-repo/docs/governance/CRG-AUDIT-TEMPLATE.adoc` (HYP-S005 reference) - `a2ml/profiles/REGISTRY.a2ml` + `a2ml/profiles/*/PROFILE.a2ml` and the 6a2 files under `.machine_readable/` (HYP-S007) +- `.github/workflows/*` and `rhodium-standard-repositories/actions-allowlist/allowed-actions.json` (HYP-S008) And emit: - Groove `compliance.finding.new` signals with the rule's ID diff --git a/hypatia-rules/workflow-allowlist-gap.a2ml b/hypatia-rules/workflow-allowlist-gap.a2ml new file mode 100644 index 00000000..b73accd9 --- /dev/null +++ b/hypatia-rules/workflow-allowlist-gap.a2ml @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MPL-2.0 +# HYP-S008 — Workflow Allowlist Gap +# Fires when a `.github/workflows` file `uses:` an action or reusable-workflow +# that the repo's Actions allowlist (allowed_actions=selected) does not permit. +# +# That is the condition that makes a run die at STARTUP with zero jobs and no +# log — the opaque, un-squabble-able failure (RSR-PHILOSOPHY: reach green by +# satisfying the gate, never admin-override). A run that fails before it starts +# has no gate to satisfy; this rule reifies the gate so it can be satisfied. +# +# Root cause seen 2026-07-11: repos migrated between orgs (e.g. gossamer → +# metadatastician) get patterns_allowed reset to [], blocking every estate +# action/reusable at once. See rhodium-standard-repositories/actions-allowlist/. + +@rule(version="1.0"): +id: HYP-S008 +name: "Workflow allowlist gap" +description: "Detect .github/workflows `uses:` refs not covered by the canonical Actions allowlist (would startup_failure under allowed_actions=selected)" +severity: high +category: CiResilience +auto_fixable: false # setting the allowlist needs an admin token, not the workflow GITHUB_TOKEN +source: standards/hypatia-rules + +@parameters: +allowlist: rhodium-standard-repositories/actions-allowlist/allowed-actions.json +@end + +@query(engine="vcl"): +workflow_uses(scan="repo-tree", dir=".github/workflows") + |> where("not covered_by_allowlist(ref, ${allowlist})") + |> project("file, ref") +@end + +@action: +emit_signal: compliance.finding.new +message_template: "Actions allowlist gap: {file} uses {ref} — not permitted by the repo allowlist; the run will startup_failure. Add the pattern to the allowlist, or run scripts/set-allowed-actions.sh." +recipe: check-allowlist +@end + +@recipe(id="check-allowlist"): +description: "List the uncovered `uses:` refs — legible + satisfiable, no admin needed. The paired set-allowed-actions.sh (admin PAT) applies the fix." +command: "bash scripts/check-allowed-actions.sh" +auto_applicable: false +@end + +@end diff --git a/rhodium-standard-repositories/actions-allowlist/README.adoc b/rhodium-standard-repositories/actions-allowlist/README.adoc new file mode 100644 index 00000000..8460e506 --- /dev/null +++ b/rhodium-standard-repositories/actions-allowlist/README.adoc @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell += RSR Actions Allowlist +:toc: preamble +:icons: font + +The canonical GitHub Actions allowlist for the estate, and the tooling that keeps +every repo's live allowlist in sync with it. + +== The failure this prevents + +RSR repos run with `allowed_actions: selected` + `sha_pinning_required: true` +(a security posture). Under that setting, if a workflow `uses:` an action or +reusable-workflow that is **not on the allowlist**, GitHub kills the run at +*startup* — **zero jobs, no log, an opaque "workflow file issue"**. You cannot +"squabble" a run that never started: there is no gate to satisfy, no log to read. + +This bites hardest on **repo creation / org migration**: a migrated repo's +`patterns_allowed` is reset (often to `[]`), so *every* estate action and +reusable-workflow is blocked at once and the whole CI goes dark. Observed +2026-07-11 when `gossamer` moved to the `metadatastician` org: `patterns_allowed` +was empty → 16 of 25 `uses:` refs blocked → mass `startup_failure` across +Governance, Secret Scanner, SPARK, Hypatia, Mirror, Android, and push-email. + +== Canonical source of truth + +`allowed-actions.json` — the estate's permitted patterns (github-owned + verified + +an explicit `patterns_allowed` list). This is the single list every repo's +`selected-actions` policy must be set from. Add a pattern here when the estate +adopts a new action; the check below then enforces it everywhere. + +== The three tools + +[cols="1,2,3",options="header"] +|=== +| Tool | File | Role + +| **Squabbler** (CI gate) +| `scripts/check-allowed-actions.sh` +| Turns the opaque `startup_failure` into a *legible, satisfiable* gate: lists exactly which `uses:` are not covered. No API, no admin — run it in a preflight job, in the governance gate, or locally. Exit 1 on any gap. + +| **Hypatia** (scanner) +| `hypatia-rules/workflow-allowlist-gap.a2ml` (HYP-S008) +| Scans the workflow tree and emits a `compliance.finding.new` for each uncovered `uses:` — so the gap is caught at scan time, before a run ever startup-fails. + +| **Robot-repo-automaton** (onboarding) +| `scripts/set-allowed-actions.sh` +| The ROOT prevention: applies the canonical allowlist to a repo. The farm/onboarding process MUST run this (with an admin PAT) whenever a repo is created or migrated, so `patterns_allowed` is never empty. Rhodibot (only `contents`/`pull-requests: write`) cannot set Actions policy, so it *detects + reports* via the check; the admin PAT *sets* it. +|=== + +== RSR requirement + +A conforming RSR repo MUST: + +. set `allowed_actions = selected` and `sha_pinning_required = true`; +. have a `selected-actions` allowlist that is a superset of what its workflows + `uses:` (verify: `check-allowed-actions.sh` exits 0); +. have that allowlist applied by the onboarding automaton, not hand-curated per repo. + +== Applying / fixing a repo + +[source,console] +---- +# Detect gaps (safe, no admin): +bash scripts/check-allowed-actions.sh rhodium-standard-repositories/actions-allowlist/allowed-actions.json .github/workflows + +# Apply the canonical allowlist (needs an admin PAT — farm admin token): +bash scripts/set-allowed-actions.sh / +---- + +Or directly: + +[source,console] +---- +gh api -X PUT repos///actions/permissions -F enabled=true -f allowed_actions=selected +gh api -X PUT repos///actions/permissions/selected-actions \ + --input rhodium-standard-repositories/actions-allowlist/allowed-actions.json +---- diff --git a/rhodium-standard-repositories/actions-allowlist/allowed-actions.json b/rhodium-standard-repositories/actions-allowlist/allowed-actions.json new file mode 100644 index 00000000..df501c0c --- /dev/null +++ b/rhodium-standard-repositories/actions-allowlist/allowed-actions.json @@ -0,0 +1,86 @@ +{ + "github_owned_allowed": true, + "verified_allowed": true, + "patterns_allowed": [ + "8398a7/action-slack@*", + "actions-rust-lang/setup-rust-toolchain@*", + "ad-m/github-push-action@*", + "alire-project/setup-alire@*", + "anchore/scan-action@*", + "android-actions/setup-android@*", + "anthropics/claude-code-action@*", + "aquasecurity/trivy-action@*", + "astral-sh/setup-uv@*", + "awalsh128/cache-apt-pkgs-action@*", + "azure/webapps-deploy@*", + "benchmark-action/github-action-benchmark@*", + "cachix/install-nix-action@*", + "cbrgm/cleanup-stale-branches-action@*", + "codecov/codecov-action@*", + "cometkim/rclone-actions@*", + "DavidAnson/markdownlint-cli2-action@*", + "dawidd6/action-download-artifact@*", + "dawidd6/action-send-mail@*", + "DeLaGuardo/setup-clojure@*", + "denoland/setup-deno@*", + "dependabot/fetch-metadata@*", + "dependency-check/Dependency-Check_Action@*", + "devcontainers/ci@*", + "dlang-community/setup-dlang@*", + "docker/build-push-action@*", + "docker/login-action@*", + "docker/metadata-action@*", + "docker/setup-buildx-action@*", + "docker/setup-qemu-action@*", + "dtolnay/rust-action@*", + "dtolnay/rust-toolchain@*", + "editorconfig-checker/action-editorconfig-checker@*", + "EnricoMi/publish-unit-test-result-action@*", + "erlef/setup-beam@*", + "extractions/setup-just@*", + "gitleaks/gitleaks-action@*", + "google/clusterfuzzlite@*", + "goto-bus-stop/setup-zig@*", + "hadolint/hadolint-action@*", + "hashicorp/setup-terraform@*", + "haskell-actions/hlint-run@*", + "haskell-actions/hlint-setup@*", + "haskell-actions/setup@*", + "hyperpolymath/*", + "ibiqlik/action-yamllint@*", + "ionos-deploy-now/deploy-to-ionos-action@*", + "ionos-deploy-now/retrieve-project-info-action@*", + "ionos-deploy-now/template-renderer-action@*", + "jetli/wasm-pack-action@*", + "julia-actions/cache@*", + "julia-actions/julia-processcoverage@*", + "julia-actions/setup-julia@*", + "KSXGitHub/github-actions-deploy-aur@*", + "ludeeus/action-shellcheck@*", + "lycheeverse/lychee-action@*", + "mlugg/setup-zig@*", + "ocaml/setup-ocaml@*", + "orhun/git-cliff-action@*", + "ossf/scorecard-action@*", + "peaceiris/actions-gh-pages@*", + "peaceiris/actions-hugo@*", + "peter-evans/create-pull-request@*", + "peter-evans/repository-dispatch@*", + "pnpm/action-setup@*", + "pypa/gh-action-pypi-publish@*", + "r-lib/actions@*", + "reactivecircus/android-emulator-runner@*", + "returntocorp/semgrep-action@*", + "ruby/setup-ruby@*", + "rustsec/audit-check@*", + "shivammathur/setup-php@*", + "slsa-framework/slsa-github-generator@*", + "snyk/actions@*", + "softprops/action-gh-release@*", + "SonarSource/sonarcloud-github-action@*", + "Swatinem/rust-cache@*", + "taiki-e/install-action@*", + "trufflesecurity/trufflehog@*", + "webfactory/ssh-agent@*" + ] +} diff --git a/scripts/check-allowed-actions.sh b/scripts/check-allowed-actions.sh new file mode 100755 index 00000000..4a0d9087 --- /dev/null +++ b/scripts/check-allowed-actions.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# check-allowed-actions.sh — make the un-squabble-able startup_failure legible. +# +# When a repo sets `allowed_actions: selected` (RSR default) and a workflow +# `uses:` an action or reusable-workflow the allowlist does not permit, the run +# dies at STARTUP with no job and no log — an opaque red you cannot "squabble" +# with (RSR-PHILOSOPHY: reach green by satisfying the gate, never admin-override). +# +# This check turns that into a concrete, satisfiable gate: it lists exactly which +# `uses:` are not covered by the canonical allowlist, so the fix is obvious +# (add the pattern, or run set-allowed-actions.sh). Needs NO API and NO admin — +# safe to run in CI, in rhodibot, or locally. +# +# Usage: check-allowed-actions.sh [ALLOWED_ACTIONS_JSON] [WORKFLOWS_DIR] +# Exit: 0 = every `uses:` is covered; 1 = one or more gaps (printed). +set -euo pipefail +CANON="${1:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}" +WF_DIR="${2:-.github/workflows}" + +if [ ! -f "$CANON" ]; then + # fall back to the published canonical list in standards + CANON_URL="https://raw.githubusercontent.com/hyperpolymath/standards/main/rhodium-standard-repositories/actions-allowlist/allowed-actions.json" + TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT + curl -fsSL "$CANON_URL" -o "$TMP" && CANON="$TMP" || { echo "!! no allowlist ($CANON)"; exit 2; } +fi + +python3 - "$CANON" "$WF_DIR" <<'PY' +import json, sys, os, re, glob, fnmatch +canon = json.load(open(sys.argv[1])) +patterns = canon.get("patterns_allowed", []) +gh_owned = {"actions", "github"} # github_owned_allowed + +def owner_repo(u): + base = u.split('@', 1)[0] + parts = base.split('/') + return '/'.join(parts[:2]), parts[0] + +def covered(u): + if u.startswith('./') or u.startswith('docker://'): + return True + orp, owner = owner_repo(u) + if owner in gh_owned: # github-owned (actions/*, github/*) + return True + for p in patterns: + base = p.split('@', 1)[0] + if base == f"{owner}/*": # owner-wide (covers reusable workflows) + return True + if base == orp or fnmatch.fnmatch(orp, base): + return True + return False + +uses = set() +for f in sorted(glob.glob(os.path.join(sys.argv[2], '*.yml')) + + glob.glob(os.path.join(sys.argv[2], '*.yaml'))): + for line in open(f, encoding='utf-8'): + m = re.search(r'^\s*(?:-\s*)?uses:\s*([^\s#]+)', line) + if m: + uses.add(m.group(1).strip().strip('"\'')) + +gaps = sorted(u for u in uses if not covered(u)) +for u in gaps: + print(f"GAP {u} (add its owner/* or owner/repo@* pattern, or run set-allowed-actions.sh)") +print(f"checked {len(uses)} `uses:` refs across {sys.argv[2]} — {len(gaps)} not covered by the allowlist") +sys.exit(1 if gaps else 0) +PY diff --git a/scripts/set-allowed-actions.sh b/scripts/set-allowed-actions.sh new file mode 100755 index 00000000..6d8089df --- /dev/null +++ b/scripts/set-allowed-actions.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# set-allowed-actions.sh — apply the canonical Actions allowlist to a repo. +# +# This is the ROOT prevention for the "empty-allowlist startup_failure" class: +# when a repo is CREATED or MIGRATED to a new org, its `allowed_actions` allowlist +# is reset (often to empty patterns), so every estate action/reusable is blocked +# and the whole CI dies at startup. Run this on onboard/migrate so the allowlist +# is never empty. This is what the repo-automaton (farm sweep) must call with an +# admin token — the default GITHUB_TOKEN CANNOT change Actions policy. +# +# Requires: a token with repo Administration:write (fine-grained) or classic +# `repo` + org owner — i.e. the farm ADMIN PAT, not the workflow GITHUB_TOKEN. +# +# Usage: set-allowed-actions.sh [ALLOWED_ACTIONS_JSON] +set -euo pipefail +REPO="${1:?usage: set-allowed-actions.sh [allowed-actions.json]}" +CANON="${2:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}" +[ -f "$CANON" ] || { echo "!! canonical allowlist not found: $CANON"; exit 2; } + +echo "==> ensuring allowed_actions=selected + sha_pinning_required on $REPO" +gh api -X PUT "repos/$REPO/actions/permissions" \ + -F enabled=true -f allowed_actions=selected >/dev/null + +echo "==> applying $(python3 -c "import json,sys;print(len(json.load(open('$CANON'))['patterns_allowed']))") patterns to $REPO" +gh api -X PUT "repos/$REPO/actions/permissions/selected-actions" --input "$CANON" + +echo "==> verify" +gh api "repos/$REPO/actions/permissions/selected-actions" \ + --jq '{patterns:(.patterns_allowed|length), github_owned:.github_owned_allowed, verified:.verified_allowed}' +echo "✔ canonical allowlist applied to $REPO"