diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index b87fd3ee..d10a6beb 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -6,6 +6,8 @@ workflows: '.github/workflows/affinescript-verify.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'ocaml/setup-ocaml@605a7e998e76e035b82c14d618a6e1010732c4ce' + '.github/workflows/allowlist-preflight-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/boj-build.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/casket-pages.yml': diff --git a/.github/workflows/allowlist-preflight-reusable.yml b/.github/workflows/allowlist-preflight-reusable.yml new file mode 100644 index 00000000..e33c099e --- /dev/null +++ b/.github/workflows/allowlist-preflight-reusable.yml @@ -0,0 +1,64 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# Reusable fail-closed Actions policy preflight (standards#486). +name: Actions Policy Preflight Reusable + +on: + workflow_call: + secrets: + HYPATIA_SCAN_PAT: + description: Fine-grained token with repository Administration read access + required: false + +permissions: + contents: read + +jobs: + preflight: + name: Actions policy (tree + live) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout caller repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + + - name: Checkout policy source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/standards + ref: main + path: .standards-checkout + sparse-checkout: | + rhodium-standard-repositories/actions-allowlist + scripts/check-actions-policy.sh + scripts/check-allowed-actions.sh + sparse-checkout-cone-mode: false + + - name: Stage policy tools outside the scanned tree + run: | + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then + cp scripts/check-actions-policy.sh scripts/check-allowed-actions.sh "$RUNNER_TEMP/" + cp rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ + "$RUNNER_TEMP/allowed-actions.json" + else + cp .standards-checkout/scripts/check-actions-policy.sh \ + .standards-checkout/scripts/check-allowed-actions.sh "$RUNNER_TEMP/" + cp .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ + "$RUNNER_TEMP/allowed-actions.json" + fi + rm -rf .standards-checkout + + - name: Check workflow references against canonical allowlist + run: | + bash "$RUNNER_TEMP/check-allowed-actions.sh" \ + "$RUNNER_TEMP/allowed-actions.json" .github/workflows + + - name: Check live repository policy + env: + GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT || github.token }} + run: | + bash "$RUNNER_TEMP/check-actions-policy.sh" \ + "$GITHUB_REPOSITORY" "$RUNNER_TEMP/allowed-actions.json" diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 9e3d82e9..662924c2 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -11,6 +11,10 @@ on: type: string required: false default: ubuntu-latest + secrets: + HYPATIA_SCAN_PAT: + description: Fine-grained token with repository Administration read access + required: false permissions: actions: read @@ -83,20 +87,33 @@ jobs: scripts sparse-checkout-cone-mode: false - - name: Run allowlist preflight check + - name: Run tree allowlist preflight check + run: | + # Bootstrap rule: standards must test the scripts from its PR tree; + # consumers use the canonical copies checked out from standards. + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then + cp scripts/check-actions-policy.sh scripts/check-allowed-actions.sh "$RUNNER_TEMP/" + cp rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ + "$RUNNER_TEMP/allowed-actions.json" + else + cp .standards-checkout/scripts/check-actions-policy.sh \ + .standards-checkout/scripts/check-allowed-actions.sh "$RUNNER_TEMP/" + cp .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ + "$RUNNER_TEMP/allowed-actions.json" + fi + bash "$RUNNER_TEMP/check-allowed-actions.sh" \ + "$RUNNER_TEMP/allowed-actions.json" .github/workflows + + - name: Check live Actions policy + env: + # GitHub's job token cannot read this Administration endpoint. Keep + # this dependency explicit: missing/expired credentials must make the + # live control red rather than silently reducing it to a tree check. + GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} run: | - # check-actions-policy.sh `exec`s its SIBLING check-allowed-actions.sh - # via "${0%/*}/...". Copying only the first script and then deleting - # the checkout left that sibling missing, so the step died with exit - # 127 (command not found) on every run. Stage both, plus the canonical - # allowlist itself — consumer repos have no copy of it in their tree. - cp .standards-checkout/scripts/check-actions-policy.sh \ - .standards-checkout/scripts/check-allowed-actions.sh "$RUNNER_TEMP/" - cp .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ - "$RUNNER_TEMP/allowed-actions.json" rm -rf .standards-checkout - ALLOWLIST_JSON="$RUNNER_TEMP/allowed-actions.json" \ - bash "$RUNNER_TEMP/check-actions-policy.sh" .github/workflows + bash "$RUNNER_TEMP/check-actions-policy.sh" \ + "$GITHUB_REPOSITORY" "$RUNNER_TEMP/allowed-actions.json" validate-hypatia-baseline: name: Validate Hypatia Baseline diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index d1f0d8a3..401cbafc 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -16,3 +16,4 @@ permissions: jobs: governance: uses: ./.github/workflows/governance-reusable.yml + secrets: inherit diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 9976925f..3eab5811 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -216,7 +216,7 @@ name = "RSR — Rhodium Standard Repositories" stream = "governance" home = "rhodium-standard-repositories/" canonical_doc = "rhodium-standard-repositories/README.adoc" -source_hash = "sha256:8b6478c4900181fa5ab17bccaf9dafe2c8e5d1c22bd03d7c3a7a3feaf048cb8c" +source_hash = "sha256:4e8ff4f5ff07dece3ecd714221e84f2354137e0ded7c291d71c5edac529e696e" route = "the repository-compliance standard every repo is graded against" [[spec]] diff --git a/Justfile b/Justfile index b0725741..5286d2de 100644 --- a/Justfile +++ b/Justfile @@ -41,6 +41,20 @@ staleness-test: @echo "=== propagate-workflow-pins ===" @bash scripts/tests/propagate-workflow-pins-test.sh +# Check the repository's workflow references and, when a repository slug is +# supplied, its live Actions policy. Example: just check-allowlist hyperpolymath/standards +check-allowlist repository="": + @bash scripts/check-allowed-actions.sh rhodium-standard-repositories/actions-allowlist/allowed-actions.json .github/workflows + @if [ -n "{{repository}}" ]; then bash scripts/check-actions-policy.sh "{{repository}}"; fi + +# Apply the estate default (all + mandatory SHA pinning). Use posture=selected +# only for a designated high-sensitivity repository. +set-allowlist repository posture="all": + @ACTIONS_POSTURE="{{posture}}" bash scripts/set-allowed-actions.sh "{{repository}}" + +actions-policy-test: + @bash scripts/tests/actions-policy-486-test.sh + # Wave-0 anti-false-green regression: proves each fixed validator CAN fail false-green-test: @bash scripts/tests/wave0-false-green-test.sh diff --git a/rhodium-standard-repositories/actions-allowlist/README.adoc b/rhodium-standard-repositories/actions-allowlist/README.adoc index 69fee013..3a4d518a 100644 --- a/rhodium-standard-repositories/actions-allowlist/README.adoc +++ b/rhodium-standard-repositories/actions-allowlist/README.adoc @@ -9,8 +9,10 @@ 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 +RSR repos default to `allowed_actions: all` + `sha_pinning_required: true`. +The mandatory SHA setting keeps action references immutable while avoiding an +empty-list outage by construction. Designated release/signing repositories use +the stricter `selected` posture. Under that posture, 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. @@ -29,7 +31,7 @@ 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 +== The policy tools [cols="1,2,3",options="header"] |=== @@ -39,17 +41,23 @@ adopts a new action; the check below then enforces it everywhere. | `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. +| **Live-policy gate** +| `scripts/check-actions-policy.sh` +| Reads the repository's actual Actions settings. It blocks disabled Actions, SHA pinning being off, an empty selected allowlist, or a selected list that is not a superset of the canonical policy. + | **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. The farm/onboarding process MUST run this (with an admin PAT) whenever a repo is created or migrated, so `patterns_allowed` is never empty. It **escalates repo → org → enterprise**: a repo-level PUT that returns 409 "already set at the organization or enterprise level" falls back to the org (owner), then the enterprise — because the allowlist is often enforced one or two levels up (as it is for `metadatastician`, governed at the org level). Rhodibot (only `contents`/`pull-requests: write`) cannot set Actions policy, so it *detects + reports* via the check; the admin PAT *sets* it. +| The ROOT prevention: applies `allowed_actions=all` plus mandatory SHA pinning by default, or the canonical selected list when `ACTIONS_POSTURE=selected`. Every PUT is followed by a GET verification that refuses success if GitHub reset SHA pinning. It **escalates repo → org → enterprise** when policy is governed higher up. |=== -An **allowlist preflight** workflow (`.github/workflows/allowlist-preflight.yml`, -seeded from the RSR template) makes this a first-class red check: it uses only +The reusable **allowlist preflight** workflow +(`.github/workflows/allowlist-preflight-reusable.yml`) makes this a first-class +red check: callers pass `secrets: inherit` (or explicitly map +`HYPATIA_SCAN_PAT`) so the live check has Administration-read access. It uses only `actions/checkout` (github-owned, always permitted) so it can never itself startup-fail, and runs the check on every push/PR — turning "the whole CI went dark" into "one legible red check that names the offending `uses:`". @@ -58,10 +66,11 @@ dark" into "one legible red check that names the offending `uses:`". 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. +. set `allowed_actions = all` and `sha_pinning_required = true`, or use the + designated high-sensitivity `selected` posture; +. when selected, have a live allowlist that is a superset of the canonical list; +. pass both `check-allowed-actions.sh` (tree) and `check-actions-policy.sh` (live); +. have the policy applied by the onboarding automaton, not hand-curated per repo. == Applying / fixing a repo @@ -70,15 +79,18 @@ A conforming RSR repo MUST: # 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): +# Apply the default all + SHA-pinning policy (needs an admin PAT): bash scripts/set-allowed-actions.sh / + +# Designated high-sensitivity repository: +ACTIONS_POSTURE=selected 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 +gh api -X PUT repos///actions/permissions \ + -F enabled=true -f allowed_actions=all -F sha_pinning_required=true +gh api repos///actions/permissions ---- diff --git a/scripts/check-actions-policy.sh b/scripts/check-actions-policy.sh index 83f327bb..a03a4946 100755 --- a/scripts/check-actions-policy.sh +++ b/scripts/check-actions-policy.sh @@ -1,38 +1,66 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# check-actions-policy.sh — RSR Actions Allowlist Policy gate +# Check the LIVE GitHub Actions policy for a repository (standards#486). # -# Part of Issue #486: Wire allowlist preflight into governance gate. -# -# This script wraps check-allowed-actions.sh to validate that every `uses:` -# in the caller repo's workflows is covered by the canonical allowlist. -# It is designed to run in CI (as part of governance-reusable.yml) with -# minimal dependencies: only actions/checkout (github-owned, always permitted), -# so it can never itself startup-fail due to an allowlist gap. -# -# Exit codes: -# 0 = all `uses:` are covered by the canonical allowlist -# 1 = one or more gaps detected -# 2 = allowlist file not found (should never happen in standards repo) -# -# Usage: check-actions-policy.sh [WORKFLOWS_DIR] -# WORKFLOWS_DIR defaults to .github/workflows +# Usage: check-actions-policy.sh [allowed-actions.json] +# Exit: 0 compliant | 1 policy violation/API failure | 2 setup error set -euo pipefail -WF_DIR="${1:-.github/workflows}" +REPOSITORY="${1:?usage: check-actions-policy.sh [allowed-actions.json]}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CANON="${2:-${ALLOWLIST_JSON:-$SCRIPT_DIR/../rhodium-standard-repositories/actions-allowlist/allowed-actions.json}}" +GH_BIN="${GH_BIN:-gh}" + +[ -f "$CANON" ] || { echo "ERROR: canonical allowlist not found: $CANON" >&2; exit 2; } +command -v "$GH_BIN" >/dev/null 2>&1 || { echo "ERROR: GitHub CLI not found: $GH_BIN" >&2; exit 2; } + +permissions="$($GH_BIN api "repos/$REPOSITORY/actions/permissions")" || { + echo "ERROR: could not read live Actions permissions for $REPOSITORY" >&2 + exit 1 +} -# The canonical allowlist lives in this repo at -# rhodium-standard-repositories/actions-allowlist/allowed-actions.json -# Resolve the canonical allowlist. $ALLOWLIST_JSON lets the caller point at a -# copy staged outside the scanned tree — required for consumer repos, which do -# not have rhodium-standard-repositories/ in their own checkout. -CANON="${ALLOWLIST_JSON:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}" +enabled="$(jq -r '.enabled // false' <<<"$permissions")" +allowed="$(jq -r '.allowed_actions // "missing"' <<<"$permissions")" +sha_pin="$(jq -r '.sha_pinning_required // false' <<<"$permissions")" -if [ ! -f "$CANON" ]; then - echo "::error::Canonical allowlist not found: $CANON" - echo "This script must be run from the standards repository or a checkout that includes it." - exit 2 -fi +[ "$enabled" = true ] || { echo "ERROR: Actions are disabled for $REPOSITORY" >&2; exit 1; } +[ "$sha_pin" = true ] || { echo "ERROR: sha_pinning_required is OFF for $REPOSITORY" >&2; exit 1; } -# Run the actual check -exec bash "${0%/*}/check-allowed-actions.sh" "$CANON" "$WF_DIR" +case "$allowed" in + all) + echo "policy ok: repository=$REPOSITORY allowed=all sha_pin=true" + ;; + selected) + selected="$($GH_BIN api "repos/$REPOSITORY/actions/permissions/selected-actions")" || { + echo "ERROR: could not read selected Actions policy for $REPOSITORY" >&2 + exit 1 + } + count="$(jq -r '(.patterns_allowed // []) | length' <<<"$selected")" + [ "$count" -gt 0 ] || { + echo "ERROR: allowed_actions=selected with EMPTY patterns_allowed for $REPOSITORY" >&2 + exit 1 + } + missing="$(jq -n --argjson canon "$(<"$CANON")" --argjson live "$selected" ' + [($canon.patterns_allowed // [])[] as $required + | select((($live.patterns_allowed // []) | index($required)) == null) + | $required]')" + if [ "$(jq 'length' <<<"$missing")" -ne 0 ]; then + echo "ERROR: live selected allowlist is not a superset of the canonical policy:" >&2 + jq -r '.[] | " missing: \(.)"' <<<"$missing" >&2 + exit 1 + fi + for flag in github_owned_allowed verified_allowed; do + required="$(jq -r --arg flag "$flag" '.[$flag] // false' "$CANON")" + live="$(jq -r --arg flag "$flag" '.[$flag] // false' <<<"$selected")" + if [ "$required" = true ] && [ "$live" != true ]; then + echo "ERROR: selected policy disables required flag $flag" >&2 + exit 1 + fi + done + echo "policy ok: repository=$REPOSITORY allowed=selected patterns=$count sha_pin=true" + ;; + *) + echo "ERROR: unsupported allowed_actions value '$allowed' for $REPOSITORY" >&2 + exit 1 + ;; +esac diff --git a/scripts/check-rsr-profile.sh b/scripts/check-rsr-profile.sh index 838a6c4a..8da883ed 100755 --- a/scripts/check-rsr-profile.sh +++ b/scripts/check-rsr-profile.sh @@ -12,13 +12,16 @@ # VESTIGIAL — present but the repo does not declare the gating capability. # MISSING — the capability is declared but the module is absent. # -# Usage: check-rsr-profile.sh [REPO_DIR] (default: current directory) +# Usage: check-rsr-profile.sh [REPO_DIR] [owner/repo] +# Set RSR_REPOSITORY instead of the second argument to enforce the live +# Actions policy alongside the local capability profile. # Exit: 0 OK | 1 violations | 2 setup error set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GATES="${RSR_GATES:-$SCRIPT_DIR/../.machine_readable/template-capability-gates.toml}" REPO="${1:-.}" +LIVE_REPOSITORY="${2:-${RSR_REPOSITORY:-}}" PROFILE="$REPO/.machine_readable/rsr-profile.a2ml" [ -f "$GATES" ] || { echo "ERROR: gates file not found: $GATES" >&2; exit 2; } @@ -74,14 +77,14 @@ present() { local key="$1" case "$key" in */) [ -d "$REPO/${key%/}" ] ;; - *'*'*) ( shopt -s globstar nullglob; local m=("$REPO"/$key); [ ${#m[@]} -gt 0 ] ) ;; + *'*'*) ( shopt -s globstar nullglob; compgen -G "$REPO/$key" >/dev/null ) ;; *) [ -e "$REPO/$key" ] ;; esac } echo "repo: $REPO" echo "profile: ${PRESET:+preset=$PRESET }${DIRECT:+direct-capabilities}" -echo "effective capabilities: $(printf '%s ' $EFFECTIVE)" +echo "effective capabilities: $(printf '%s\n' "$EFFECTIVE" | paste -sd ' ' -)" echo fail=0 @@ -108,4 +111,9 @@ Fix one of: MSG exit 1 fi + +if [ -n "$LIVE_REPOSITORY" ]; then + echo "checking live Actions policy: $LIVE_REPOSITORY" + bash "$SCRIPT_DIR/check-actions-policy.sh" "$LIVE_REPOSITORY" +fi echo "rsr-profile check: OK — scaffold matches declared capabilities." diff --git a/scripts/rsr-selfaudit.sh b/scripts/rsr-selfaudit.sh index 2b661469..65fce40d 100755 --- a/scripts/rsr-selfaudit.sh +++ b/scripts/rsr-selfaudit.sh @@ -11,14 +11,24 @@ # `rsr-audit.sh … || true` swallowed exactly that. Here a grade is reported and # returns 0; only a genuine audit error returns non-zero. # -# Usage: rsr-selfaudit.sh [repo-path] (default: .) +# Usage: rsr-selfaudit.sh [repo-path] [owner/repo] +# Set RSR_REPOSITORY instead of the second argument to include the live +# Actions-permissions requirement in the audit. set -uo pipefail REPO="${1:-.}" +LIVE_REPOSITORY="${2:-${RSR_REPOSITORY:-}}" SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AUDIT="$SELF_DIR/../rhodium-standard-repositories/rsr-audit.sh" +if [ -n "$LIVE_REPOSITORY" ]; then + bash "$SELF_DIR/check-actions-policy.sh" "$LIVE_REPOSITORY" || { + echo " -> RSR self-audit: live Actions policy is non-compliant" >&2 + exit 1 + } +fi + bash "$AUDIT" "$REPO" text rc=$? case "$rc" in diff --git a/scripts/set-allowed-actions.sh b/scripts/set-allowed-actions.sh index eda6cd36..4587ae25 100755 --- a/scripts/set-allowed-actions.sh +++ b/scripts/set-allowed-actions.sh @@ -1,74 +1,121 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# set-allowed-actions.sh — apply the canonical Actions allowlist at the level -# that actually governs a repo (repo -> org -> enterprise fallback). +# Apply and verify the canonical GitHub Actions policy (standards#486). # -# This is the ROOT prevention for the "empty-allowlist startup_failure" class: -# when a repo is CREATED or MIGRATED, its allowlist is reset, blocking every -# estate action/reusable at once so the whole CI dies at startup. Run this on -# onboard/migrate so patterns_allowed is never empty. +# Estate default: allowed_actions=all + sha_pinning_required=true. Set +# ACTIONS_POSTURE=selected for designated high-sensitivity repositories; that +# mode also installs the canonical selected-actions payload. # -# The allowlist may be enforced at the REPO, the ORG, or the ENTERPRISE level. A -# repo-level PUT returns 409 "already set at the organization or enterprise level" -# when a higher level governs it — so this escalates automatically: -# repo -> (409) -> org (owner) -> (409) -> enterprise (if slug given) -# Fixing it at the org/enterprise level repairs ALL repos beneath it at once. -# -# Requires an admin token for the level reached: repo Administration:write, or -# admin:org, or admin:enterprise — the farm ADMIN PAT, not the workflow -# GITHUB_TOKEN. (gh auth refresh -h github.com -s admin:org / admin:enterprise) -# -# Usage: set-allowed-actions.sh [ALLOWED_ACTIONS_JSON] [ENTERPRISE_SLUG] -# ENTERPRISE= may be given via env instead of the 3rd arg. +# Usage: set-allowed-actions.sh [allowed-actions.json] [enterprise] set -uo pipefail -REPO="${1:?usage: set-allowed-actions.sh [allowed-actions.json] [enterprise-slug]}" +REPO="${1:?usage: set-allowed-actions.sh [allowed-actions.json] [enterprise]}" CANON="${2:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}" ENTERPRISE="${3:-${ENTERPRISE:-}}" +POSTURE="${ACTIONS_POSTURE:-all}" +GH_BIN="${GH_BIN:-gh}" OWNER="${REPO%%/*}" -[ -f "$CANON" ] || { echo "!! canonical allowlist not found: $CANON" >&2; exit 2; } -N="$(python3 -c "import json;print(len(json.load(open('$CANON'))['patterns_allowed']))")" -# Apply the patterns at one scope. Returns 0 = applied, 42 = 409 (escalate), 1 = other error. -apply() { - local label="$1" prefix="$2" out rc - out="$(gh api -X PUT "$prefix/actions/permissions/selected-actions" --input "$CANON" 2>&1)"; rc=$? - if [ $rc -eq 0 ]; then - echo "✔ applied $N patterns at the $label level ($prefix) — governs everything beneath it" - return 0 +[ "$POSTURE" = all ] || [ "$POSTURE" = selected ] || { + echo "ERROR: ACTIONS_POSTURE must be 'all' or 'selected'" >&2 + exit 2 +} +[ -f "$CANON" ] || { echo "ERROR: canonical allowlist not found: $CANON" >&2; exit 2; } +command -v "$GH_BIN" >/dev/null 2>&1 || { echo "ERROR: GitHub CLI not found: $GH_BIN" >&2; exit 2; } +PATTERN_COUNT="$(jq -r '.patterns_allowed | length' "$CANON")" + +# Return 0=applied+verified, 42=governed at a higher scope, 1=other failure. +apply_policy() { + local label="$1" prefix="$2" current enabled_key enabled_value out rc verify + local -a fields + + current="$($GH_BIN api "$prefix/actions/permissions" 2>&1)" || { + echo "ERROR: cannot read $label Actions permissions: $current" >&2 + return 1 + } + + case "$prefix" in + repos/*) + enabled_key=enabled + enabled_value="$(jq -r '.enabled // true' <<<"$current")" + fields=(-F "enabled=$enabled_value") + ;; + *) + enabled_key=enabled_repositories + enabled_value="$(jq -r '.enabled_repositories // "all"' <<<"$current")" + fields=(-f "enabled_repositories=$enabled_value") + ;; + esac + + out="$($GH_BIN api -X PUT "$prefix/actions/permissions" \ + "${fields[@]}" -f "allowed_actions=$POSTURE" -F sha_pinning_required=true 2>&1)" + rc=$? + if [ "$rc" -ne 0 ]; then + if grep -qiE 'organization or enterprise level|enterprise level' <<<"$out"; then + echo " $label policy is governed higher up (409) — escalating" + return 42 + fi + echo "ERROR: $label permissions update failed: $out" >&2 + return 1 + fi + + if [ "$POSTURE" = selected ]; then + out="$($GH_BIN api -X PUT "$prefix/actions/permissions/selected-actions" --input "$CANON" 2>&1)" || { + if grep -qiE 'organization or enterprise level|enterprise level' <<<"$out"; then + echo " $label selected policy is governed higher up (409) — escalating" + return 42 + fi + echo "ERROR: $label selected-actions update failed: $out" >&2 + return 1 + } + fi + + # The read-after-write is mandatory: an endpoint that silently drops or + # resets sha_pinning_required must never be reported as successfully fixed. + verify="$($GH_BIN api "$prefix/actions/permissions")" || { + echo "ERROR: $label policy update could not be verified" >&2 + return 1 + } + if [ "$(jq -r '.sha_pinning_required // false' <<<"$verify")" != true ]; then + echo "ERROR: $label update left sha_pinning_required=false; refusing success" >&2 + return 1 fi - if printf '%s' "$out" | grep -qiE 'organization or enterprise level|enterprise level'; then - echo " $label level is governed higher up (409) — escalating…" - return 42 + if [ "$(jq -r '.allowed_actions // "missing"' <<<"$verify")" != "$POSTURE" ]; then + echo "ERROR: $label update did not retain allowed_actions=$POSTURE" >&2 + return 1 + fi + if [ "$(jq -r --arg key "$enabled_key" '.[$key]' <<<"$verify")" != "$enabled_value" ]; then + echo "ERROR: $label update changed $enabled_key unexpectedly" >&2 + return 1 + fi + + if [ "$POSTURE" = selected ]; then + selected="$($GH_BIN api "$prefix/actions/permissions/selected-actions")" || return 1 + if [ "$(jq -r '(.patterns_allowed // []) | length' <<<"$selected")" -lt "$PATTERN_COUNT" ]; then + echo "ERROR: selected-actions verification returned fewer than $PATTERN_COUNT patterns" >&2 + return 1 + fi fi - echo "!! $label-level apply failed:" >&2; printf '%s\n' "$out" >&2 - return 1 -} -# Best-effort: ensure the repo is on allowed_actions=selected, preserving `enabled`. -enabled="$(gh api "repos/$REPO/actions/permissions" --jq '.enabled' 2>/dev/null || echo true)" -gh api -X PUT "repos/$REPO/actions/permissions" -F enabled="$enabled" -f allowed_actions=selected >/dev/null 2>&1 || true + echo "OK: $label policy verified (allowed=$POSTURE sha_pinning_required=true)" +} -echo "==> repo level: $REPO" -apply "repo" "repos/$REPO"; rc=$? -[ $rc -eq 0 ] && exit 0 -[ $rc -ne 42 ] && exit 1 +echo "==> repository level: $REPO" +apply_policy repository "repos/$REPO"; rc=$? +[ "$rc" -eq 0 ] && exit 0 +[ "$rc" -ne 42 ] && exit 1 -echo "==> org level: $OWNER (fixes every repo in the org)" -apply "org" "orgs/$OWNER"; rc=$? -[ $rc -eq 0 ] && exit 0 -[ $rc -ne 42 ] && exit 1 +echo "==> organization level: $OWNER" +apply_policy organization "orgs/$OWNER"; rc=$? +[ "$rc" -eq 0 ] && exit 0 +[ "$rc" -ne 42 ] && exit 1 if [ -n "$ENTERPRISE" ]; then - echo "==> enterprise level: $ENTERPRISE (fixes every org in the enterprise)" - apply "enterprise" "enterprises/$ENTERPRISE"; rc=$? - [ $rc -eq 0 ] && exit 0 + echo "==> enterprise level: $ENTERPRISE" + apply_policy enterprise "enterprises/$ENTERPRISE"; rc=$? + [ "$rc" -eq 0 ] && exit 0 exit 1 fi -cat >&2 < - (needs admin:enterprise — gh auth refresh -h github.com -s admin:enterprise) -EOF +echo "ERROR: policy is enterprise-governed; provide the enterprise slug" >&2 exit 3 diff --git a/scripts/tests/actions-policy-486-test.sh b/scripts/tests/actions-policy-486-test.sh new file mode 100755 index 00000000..c4bb25ce --- /dev/null +++ b/scripts/tests/actions-policy-486-test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Regression fixtures for the live Actions policy detector/setter (#486). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CHECK="$SCRIPT_DIR/../check-actions-policy.sh" +SET="$SCRIPT_DIR/../set-allowed-actions.sh" +SELF_AUDIT="$SCRIPT_DIR/../rsr-selfaudit.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +CANON="$WORK/allowed-actions.json" +FAKE_GH="$WORK/gh" + +printf '%s\n' '{"github_owned_allowed":true,"verified_allowed":true,"patterns_allowed":["owner/action@*"]}' > "$CANON" + +cat > "$FAKE_GH" <<'FAKE' +#!/usr/bin/env bash +set -u +scenario="${POLICY_SCENARIO:-all-ok}" +args=" $* " + +if [[ "$args" == *" -X PUT "* ]]; then + printf '%s\n' '{}' + exit 0 +fi + +if [[ "$args" == *"/selected-actions "* ]]; then + case "$scenario" in + selected-empty) printf '%s\n' '{"github_owned_allowed":true,"verified_allowed":true,"patterns_allowed":[]}' ;; + selected-missing) printf '%s\n' '{"github_owned_allowed":true,"verified_allowed":true,"patterns_allowed":["other/action@*"]}' ;; + *) printf '%s\n' '{"github_owned_allowed":true,"verified_allowed":true,"patterns_allowed":["owner/action@*","extra/action@*"]}' ;; + esac + exit 0 +fi + +case "$scenario" in + sha-off|setter-reset) printf '%s\n' '{"enabled":true,"allowed_actions":"all","sha_pinning_required":false}' ;; + selected-empty|selected-missing|selected-ok|setter-selected) + printf '%s\n' '{"enabled":true,"allowed_actions":"selected","sha_pinning_required":true}' ;; + disabled) printf '%s\n' '{"enabled":false,"allowed_actions":"all","sha_pinning_required":true}' ;; + *) printf '%s\n' '{"enabled":true,"allowed_actions":"all","sha_pinning_required":true}' ;; +esac +FAKE +chmod +x "$FAKE_GH" + +pass=0 +fail=0 +expect() { + local label="$1" want="$2" scenario="$3"; shift 3 + local out rc + out="$(POLICY_SCENARIO="$scenario" GH_BIN="$FAKE_GH" "$@" 2>&1)"; rc=$? + if [ "$rc" -eq "$want" ]; then + echo "PASS: $label" + pass=$((pass + 1)) + else + echo "FAIL: $label (wanted $want, got $rc): $out" + fail=$((fail + 1)) + fi +} + +expect "all + SHA pinning passes" 0 all-ok "$CHECK" owner/repo "$CANON" +expect "SHA pinning off blocks" 1 sha-off "$CHECK" owner/repo "$CANON" +expect "disabled Actions blocks" 1 disabled "$CHECK" owner/repo "$CANON" +expect "empty selected allowlist blocks" 1 selected-empty "$CHECK" owner/repo "$CANON" +expect "selected allowlist missing canonical entry blocks" 1 selected-missing "$CHECK" owner/repo "$CANON" +expect "selected canonical superset passes" 0 selected-ok "$CHECK" owner/repo "$CANON" +expect "setter applies and verifies estate default" 0 all-ok "$SET" owner/repo "$CANON" +expect "setter refuses a read-back with SHA pinning reset" 1 setter-reset "$SET" owner/repo "$CANON" +expect "setter supports selected high-sensitivity posture" 0 setter-selected \ + env ACTIONS_POSTURE=selected "$SET" owner/repo "$CANON" +expect "RSR self-audit blocks an unpinned live policy" 1 sha-off \ + env RSR_REPOSITORY=owner/repo "$SELF_AUDIT" "$SCRIPT_DIR/../.." +expect "RSR self-audit accepts a compliant live policy" 0 all-ok \ + env RSR_REPOSITORY=owner/repo "$SELF_AUDIT" "$SCRIPT_DIR/../.." + +echo "actions-policy-486-test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/tasks/Justfile b/tasks/Justfile index f9757105..9a73b80a 100644 --- a/tasks/Justfile +++ b/tasks/Justfile @@ -25,3 +25,12 @@ lint: # Clean build artifacts clean: @echo "Clean not configured yet" + +# Tree policy plus optional live repository policy. +check-allowlist repository="": + @bash scripts/check-allowed-actions.sh rhodium-standard-repositories/actions-allowlist/allowed-actions.json .github/workflows + @if [ -n "{{repository}}" ]; then bash scripts/check-actions-policy.sh "{{repository}}"; fi + +# Apply all+SHA-pinning by default; selected is reserved for sensitive repos. +set-allowlist repository posture="all": + @ACTIONS_POSTURE="{{posture}}" bash scripts/set-allowed-actions.sh "{{repository}}"