diff --git a/.agents/skills/github-actions-security/SKILL.md b/.agents/skills/github-actions-security/SKILL.md index 578a596..5acb43c 100644 --- a/.agents/skills/github-actions-security/SKILL.md +++ b/.agents/skills/github-actions-security/SKILL.md @@ -52,6 +52,16 @@ For `pull_request_target`: GitHub’s 2026 checkout hardening blocks common fork-head patterns, but it does not make arbitrary `git`, `gh`, curl, artifact, or script ingestion safe. +For a reusable workflow that accepts a caller-chosen ref (`checkout_ref` or equivalent), the safety rule belongs **inside the reusable**, not in its docs. A reusable inherits the caller's `github` context, so `github.event_name` is the event that triggered the calling run — the reusable can therefore detect a privileged caller itself: + +- make the refusal the **first step**, before any checkout or setup; +- fail closed when a privileged event supplies a non-empty ref, and leave the empty-ref case alone (checking out the base repository stays legitimate); +- give it no opt-out input — a boolean that disables a security control is the control failing open; +- read the event and the ref through `env:`, never `${{ }}` inside `run:`; +- enforce it with a validator that *executes* the guard across the event matrix, so a new ref-accepting workflow cannot ship without it. + +A rule that lives only in a risk note or a dismissed scanner alert is not a control. Treat "consumers must not call this from `pull_request_target`" as unenforced until a job step fails on it. + ### 2. Token and permission audit - Require top-level deny-all and minimum job scopes. diff --git a/.agents/skills/nddev-change-flow/SKILL.md b/.agents/skills/nddev-change-flow/SKILL.md index 3613117..a1531ce 100644 --- a/.agents/skills/nddev-change-flow/SKILL.md +++ b/.agents/skills/nddev-change-flow/SKILL.md @@ -28,8 +28,8 @@ New to the repo? Read `nddev-repo-orientation` first. 5. **If you touched a skill** under `.agents/skills/`, run `python3 scripts/sync_skills.py` to regenerate the `.claude/skills` mirror. 6. **Add a `CHANGELOG.md` entry** under `[Unreleased]`. -7. **Validate** (below), then open a PR. `main` is squash-merge-only behind the - required `ci-gate` check. +7. **Validate** (below), then open a PR. `main` takes merge commits only + (squash and rebase are disabled) behind the required `ci-gate` check. ## Editing a workflow — the security invariants @@ -150,4 +150,5 @@ git diff --check (`-S`, SSH is configured in this checkout); no `Co-Authored-By`. - Fill `.github/PULL_REQUEST_TEMPLATE.md` — the permissions diff and threat-model note are required for any workflow or permission change. -- Merge is squash-only after `ci-gate` is green. +- Merge is a merge commit after `ci-gate` is green; squash and rebase are + disabled on this repository. diff --git a/.agents/skills/nddev-release-flow/SKILL.md b/.agents/skills/nddev-release-flow/SKILL.md index b238c84..78f98d8 100644 --- a/.agents/skills/nddev-release-flow/SKILL.md +++ b/.agents/skills/nddev-release-flow/SKILL.md @@ -38,7 +38,7 @@ A mistake ships in the next version, never as an edit to a published one. version, not the date, but keep it honest. 3. Choose the number: this is `0.x`, so a **breaking or fail-closed contract change lands as a minor bump**; pure fixes/docs are a patch. -4. Confirm consistency and gate, then PR + squash-merge: +4. Confirm consistency and gate, then PR + merge commit: ```bash cat VERSION diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4eb56ec..3d2563d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -21,8 +21,8 @@ prose docs as the source of truth. 5. If you touched a skill, `python3 scripts/sync_skills.py` to regenerate the `.claude/skills` mirror from `.agents/skills`. 6. Add a `CHANGELOG.md` entry under `[Unreleased]`. -7. Validate (below), then PR — `main` is squash-merge-only behind the - `ci-gate` check. +7. Validate (below), then PR — `main` takes merge commits only (squash and + rebase are disabled) behind the `ci-gate` check. ## CI skills @@ -95,8 +95,8 @@ programs), so a failure message usually names the exact broken contract. - Conventional Commits (<100-char subject), `git commit -s` (DCO) and `-S` (SSH signing is configured in this checkout), no `Co-Authored-By`. - PRs fill `.github/PULL_REQUEST_TEMPLATE.md` (permissions diff + - threat-model note for workflow changes); merge is squash-only after - `ci-gate` is green. + threat-model note for workflow changes); merge is a merge commit after + `ci-gate` is green — squash and rebase are disabled. - Release = maintainer pushes SemVer tag; `release.yml` validates byte-exact `VERSION`, a single matching `CHANGELOG.md` heading, then publishes five immutable checksummed assets in one create call. Never diff --git a/.claude/skills/github-actions-security/.generated-from-agents-skills b/.claude/skills/github-actions-security/.generated-from-agents-skills index efc4130..3ac2bc7 100644 --- a/.claude/skills/github-actions-security/.generated-from-agents-skills +++ b/.claude/skills/github-actions-security/.generated-from-agents-skills @@ -1,2 +1,2 @@ source=.agents/skills/github-actions-security/SKILL.md -sha256=4122a6426cc2f9a930c3f8ec3787e5d0889b8067c8f1094d3e99ea682203cc20 +sha256=1a21a985c577e9fb7ad32bc44eddad1833e4ba03af020ce2a846293f06df77d3 diff --git a/.claude/skills/github-actions-security/SKILL.md b/.claude/skills/github-actions-security/SKILL.md index 578a596..5acb43c 100644 --- a/.claude/skills/github-actions-security/SKILL.md +++ b/.claude/skills/github-actions-security/SKILL.md @@ -52,6 +52,16 @@ For `pull_request_target`: GitHub’s 2026 checkout hardening blocks common fork-head patterns, but it does not make arbitrary `git`, `gh`, curl, artifact, or script ingestion safe. +For a reusable workflow that accepts a caller-chosen ref (`checkout_ref` or equivalent), the safety rule belongs **inside the reusable**, not in its docs. A reusable inherits the caller's `github` context, so `github.event_name` is the event that triggered the calling run — the reusable can therefore detect a privileged caller itself: + +- make the refusal the **first step**, before any checkout or setup; +- fail closed when a privileged event supplies a non-empty ref, and leave the empty-ref case alone (checking out the base repository stays legitimate); +- give it no opt-out input — a boolean that disables a security control is the control failing open; +- read the event and the ref through `env:`, never `${{ }}` inside `run:`; +- enforce it with a validator that *executes* the guard across the event matrix, so a new ref-accepting workflow cannot ship without it. + +A rule that lives only in a risk note or a dismissed scanner alert is not a control. Treat "consumers must not call this from `pull_request_target`" as unenforced until a job step fails on it. + ### 2. Token and permission audit - Require top-level deny-all and minimum job scopes. diff --git a/.claude/skills/nddev-change-flow/.generated-from-agents-skills b/.claude/skills/nddev-change-flow/.generated-from-agents-skills index 1a31458..dd83362 100644 --- a/.claude/skills/nddev-change-flow/.generated-from-agents-skills +++ b/.claude/skills/nddev-change-flow/.generated-from-agents-skills @@ -1,2 +1,2 @@ source=.agents/skills/nddev-change-flow/SKILL.md -sha256=aef91f2d801e3501335c1b53f681f73b930619db6e390b70ad3a4ce1f05a24f4 +sha256=2fce2bc710e42a96cc2c311487b907d177f16215c80be8152ca0fc569fe4fd72 diff --git a/.claude/skills/nddev-change-flow/SKILL.md b/.claude/skills/nddev-change-flow/SKILL.md index 3613117..a1531ce 100644 --- a/.claude/skills/nddev-change-flow/SKILL.md +++ b/.claude/skills/nddev-change-flow/SKILL.md @@ -28,8 +28,8 @@ New to the repo? Read `nddev-repo-orientation` first. 5. **If you touched a skill** under `.agents/skills/`, run `python3 scripts/sync_skills.py` to regenerate the `.claude/skills` mirror. 6. **Add a `CHANGELOG.md` entry** under `[Unreleased]`. -7. **Validate** (below), then open a PR. `main` is squash-merge-only behind the - required `ci-gate` check. +7. **Validate** (below), then open a PR. `main` takes merge commits only + (squash and rebase are disabled) behind the required `ci-gate` check. ## Editing a workflow — the security invariants @@ -150,4 +150,5 @@ git diff --check (`-S`, SSH is configured in this checkout); no `Co-Authored-By`. - Fill `.github/PULL_REQUEST_TEMPLATE.md` — the permissions diff and threat-model note are required for any workflow or permission change. -- Merge is squash-only after `ci-gate` is green. +- Merge is a merge commit after `ci-gate` is green; squash and rebase are + disabled on this repository. diff --git a/.claude/skills/nddev-release-flow/.generated-from-agents-skills b/.claude/skills/nddev-release-flow/.generated-from-agents-skills index 6d20596..7d199f3 100644 --- a/.claude/skills/nddev-release-flow/.generated-from-agents-skills +++ b/.claude/skills/nddev-release-flow/.generated-from-agents-skills @@ -1,2 +1,2 @@ source=.agents/skills/nddev-release-flow/SKILL.md -sha256=23d3186d24f779571c0098da05a9d9233eb9190ac5c292e5b030510cfef4be16 +sha256=7930735d57821b5b4e2939e681381c8d6cbc4030c227c6da16120c60925f3380 diff --git a/.claude/skills/nddev-release-flow/SKILL.md b/.claude/skills/nddev-release-flow/SKILL.md index b238c84..78f98d8 100644 --- a/.claude/skills/nddev-release-flow/SKILL.md +++ b/.claude/skills/nddev-release-flow/SKILL.md @@ -38,7 +38,7 @@ A mistake ships in the next version, never as an edit to a published one. version, not the date, but keep it honest. 3. Choose the number: this is `0.x`, so a **breaking or fail-closed contract change lands as a minor bump**; pure fixes/docs are a patch. -4. Confirm consistency and gate, then PR + squash-merge: +4. Confirm consistency and gate, then PR + merge commit: ```bash cat VERSION diff --git a/.github/workflows/cross-platform-smoke.yml b/.github/workflows/cross-platform-smoke.yml index 87d6754..298f6f8 100644 --- a/.github/workflows/cross-platform-smoke.yml +++ b/.github/workflows/cross-platform-smoke.yml @@ -62,6 +62,28 @@ jobs: matrix: os: ${{ fromJSON(inputs.os_list) }} steps: + - name: Reject caller-supplied ref on a privileged event + shell: bash + env: + CALLER_EVENT: ${{ github.event_name }} + CHECKOUT_REF: ${{ inputs.checkout_ref }} + run: | + set -euo pipefail + # A reusable workflow inherits the caller's github context, so + # CALLER_EVENT is the event that triggered the calling run. On a + # privileged event the job holds the caller's write token and + # secrets, and checking out a caller-supplied ref would execute + # untrusted code with them. Fail closed instead. + case "$CALLER_EVENT" in + pull_request_target|workflow_run|issue_comment|issues|discussion|discussion_comment) + if [ -n "$CHECKOUT_REF" ]; then + echo "::error::cross-platform-smoke refuses checkout_ref='${CHECKOUT_REF}' on privileged event '${CALLER_EVENT}'; call it from pull_request instead" >&2 + exit 1 + fi + ;; + esac + echo "caller event '${CALLER_EVENT}' accepted for checkout_ref='${CHECKOUT_REF:-}'" + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/private-static.yml b/.github/workflows/private-static.yml index d61a6be..9050875 100644 --- a/.github/workflows/private-static.yml +++ b/.github/workflows/private-static.yml @@ -50,6 +50,28 @@ jobs: permissions: contents: read steps: + - name: Reject caller-supplied ref on a privileged event + shell: bash + env: + CALLER_EVENT: ${{ github.event_name }} + CHECKOUT_REF: ${{ inputs.checkout_ref }} + run: | + set -euo pipefail + # A reusable workflow inherits the caller's github context, so + # CALLER_EVENT is the event that triggered the calling run. On a + # privileged event the job holds the caller's write token and + # secrets, and checking out a caller-supplied ref would execute + # untrusted code with them. Fail closed instead. + case "$CALLER_EVENT" in + pull_request_target|workflow_run|issue_comment|issues|discussion|discussion_comment) + if [ -n "$CHECKOUT_REF" ]; then + echo "::error::private-static refuses checkout_ref='${CHECKOUT_REF}' on privileged event '${CALLER_EVENT}'; call it from pull_request instead" >&2 + exit 1 + fi + ;; + esac + echo "caller event '${CALLER_EVENT}' accepted for checkout_ref='${CHECKOUT_REF:-}'" + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/AGENTS.md b/AGENTS.md index a40edeb..7225205 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,8 @@ variants, and runner-guard programs run against OS/architecture matrices. - Conventional Commits, subject under 100 chars; DCO sign-off (`-s`); cryptographic signature (`-S` — this checkout is configured for SSH signing); no `Co-Authored-By` trailers. -- `main` is PR-only: squash merges, required `ci-gate` status check, signed +- `main` is PR-only: merge commits only (squash and rebase are disabled), + required `ci-gate` status check, signed commits, linear history. Fill `.github/PULL_REQUEST_TEMPLATE.md` completely (threat-model note and permissions diff for workflow changes). - Releases are tag-driven and immutable: `VERSION` must equal the tag as one diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a72b9..f6d96ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ ## [Unreleased] +### Security + +- **`checkout_ref` delegated its own safety rule to prose.** + `private-static.yml` and `cross-platform-smoke.yml` are the only two + reusables that let the caller choose the checked-out commit. That is correct + on `pull_request`, where the job runs with a read-only token and no secrets, + and unsafe on a privileged event such as `pull_request_target` or + `workflow_run`, where the job holds the caller's write token and secrets and + the checkout would execute untrusted code with them. The rule existed only as + a sentence in the risk notes and in three dismissed code-scanning alerts — + nothing enforced it, and nothing stopped a consumer from getting it wrong. + + Both workflows now open with a fail-closed guard step. A reusable workflow + inherits the caller's `github` context, so `github.event_name` is the event + that triggered the *calling* run; the guard refuses to proceed when a + privileged event (`pull_request_target`, `workflow_run`, `issue_comment`, + `issues`, `discussion`, `discussion_comment`) supplies a non-empty + `checkout_ref`. It is the first step in the job, has no opt-out input, and + reads both values through the environment, so no step runs before the + refusal. A privileged caller that supplies no `checkout_ref` is untouched — + the default checkout of the base repository stays legitimate. + + New `scripts/check_privileged_ref_guard.py` (wired into `validate_all` as + `privileged-ref-guard`) discovers every workflow exposing `checkout_ref`, + asserts the guard is the first step of every checking-out job, and then + **executes** the extracted guard body against the full privileged/safe event + matrix — so the contract is proven by behaviour, not by pattern matching. A + future reusable that adds `checkout_ref` without the guard fails the gate. + +### Documentation + +- **Eight files told agents `main` was squash-merge-only; it has not been.** + The declared ruleset `.github/rulesets/branch-main.json` sets + `"allowed_merge_methods": ["merge"]`, and the live repository settings agree + (`allow_squash_merge: false`, `allow_rebase_merge: false`, + `allow_merge_commit: true`) — merge commits are the only permitted method, + matching the estate-wide atomic-commit policy. `README.md`, `SECURITY.md`, + `AGENTS.md`, `.claude/CLAUDE.md`, `docs/08-governance-rulesets.md`, and the + `nddev-change-flow` / `nddev-release-flow` skills all still described the + opposite, sending every agent that read them toward a merge method the + repository rejects. Corrected in the authored sources and remirrored into + `.claude/skills/`. + ## [0.13.2] - 2026-08-01 ### Fixed diff --git a/README.md b/README.md index 0334076..18f842a 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ has `pre` and `post` entry points that GitHub can execute even when a step-level ## Governance `main` and release tags are protected by **rulesets** in -[`.github/rulesets/`](.github/rulesets/) (pull-request-only squash merges, +[`.github/rulesets/`](.github/rulesets/) (pull-request-only merge commits, resolved review threads, signed commits, linear history, the strict `ci-gate` status check, and tag protection). The repository's solo-maintainer rule does not require an impossible self-approval; reusable projects with independent diff --git a/SECURITY.md b/SECURITY.md index 84139f4..26aa1f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,7 +30,7 @@ response as soon as reasonably possible. - `check_harden_runner_contract.py` rejects conditional Harden-Runner steps and any paid runtime-hardening reference in cross-tier/private-free workflows. - `main` is protected by a repository ruleset (`.github/rulesets/branch-main.json`): - pull-request-only squash merges, resolved review threads, signed commits, + pull-request-only merge commits, resolved review threads, signed commits, linear history, no force-push or deletion, and the strict `ci-gate` status check. The solo-maintainer repository does not require self-approval. Release tags are diff --git a/catalog/capabilities.yml b/catalog/capabilities.yml index 73e6532..c8784cc 100644 --- a/catalog/capabilities.yml +++ b/catalog/capabilities.yml @@ -288,8 +288,9 @@ capabilities: - "Use OS-specific command overrides when a smoke command is not portable across Linux, macOS, and Windows" - "macOS and Windows runner minutes are billed at a multiplier on private repos" - "Cross-tier contract intentionally contains no paid runtime-hardening action" + - "checkout_ref lets the caller pick the checked-out code; the workflow fails closed when a privileged caller event (pull_request_target, workflow_run, issue_comment, issues, discussion, discussion_comment) supplies one" deprecations: null - last_verified: "2026-07-04" + last_verified: "2026-08-02" sources: - "https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners" @@ -752,8 +753,9 @@ capabilities: required_settings: [] risks: - "Runs an arbitrary caller-supplied command; keep the command trusted and least-privilege" + - "checkout_ref lets the caller pick the checked-out code; the workflow fails closed when a privileged caller event (pull_request_target, workflow_run, issue_comment, issues, discussion, discussion_comment) supplies one" deprecations: null - last_verified: "2026-07-04" + last_verified: "2026-08-02" sources: - "https://docs.github.com/en/actions/using-workflows/reusing-workflows" diff --git a/docs/02-private-free.md b/docs/02-private-free.md index 4964bf7..5f3fc27 100644 --- a/docs/02-private-free.md +++ b/docs/02-private-free.md @@ -101,6 +101,24 @@ jobs: timeout_minutes: 10 ``` +### `checkout_ref` and privileged events + +`private-static.yml` and `cross-platform-smoke.yml` are the only two reusables +that let the caller choose the checked-out commit via `checkout_ref`. Both open +with a **fail-closed guard**: because a reusable workflow inherits the caller's +`github` context, the workflow can see the event that triggered the *calling* +run, and it refuses to start when a privileged event — +`pull_request_target`, `workflow_run`, `issue_comment`, `issues`, `discussion`, +`discussion_comment` — supplies a non-empty `checkout_ref`. + +That combination would run caller-chosen code with the base repository's write +token and secrets. Pass `checkout_ref` from a `pull_request` wrapper instead, +where the token is read-only. A privileged caller that supplies no +`checkout_ref` is unaffected — checking out the base repository stays +legitimate. There is no opt-out input; the guard is enforced by +`scripts/check_privileged_ref_guard.py`, which executes it against the full +event matrix on every gate run. + ## Release supply chain without attestations (`release-supply-chain-free.yml`) GitHub Artifact Attestations are **not available to private or internal @@ -132,6 +150,9 @@ to stay free-minimal, or accept the multiplier cost intentionally. Use `linux_command`, `macos_command`, or `windows_command` when one platform needs a lighter smoke than the default `command`. +It carries the same `checkout_ref` privileged-event guard as `private-static.yml` +(see [`checkout_ref` and privileged events](#checkout_ref-and-privileged-events)). + ## Self-hosted caveats Self-hosted runners avoid GitHub minute billing but move trust and cost onto diff --git a/docs/08-governance-rulesets.md b/docs/08-governance-rulesets.md index c5a42c6..92c5b93 100644 --- a/docs/08-governance-rulesets.md +++ b/docs/08-governance-rulesets.md @@ -79,7 +79,7 @@ version-controlled and reviewable. | Block deletion | Protect the branch | This is the recommended multi-maintainer baseline. This repository's live -solo-maintainer variant still requires a pull request, squash-only merge, +solo-maintainer variant still requires a pull request, merge-commit-only merge, resolved review threads, signed commits, linear history, no force-push/deletion, and the strict `ci-gate` check, but sets approvals to zero because GitHub does not allow an author to approve their own pull request. diff --git a/scripts/check_privileged_ref_guard.py b/scripts/check_privileged_ref_guard.py new file mode 100644 index 0000000..dbc4ea6 --- /dev/null +++ b/scripts/check_privileged_ref_guard.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Privileged-event guard contract. + +A reusable workflow that accepts a caller-supplied ``checkout_ref`` hands the +choice of checked-out code to its caller. That is safe on ``pull_request``, +where the job runs with a read-only token and no secrets, and unsafe on a +privileged event such as ``pull_request_target`` or ``workflow_run``, where the +job holds the caller's write token and secrets. Because a reusable workflow +inherits the caller's ``github`` context, it can detect that itself instead of +delegating the rule to prose. + +This validator enforces that every workflow exposing ``checkout_ref``: + +* declares the guard as the **first** step of every job that checks out, so no + step can run before the refusal; +* refuses the full privileged-event set (fail closed, no opt-out input); +* reads its inputs through the environment, never ``${{ }}`` interpolation + inside ``run:``. + +It then **executes** the extracted guard body against an accept/reject matrix, +so the contract is proven by behaviour rather than by pattern matching. +""" +from __future__ import annotations + +import subprocess +import sys + +from _workflow_yaml import get_on, load_yaml, workflow_files + +GUARD_STEP_NAME = "Reject caller-supplied ref on a privileged event" + +# Events that run with the base repository's token and secrets while being +# triggered by content an outside contributor controls. +PRIVILEGED_EVENTS = ( + "pull_request_target", + "workflow_run", + "issue_comment", + "issues", + "discussion", + "discussion_comment", +) + +# Events that legitimately combine a caller-supplied ref with a safe token. +SAFE_EVENTS = ("pull_request", "push", "workflow_dispatch", "schedule", "merge_group") + + +def _guarded_workflows() -> list[tuple[str, dict]]: + """Every reusable workflow that exposes a ``checkout_ref`` input.""" + found: list[tuple[str, dict]] = [] + for path in workflow_files(): + doc = load_yaml(path) + on = get_on(doc) + if not isinstance(on, dict): + continue + call = on.get("workflow_call") + if not isinstance(call, dict): + continue + inputs = call.get("inputs") + if isinstance(inputs, dict) and "checkout_ref" in inputs: + found.append((path.name, doc)) + return found + + +def _run_guard(body: str, event: str, ref: str) -> int: + """Execute the extracted guard body the way the runner would.""" + completed = subprocess.run( + ["bash", "-c", body], + env={"PATH": "/usr/bin:/bin", "CALLER_EVENT": event, "CHECKOUT_REF": ref}, + capture_output=True, + text=True, + timeout=30, + ) + return completed.returncode + + +def check() -> list[str]: + problems: list[str] = [] + guarded = _guarded_workflows() + + if not guarded: + problems.append( + "no workflow exposes `checkout_ref`; delete this validator or restore " + "the guarded workflows" + ) + return problems + + for name, doc in guarded: + jobs = doc.get("jobs", {}) or {} + for job_name, job in jobs.items(): + if not isinstance(job, dict): + continue + steps = job.get("steps") or [] + checks_out = any( + isinstance(s, dict) and str(s.get("uses", "")).startswith("actions/checkout@") + for s in steps + ) + if not checks_out: + continue + + if not steps or not isinstance(steps[0], dict): + problems.append(f"{name}: job {job_name!r} has no steps to guard") + continue + + first = steps[0] + if first.get("name") != GUARD_STEP_NAME: + problems.append( + f"{name}: job {job_name!r} must start with the step " + f"{GUARD_STEP_NAME!r}, found {first.get('name')!r}" + ) + continue + + env = first.get("env") or {} + if env.get("CALLER_EVENT") != "${{ github.event_name }}": + problems.append( + f"{name}: guard must read the caller event via the " + "CALLER_EVENT environment variable" + ) + if env.get("CHECKOUT_REF") != "${{ inputs.checkout_ref }}": + problems.append( + f"{name}: guard must read the ref via the CHECKOUT_REF " + "environment variable" + ) + + body = first.get("run") or "" + if "${{" in body: + problems.append( + f"{name}: guard `run:` must not interpolate ${{{{ }}}}; " + "use environment indirection" + ) + continue + if first.get("shell") != "bash": + problems.append( + f"{name}: guard must pin `shell: bash` so it behaves " + "identically on Linux, macOS, and Windows runners" + ) + + # Behavioural proof: the guard must reject every privileged event + # that carries a ref, and must let every safe combination through. + for event in PRIVILEGED_EVENTS: + if _run_guard(body, event, "refs/pull/1/head") == 0: + problems.append( + f"{name}: guard accepted checkout_ref on privileged " + f"event {event!r}" + ) + if _run_guard(body, event, "") != 0: + problems.append( + f"{name}: guard rejected event {event!r} with no " + "checkout_ref; the default checkout stays legitimate" + ) + for event in SAFE_EVENTS: + if _run_guard(body, event, "refs/pull/1/head") != 0: + problems.append( + f"{name}: guard rejected checkout_ref on safe event {event!r}" + ) + + return problems + + +def main() -> int: + problems = check() + if problems: + print("check_privileged_ref_guard: FAIL", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + print("check_privileged_ref_guard: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_all.py b/scripts/validate_all.py index 9e7af44..51429a9 100644 --- a/scripts/validate_all.py +++ b/scripts/validate_all.py @@ -35,6 +35,7 @@ import check_harden_runner_contract import check_merge_group import check_monorepo_routing +import check_privileged_ref_guard import check_rulesets import check_skills import check_workflow_contracts @@ -49,6 +50,7 @@ ("permissions", check_permissions.check), ("workflow-contracts", check_workflow_contracts.check), ("harden-runner-contract", check_harden_runner_contract.check), + ("privileged-ref-guard", check_privileged_ref_guard.check), ("release-supply-chain", check_release_supply_chain.check), ("monorepo-routing", check_monorepo_routing.check), ("benchmark-contract", check_benchmark_contract.check),