From 8e558065b20cb75f4b8e72756c9096c1f97d5193 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:31:13 +0000 Subject: [PATCH 1/5] fix(ci): pass hypatia-ref aa34a4c to the standards scanner reusables Governance's Validate Hypatia Baseline and the Hypatia Security Scan build the scanner at the reusables' default hypatia-ref, 51ab649. That commit's ActionsLock parser accepts only v0.0.2, so this repo's tool-owned v0.0.3 actions.lock fails closed as two high invalid_actions_lock findings on every run. Pass hypatia-ref aa34a4c (main after #938, which adds v0.0.3 support) as an input. The `uses:` refs are unchanged, so actions.lock is untouched. This is the value standards#1315 moves the defaults to; drop the input once the standards callers are re-pinned past that PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CYR5bf5FBLS7cXQK72aVNM --- .github/workflows/governance.yml | 6 ++++++ .github/workflows/hypatia-scan.yml | 3 +++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index b587d669..a04c0675 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -17,6 +17,12 @@ permissions: jobs: governance: uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + # Scanner pin, overriding the reusable's default (51ab649, which rejects the + # v0.0.3 actions.lock that gh actions-lock now writes). aa34a4c carries + # #938. Same value as standards#1315; drop this input once the `uses:` above + # is re-pinned past that PR. + with: + hypatia-ref: aa34a4cc6ba39d873710ff39763fdb9cde715f9d permissions: actions: read contents: read diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index c640d0f7..f647bbcc 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -22,5 +22,8 @@ permissions: jobs: hypatia: uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + # Scanner pin: see governance.yml. Drop once `uses:` is re-pinned. + with: + hypatia-ref: aa34a4cc6ba39d873710ff39763fdb9cde715f9d secrets: HYPATIA_SCAN_PAT: ${{ secrets.HYPATIA_SCAN_PAT }} From cb15dcec1794c85715c32eaa12b6c1801fdfc142 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:34:43 +0000 Subject: [PATCH 2/5] fix(rules): judge WH013 per job, and skip writes made with their own credential WH013 reported a write as starved whenever the file had no `contents: write` grant, including jobs that write with their own credential. That grant only governs GITHUB_TOKEN, so a PAT or app-token write cannot be starved by a read-only grant. Measured false positives: - hypatia estate-rescan.yml: PAT clone of verisimdb-data and push (high) - hypatia inbox-steward-intake.yml: checkout with FARM_PAT (high) - standards github-backup-mirror-reusable.yml: PAT push to the backup repo Writes are now judged per job. A job is credentialed when `token:`, `GH_TOKEN:` or `GITHUB_TOKEN:` is set to a secret other than secrets.GITHUB_TOKEN, or to a step-minted outputs.token. Starvation and its masked :high arm consider only GITHUB_TOKEN jobs. A credentialed write that is masked (2>/dev/null, || true, || echo) is still reported, at :warn with fix recipe unmask-credentialed-write, since a credential that expires would fail it silently. Tests: 5 new cases; 61/61 pass, and 3 of the new cases fail against the previous module. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CYR5bf5FBLS7cXQK72aVNM --- lib/rules/workflow_hardening.ex | 184 ++++++++++++++++++++++--------- test/workflow_hardening_test.exs | 107 ++++++++++++++++++ 2 files changed, 240 insertions(+), 51 deletions(-) diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index 2a2b92f3..9ffe70d1 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1075,6 +1075,20 @@ defmodule Hypatia.Rules.WorkflowHardening do 1. is there a workflow-level `contents: write`; 2. does the file actually perform a write; 3. does a job carry its own `permissions:` (and so possibly its own grant). + + ## Writes that do not ride on `GITHUB_TOKEN` + + `contents: write` governs the job's `GITHUB_TOKEN` only. A job that brings + its own credential (a PAT or app token as `actions/checkout`'s `token:`, or + as the `GH_TOKEN`/`GITHUB_TOKEN` env of its steps) writes with THAT + credential, so writes are judged per job and a credentialed job is never + "starved" by a read-only grant. This repository's `estate-rescan.yml` (a + PAT clone of another repository) and `inbox-steward-intake.yml` (checkout + with `FARM_PAT`) were both reported as starved for this reason. + + Masking stays a defect there: `git push … 2>/dev/null || echo` cannot turn + the job red when the credential expires or loses scope. That shape is + reported at `:warn` with its own fix recipe, because no grant is missing. """ def wh013_permission_starved_write(repo_path) do repo_path @@ -1083,62 +1097,130 @@ defmodule Hypatia.Rules.WorkflowHardening do content = File.read!(path) rel = Path.relative_to(path, repo_path) - cond do - not performs_contents_write?(content) -> - [] + {credentialed, token_writes} = + content + |> writing_job_bodies() + |> Enum.split_with(&own_credential_job?/1) - # A grant exists at either level. This rule deliberately does not try - # to prove the grant is on the RIGHT job — that needs a real YAML - # parse, and a false "you are broken" is worse than a missed one. - grants_contents_write_anywhere?(content) -> - [] + masked_credentialed = + if Enum.any?(credentialed, &masked_write?/1), + do: [wh013_masked_credentialed_finding(rel)], + else: [] - # No grant anywhere, and the write is masked so it can never go red. - masked_write?(content) -> - [ - %{ - rule: "WH013", - file: rel, - severity: :high, - reason: - "workflow #{rel} performs a write (push/commit/release/PR) with NO " <> - "`contents: write` at the workflow level or any job level, AND the " <> - "write is masked (`2>/dev/null`, `|| true`, or `|| echo`). The push " <> - "is denied, the error is swallowed, and the workflow stays GREEN " <> - "while recording nothing.", - action: :report, - fix_recipe: "grant-contents-write-to-writing-job-and-unmask", - detail: %{ - fix: - "Add `permissions: {contents: write}` to the job that performs the " <> - "write, and remove the `2>/dev/null` / `|| echo` mask so a denied " <> - "push fails the job instead of warning." - } + wh013_starved_findings(rel, content, token_writes) ++ masked_credentialed + end) + end + + # Bodies of the jobs that perform a write. A file whose jobs the extractor + # cannot split is judged as one body, as it was before the per-job split. + defp writing_job_bodies(content) do + bodies = + case extract_job_blocks(content) do + [] -> [content] + jobs -> Enum.map(jobs, fn {_id, _no, body} -> body end) + end + + Enum.filter(bodies, &performs_contents_write?/1) + end + + # `token:` / `GH_TOKEN:` / `GITHUB_TOKEN:` set to a secret other than + # `secrets.GITHUB_TOKEN`, or to a token minted by an earlier step. + @own_credential ~r/^[ \t]*(?:-[ \t]+)?(?:token|GH_TOKEN|GITHUB_TOKEN):[ \t]*["']?\$\{\{\s*(?:secrets\.(?!GITHUB_TOKEN\b)[A-Za-z_][A-Za-z0-9_]*|steps\.[A-Za-z0-9_-]+\.outputs\.token)\s*\}\}/m + + @doc """ + Return true when a job body supplies its own write credential instead of + relying on `GITHUB_TOKEN`: `token:` (e.g. on `actions/checkout`), `GH_TOKEN:` + or `GITHUB_TOKEN:` set to a secret other than `secrets.GITHUB_TOKEN`, or to a + step-minted `steps..outputs.token`. + + Approximate in the safe direction for WH013: a job that sets a PAT and then + writes with `GITHUB_TOKEN` anyway is not reported as starved. + """ + def own_credential_job?(body) when is_binary(body) do + Regex.match?(@own_credential, body) + end + + defp wh013_masked_credentialed_finding(rel) do + %{ + rule: "WH013", + file: rel, + severity: :warn, + reason: + "workflow #{rel} performs a write with its own credential (PAT or app " <> + "token), and the write is masked (`2>/dev/null`, `|| true`, or `|| echo`). " <> + "If the credential expires or loses scope, the write fails and the " <> + "workflow stays green.", + action: :report, + fix_recipe: "unmask-credentialed-write", + detail: %{ + fix: + "Remove the `2>/dev/null` / `|| true` / `|| echo` mask so a failed write " <> + "fails the job. Skip an empty commit with `git diff --cached --quiet` " <> + "rather than swallowing `git commit`'s exit." + } + } + end + + # The grant is judged file-wide (`content`); starvation and masking only + # over the jobs whose writes ride on GITHUB_TOKEN (`token_writes`). + defp wh013_starved_findings(rel, content, token_writes) do + token_text = Enum.join(token_writes, "\n") + + cond do + token_writes == [] -> + [] + + # A grant exists at either level. This rule deliberately does not try + # to prove the grant is on the RIGHT job — that needs a real YAML + # parse, and a false "you are broken" is worse than a missed one. + grants_contents_write_anywhere?(content) -> + [] + + # No grant anywhere, and the write is masked so it can never go red. + masked_write?(token_text) -> + [ + %{ + rule: "WH013", + file: rel, + severity: :high, + reason: + "workflow #{rel} performs a write (push/commit/release/PR) with NO " <> + "`contents: write` at the workflow level or any job level, AND the " <> + "write is masked (`2>/dev/null`, `|| true`, or `|| echo`). The push " <> + "is denied, the error is swallowed, and the workflow stays GREEN " <> + "while recording nothing.", + action: :report, + fix_recipe: "grant-contents-write-to-writing-job-and-unmask", + detail: %{ + fix: + "Add `permissions: {contents: write}` to the job that performs the " <> + "write, and remove the `2>/dev/null` / `|| echo` mask so a denied " <> + "push fails the job instead of warning." } - ] + } + ] - true -> - [ - %{ - rule: "WH013", - file: rel, - severity: :high, - reason: - "workflow #{rel} performs a write (push/commit/release/PR) but grants " <> - "no `contents: write` at the workflow level or any job level — the " <> - "write will be denied at run time.", - action: :report, - fix_recipe: "grant-contents-write-to-writing-job", - detail: %{ - fix: - "Add `permissions: {contents: write}` to the job that performs the " <> - "write. Do not widen the workflow level if other jobs are correctly " <> - "scoped — a job-level block replaces the workflow-level one." - } + true -> + [ + %{ + rule: "WH013", + file: rel, + severity: :high, + reason: + "workflow #{rel} performs a write (push/commit/release/PR) but grants " <> + "no `contents: write` at the workflow level or any job level — the " <> + "write will be denied at run time.", + action: :report, + fix_recipe: "grant-contents-write-to-writing-job", + detail: %{ + fix: + "Add `permissions: {contents: write}` to the job that performs the " <> + "write. Do not widen the workflow level if other jobs are correctly " <> + "scoped — a job-level block replaces the workflow-level one." } - ] - end - end) + } + ] + end end @doc """ diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index 1f2880c4..36867c43 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -896,6 +896,113 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end end + describe "wh013_permission_starved_write/1 — writes with their own credential" do + test "a PAT clone of another repo with an unmasked push is not starved (estate-rescan)" do + repo = + create_repo_with_workflow(""" + name: Rescan + permissions: + contents: read + jobs: + rescan: + runs-on: ubuntu-latest + steps: + - name: Publish + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + run: | + git clone "https://x-access-token:${GH_TOKEN}@github.com/o/data.git" d + cd d && git commit -m scan && git push origin HEAD:refs/heads/b + gh pr create --repo o/data --head b --title scan --body x + """) + + assert [] = WorkflowHardening.wh013_permission_starved_write(repo) + File.rm_rf!(repo) + end + + test "a PAT checkout with a masked push reports the mask at :warn, not starvation" do + repo = + create_repo_with_workflow(""" + name: Steward + permissions: + contents: read + jobs: + record: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + token: ${{ secrets.FARM_PAT }} + - run: git push origin HEAD 2>/dev/null || echo "::warning::could not push" + """) + + [f] = WorkflowHardening.wh013_permission_starved_write(repo) + assert f.severity == :warn + assert f.fix_recipe == "unmask-credentialed-write" + File.rm_rf!(repo) + end + + test "secrets.GITHUB_TOKEN is not an own credential — still starved" do + repo = + create_repo_with_workflow(""" + name: Steward + permissions: + contents: read + jobs: + record: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - run: git push origin HEAD + """) + + [f] = WorkflowHardening.wh013_permission_starved_write(repo) + assert f.severity == :high + assert f.fix_recipe == "grant-contents-write-to-writing-job" + File.rm_rf!(repo) + end + + test "a PAT in one job does not excuse a GITHUB_TOKEN push in another" do + repo = + create_repo_with_workflow(""" + name: Mixed + permissions: + contents: read + jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ secrets.FARM_PAT }} + run: gh pr create --repo o/farm --title x --body y + record: + runs-on: ubuntu-latest + steps: + - run: git push origin HEAD 2>/dev/null || true + """) + + [f] = WorkflowHardening.wh013_permission_starved_write(repo) + assert f.severity == :high + assert f.fix_recipe == "grant-contents-write-to-writing-job-and-unmask" + File.rm_rf!(repo) + end + + test "own_credential_job?/1 accepts PATs and app tokens, nothing else" do + assert WorkflowHardening.own_credential_job?(" token: ${{ secrets.FARM_PAT }}") + assert WorkflowHardening.own_credential_job?(~s( GH_TOKEN: "${{ secrets.X_PAT }}")) + + assert WorkflowHardening.own_credential_job?( + " GITHUB_TOKEN: ${{ steps.app.outputs.token }}" + ) + + refute WorkflowHardening.own_credential_job?(" token: ${{ secrets.GITHUB_TOKEN }}") + refute WorkflowHardening.own_credential_job?(" GH_TOKEN: ${{ github.token }}") + refute WorkflowHardening.own_credential_job?(" # token: ${{ secrets.FARM_PAT }}") + end + end + # ─── WH014 ────────────────────────────────────────────────────────── # # Regression floor for the alert-DELETION shape measured 2026-09-14 on From 6ab2046a47594d1935cd26db0c17d3286618046c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:38:41 +0100 Subject: [PATCH 3/5] fix(ci): inbox-steward-intake publishes through PRs and fails loudly (#943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary PR 3 of the baseline-gate stack, stacked on #942. Fixes the true positive left after WH013's precision fix: four real defects in `.github/workflows/inbox-steward-intake.yml`. | Defect | Effect | Fix | |---|---|---| | `update-ruleset` appended ` :: TODO: Apply rule update: …` lines to **`.hypatia-baseline.json`** | Corrupts the JSON the Governance baseline gate reads, on every critical/high suggestion | Suggestions are surfaced as `::notice` annotations only. The `data/ruleset-updates/*.json` record (`applied: false`) is unchanged. | | Every `git commit` / `git push origin HEAD` was masked (`2>/dev/null \|\| true`, `\|\| echo "::warning::…"`) and went **straight to main** | A refused write stayed green and bypassed review and signing | New `scripts/open-data-pr.sh`: branch, PR, squash auto-merge (the `estate-rescan.yml` pattern). Every failure exits non-zero. An empty change is skipped with `git diff --cached --quiet`. | | `record-report` committed only when `inputs.dry_run == "false"`; the input is **empty on `repository_dispatch`** | Every real dispatch ran as a dry run, while `update-ruleset` (`!= 'true'`) did write | Only an explicit `'true'` is a dry run, matching `update-ruleset`. | | The `.git-private-farm` dispatch discarded stderr and turned a refusal into a warning | A failed hand-off stayed green | Fails the job. | Context: none of this has produced data. `data/inbox-steward-reports/` and `data/ruleset-updates/` don't exist on main, and the last 17 runs (to 2026-07-05) all failed. ## Type of change - [x] 🐛 Bug fix - [x] 🔧 Build / CI / tooling ## How has this been verified? - `wh013_permission_starved_write/1` (with #942) on this tree returns `[]`, down from 1 `:warn` `unmask-credentialed-write` finding. - `actionlint` 1.7.7: the same 14 messages before and after (diffed). All 14 are pre-existing and sit in the `summary` job; see notes. - `shellcheck scripts/open-data-pr.sh`: clean. - I sandbox-ran `open-data-pr.sh` against a bare local remote with a stub `gh`: - publish pushes `inbox-steward/123-report` and calls `gh pr create … --base main` then `gh pr merge 77 --squash --auto --delete-branch` (rc 0); - with nothing to commit it exits 0 without a PR; - an unreachable remote gives rc 128. ## Checklist - [ ] My commits are **signed**. The session's signing shim is platform-mediated. - [x] I ran the project's own checks/tests locally and they pass. - [x] New files carry the correct SPDX (`scripts/open-data-pr.sh`: MPL-2.0). - [x] Docs are updated, and no public claim now overstates what the code does. - [x] I have not introduced a soundness hole. ## Notes for reviewers - **Needs:** `FARM_PAT` with Contents **and** Pull requests write on this repo, plus *Allow auto-merge* enabled. If either is missing, the job now fails visibly instead of warning. - **Pre-existing, not fixed here:** the `summary` job builds its step summary with `${{ … && echo '…' || echo '…' }}`. That is shell inside an expression, and actionlint rejects it ("parser did not reach end of input"). Left out to keep this PR to the write path. --- _Generated by [Claude Code](https://claude.ai/code/session_01CYR5bf5FBLS7cXQK72aVNM)_ Co-authored-by: Claude --- .github/workflows/inbox-steward-intake.yml | 78 ++++++++-------------- scripts/open-data-pr.sh | 45 +++++++++++++ 2 files changed, 73 insertions(+), 50 deletions(-) create mode 100644 scripts/open-data-pr.sh diff --git a/.github/workflows/inbox-steward-intake.yml b/.github/workflows/inbox-steward-intake.yml index 1a893795..938119c0 100644 --- a/.github/workflows/inbox-steward-intake.yml +++ b/.github/workflows/inbox-steward-intake.yml @@ -63,6 +63,7 @@ jobs: env: EVENT_JSON: ${{ toJson(github.event) }} DRY_RUN: ${{ inputs.dry_run }} + GH_TOKEN: ${{ secrets.FARM_PAT }} run: | set -euo pipefail @@ -101,13 +102,14 @@ jobs: echo "validated_count=$VALIDATED" >> "$GITHUB_OUTPUT" echo "merged_count=$MERGED" >> "$GITHUB_OUTPUT" - # Commit the report - if [ "$DRY_RUN" = "false" ]; then + # Publish the report through a PR. `inputs.dry_run` is empty on + # repository_dispatch, so only an explicit 'true' is a dry run. + if [ "$DRY_RUN" != "true" ]; then git config user.name "Inbox Steward Intake" git config user.email "inbox-steward-intake@reposystem.dev" - git add "$REPORT_PATH" - git commit -m "inbox-steward: record from $SOURCE_REPO ($PR_COUNT PRs, $MERGED merged)" 2>/dev/null || true - git push origin HEAD 2>/dev/null || echo "::warning::Could not push report" + bash scripts/open-data-pr.sh report \ + "inbox-steward: record from $SOURCE_REPO ($PR_COUNT PRs, $MERGED merged)" \ + "$REPORT_PATH" else echo "::notice::Dry run - not committing report" fi @@ -234,12 +236,10 @@ jobs: id: update env: RULE_UPDATES: ${{ needs.analyze-patterns.outputs.rule_updates }} + GH_TOKEN: ${{ secrets.FARM_PAT }} run: | set -euo pipefail - - # Load existing baseline - BASELINE_PATH=".hypatia-baseline.json" - + # Create rule update file UPDATES_DIR="data/ruleset-updates" mkdir -p "$UPDATES_DIR" @@ -256,39 +256,19 @@ jobs: echo "Created rule updates: $UPDATE_FILE" - # Auto-apply critical/high priority updates - CRITICAL_UPDATES=$(echo "$RULE_UPDATES" | jq '[.[] | select(.priority == "critical" or .priority == "high")]') - - if [ "$(echo "$CRITICAL_UPDATES" | jq length)" -gt 0 ]; then - echo "Applying critical/high priority updates..." - - for UPDATE in $(echo "$CRITICAL_UPDATES" | jq -c '.[]'); do - UPDATE_TYPE=$(echo "$UPDATE" | jq -r '.type') - ACTION=$(echo "$UPDATE" | jq -r '.action') - REASON=$(echo "$UPDATE" | jq -r '.reason') - SUGGESTION=$(echo "$UPDATE" | jq -r '.suggestion') - - echo " - $UPDATE_TYPE: $ACTION ($REASON)" - - # Apply the update to baseline - # For now, append as a comment/todo - full automation needs more logic - echo " :: TODO: Apply rule update: $SUGGESTION" >> "$BASELINE_PATH" - done - - # Commit updates - git config user.name "Inbox Steward Intake" - git config user.email "inbox-steward-intake@reposystem.dev" - git add "$UPDATE_FILE" "$BASELINE_PATH" - git commit -m "inbox-steward: apply critical rule updates from steward reports" 2>/dev/null || true - git push origin HEAD 2>/dev/null || echo "::warning::Could not push updates" - else - echo "No critical/high priority updates to apply" - git config user.name "Inbox Steward Intake" - git config user.email "inbox-steward-intake@reposystem.dev" - git add "$UPDATE_FILE" - git commit -m "inbox-steward: record rule update suggestions" 2>/dev/null || true - git push origin HEAD 2>/dev/null || echo "::warning::Could not push updates" - fi + # Critical/high suggestions are surfaced, not applied. This step used + # to append " :: TODO: …" text lines to .hypatia-baseline.json, + # which corrupts the JSON the Governance baseline gate reads. The + # suggestions live only in $UPDATE_FILE (`applied: false`). + echo "$RULE_UPDATES" \ + | jq -r '.[] | select(.priority == "critical" or .priority == "high") + | "::notice title=Rule update suggested (\(.priority))::\(.type): \(.action) (\(.reason)) — \(.suggestion)"' + + git config user.name "Inbox Steward Intake" + git config user.email "inbox-steward-intake@reposystem.dev" + bash scripts/open-data-pr.sh rule-updates \ + "inbox-steward: record rule update suggestions" \ + "$UPDATE_FILE" # Job 4: Dispatch to .git-private-farm for fleet-wide application dispatch-to-farm: @@ -333,14 +313,12 @@ jobs: echo "Dispatching to .git-private-farm: $(echo "$PAYLOAD" | jq -c '.')" - # Send to .git-private-farm - if gh api -X POST \ - "/repos/hyperpolymath/.git-private-farm/dispatches" \ - --input - <<<"$PAYLOAD" >/dev/null 2>&1; then - echo "✅ Successfully dispatched to .git-private-farm" - else - echo "::warning::Failed to dispatch to .git-private-farm" - fi + # Send to .git-private-farm. A refused dispatch fails the job; it + # used to be swallowed into a warning with stderr discarded. + gh api -X POST \ + "/repos/hyperpolymath/.git-private-farm/dispatches" \ + --input - <<<"$PAYLOAD" >/dev/null + echo "✅ Successfully dispatched to .git-private-farm" # Job 5: Summary summary: diff --git a/scripts/open-data-pr.sh b/scripts/open-data-pr.sh new file mode 100644 index 00000000..80b3aeb5 --- /dev/null +++ b/scripts/open-data-pr.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# open-data-pr.sh — land machine-written data files on main through a PR +# +# Usage: open-data-pr.sh <path>... +# +# Commits <path>... on a new branch `inbox-steward/<run_id>-<suffix>`, pushes +# it, opens a PR against main and queues a squash auto-merge. Used by +# inbox-steward-intake.yml instead of `git push origin HEAD` to main, which +# bypassed review and signing and was masked so a refusal stayed green. +# +# Needs: GH_TOKEN (a credential with Contents and Pull requests write on this +# repository), GITHUB_RUN_ID, GITHUB_REPOSITORY, and a checkout whose `origin` +# authenticates with the same credential. Every failure exits non-zero. +set -euo pipefail + +if [ "$#" -lt 3 ]; then + echo "usage: $0 <branch-suffix> <title> <path>..." >&2 + exit 2 +fi + +suffix="$1" +title="$2" +shift 2 + +git add -- "$@" +if git diff --cached --quiet; then + echo "No changes under $* — nothing to publish." + exit 0 +fi + +branch="inbox-steward/${GITHUB_RUN_ID}-${suffix}" +git switch -c "$branch" +git commit -m "$title" +git push origin "HEAD:refs/heads/${branch}" + +pr_url="$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" \ + --title "$title" \ + --body "Machine-written by inbox-steward-intake.yml, run ${GITHUB_RUN_ID}. Data files only.")" +echo "opened ${pr_url}" + +# --auto queues the merge until the base-branch policy allows it; a bare +# merge right after creation is refused before the policy settles. +gh pr merge "${pr_url##*/}" --repo "$GITHUB_REPOSITORY" --squash --auto --delete-branch +echo "auto-merge queued for ${pr_url}" From 46f37d647cb82881b87a5a3786899bb0a143ba37 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 20 Oct 2026 12:48:45 +0000 Subject: [PATCH 4/5] fix(rules): close four WH013 per-job gaps found in review - A file whose jobs the extractor cannot split is judged as one body and is never credentialed. One job's PAT can no longer excuse another job's write. - `jobs: # comment` now splits jobs, so that fallback is rarer. - A workflow-level `env:` credential (`GH_TOKEN: ${{ secrets.X }}`) credentials every job. - `secrets['NAME']` counts as an own credential; `secrets['GITHUB_TOKEN']` does not. - masked_write?/1 also sees `gh release create|upload|edit`. Tests: 4 new cases; 64/64 pass, and the 4 fail against the previous commit. WH013 on hypatia, standards and echidna: no findings, and no workflow there has a commented `jobs:` header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYR5bf5FBLS7cXQK72aVNM --- lib/rules/workflow_hardening.ex | 49 +++++++++++++++--------- test/workflow_hardening_test.exs | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index 9ffe70d1..06de1874 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -1100,7 +1100,10 @@ defmodule Hypatia.Rules.WorkflowHardening do {credentialed, token_writes} = content |> writing_job_bodies() - |> Enum.split_with(&own_credential_job?/1) + |> Enum.split_with(fn {_body, credentialed?} -> credentialed? end) + + credentialed = Enum.map(credentialed, &elem(&1, 0)) + token_writes = Enum.map(token_writes, &elem(&1, 0)) masked_credentialed = if Enum.any?(credentialed, &masked_write?/1), @@ -1111,27 +1114,39 @@ defmodule Hypatia.Rules.WorkflowHardening do end) end - # Bodies of the jobs that perform a write. A file whose jobs the extractor - # cannot split is judged as one body, as it was before the per-job split. + # `{body, credentialed?}` for each job that performs a write. A job is + # credentialed by its own body or by a workflow-level `env:` it inherits. + # A file whose jobs the extractor cannot split is judged as one body and is + # never credentialed: one job's PAT must not excuse another job's write. defp writing_job_bodies(content) do - bodies = - case extract_job_blocks(content) do - [] -> [content] - jobs -> Enum.map(jobs, fn {_id, _no, body} -> body end) - end + case extract_job_blocks(content) do + [] -> + if performs_contents_write?(content), do: [{content, false}], else: [] + + jobs -> + inherited? = own_credential_job?(workflow_preamble(content)) + + for {_id, _no, body} <- jobs, performs_contents_write?(body) do + {body, inherited? or own_credential_job?(body)} + end + end + end - Enum.filter(bodies, &performs_contents_write?/1) + # Everything above the top-level `jobs:` key (name, on, permissions, env). + defp workflow_preamble(content) do + content |> String.split(~r/^jobs:/m, parts: 2) |> hd() end # `token:` / `GH_TOKEN:` / `GITHUB_TOKEN:` set to a secret other than - # `secrets.GITHUB_TOKEN`, or to a token minted by an earlier step. - @own_credential ~r/^[ \t]*(?:-[ \t]+)?(?:token|GH_TOKEN|GITHUB_TOKEN):[ \t]*["']?\$\{\{\s*(?:secrets\.(?!GITHUB_TOKEN\b)[A-Za-z_][A-Za-z0-9_]*|steps\.[A-Za-z0-9_-]+\.outputs\.token)\s*\}\}/m + # `secrets.GITHUB_TOKEN` (dot or index form), or to a step-minted token. + @own_credential ~r/^[ \t]*(?:-[ \t]+)?(?:token|GH_TOKEN|GITHUB_TOKEN):[ \t]*["']?\$\{\{\s*(?:secrets\.(?!GITHUB_TOKEN\b)[A-Za-z_][A-Za-z0-9_]*|secrets\[\s*['"](?!GITHUB_TOKEN['"])[A-Za-z_][A-Za-z0-9_]*['"]\s*\]|steps\.[A-Za-z0-9_-]+\.outputs\.token)\s*\}\}/m @doc """ Return true when a job body supplies its own write credential instead of relying on `GITHUB_TOKEN`: `token:` (e.g. on `actions/checkout`), `GH_TOKEN:` - or `GITHUB_TOKEN:` set to a secret other than `secrets.GITHUB_TOKEN`, or to a - step-minted `steps.<id>.outputs.token`. + or `GITHUB_TOKEN:` set to a secret other than `secrets.GITHUB_TOKEN` (as + `secrets.NAME` or `secrets['NAME']`), or to a step-minted + `steps.<id>.outputs.token`. Approximate in the safe direction for WH013: a job that sets a PAT and then writes with `GITHUB_TOKEN` anyway is not reported as starved. @@ -1237,9 +1252,9 @@ defmodule Hypatia.Rules.WorkflowHardening do # command `||`-chained into a warning or a no-op. AGENTS.md §5 — never # `2>/dev/null` the thing under test. @write_masks [ - ~r/(?:git\s+push|git\s+commit|gh\s+pr\s+(?:create|merge))[^\n]*2>\s*\/dev\/null/, - ~r/(?:git\s+push|git\s+commit|gh\s+pr\s+(?:create|merge))[^\n]*\|\|\s*(?:true|:)\s*$/m, - ~r/(?:git\s+push|git\s+commit|gh\s+pr\s+(?:create|merge))[^\n]*\|\|\s*echo/ + ~r/(?:git\s+push|git\s+commit|gh\s+pr\s+(?:create|merge)|gh\s+release\s+(?:create|upload|edit))[^\n]*2>\s*\/dev\/null/, + ~r/(?:git\s+push|git\s+commit|gh\s+pr\s+(?:create|merge)|gh\s+release\s+(?:create|upload|edit))[^\n]*\|\|\s*(?:true|:)\s*$/m, + ~r/(?:git\s+push|git\s+commit|gh\s+pr\s+(?:create|merge)|gh\s+release\s+(?:create|upload|edit))[^\n]*\|\|\s*echo/ ] @doc """ @@ -1352,7 +1367,7 @@ defmodule Hypatia.Rules.WorkflowHardening do Enum.with_index(lines, 1) |> Enum.reduce({[], in_jobs?, jobs_indent, nil}, fn {line, _no}, {acc, false, _ji, _current} -> - if Regex.match?(~r/^jobs:\s*$/, line) do + if Regex.match?(~r/^jobs:\s*(?:#.*)?$/, line) do {acc, true, nil, nil} else {acc, false, nil, nil} diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index 36867c43..aabd3f0e 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -1000,6 +1000,71 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do refute WorkflowHardening.own_credential_job?(" token: ${{ secrets.GITHUB_TOKEN }}") refute WorkflowHardening.own_credential_job?(" GH_TOKEN: ${{ github.token }}") refute WorkflowHardening.own_credential_job?(" # token: ${{ secrets.FARM_PAT }}") + assert WorkflowHardening.own_credential_job?(" GH_TOKEN: ${{ secrets['RELEASE_PAT'] }}") + refute WorkflowHardening.own_credential_job?(~s( GH_TOKEN: ${{ secrets["GITHUB_TOKEN"] }})) + end + + test "a commented `jobs:` header still splits jobs, so one PAT cannot excuse another job" do + repo = + create_repo_with_workflow(""" + name: Mixed + permissions: + contents: read + jobs: # dispatch and record + dispatch: + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ secrets.FARM_PAT }} + run: gh pr create --repo o/farm --title x --body y + record: + runs-on: ubuntu-latest + steps: + - run: git push origin HEAD + """) + + assert [%{severity: :high}] = WorkflowHardening.wh013_permission_starved_write(repo) + File.rm_rf!(repo) + end + + test "a workflow-level env credential covers every job" do + repo = + create_repo_with_workflow(""" + name: Release + permissions: + contents: read + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + jobs: + publish: + runs-on: ubuntu-latest + steps: + - run: gh release create v1 dist/* + """) + + assert [] = WorkflowHardening.wh013_permission_starved_write(repo) + File.rm_rf!(repo) + end + + test "a masked credentialed gh release is reported" do + repo = + create_repo_with_workflow(""" + name: Release + permissions: + contents: read + jobs: + publish: + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + run: gh release create v1 dist/* || true + """) + + assert [%{fix_recipe: "unmask-credentialed-write"}] = + WorkflowHardening.wh013_permission_starved_write(repo) + + File.rm_rf!(repo) end end From 5d733578cf226e491e369188e7d0a1b3bf3e79eb Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:52:58 +0100 Subject: [PATCH 5/5] fix(rules): RE005 ignores trap handlers; unmask two workflow steps (#944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary PR 4 of the baseline-gate stack, stacked on #942 (which now also carries #943). It clears all 3 RE005 findings at their source: one false positive in the rule, and two masks removed from workflows. | Finding | Verdict | Fix | |---|---|---| | `tests.yml:763` `trap 'kill "$STRESS_PID" 2>/dev/null \|\| true' EXIT` | **Rule false positive.** The EXIT trap runs after the step's status is decided. Under `set -e`, an *unmasked* failing `kill` turns a **passing** step red (measured: rc 1). The `\|\| true` is what keeps it honest. | RE005 skips `trap …` lines (`lib/rules/research_extensions.ex`). The same false positive is in echidna `server-boot-gate.yml:40`. | | `tests.yml:192` `$(find … -exec grep … \| grep -v … \|\| true)` | **Real, small mask.** It also swallowed grep's exit **2**: with a missing directory the old step reports PASS. | Rewritten as `grep -r --include … --exclude-dir=rules` with an explicit status test (1 = no match = pass, >1 fails). Comment lines are removed with `sed`, which can't exit 1 on "all filtered". | | `build-gossamer-gui.yml:189` `ls … 2>/dev/null \|\| true` right before `exit 1` | The mask did nothing except hide the diagnosis | Mask dropped | ## Type of change - [x] 🐛 Bug fix (a rule false positive, plus two CI masks) ## How has this been verified? - **Rule:** I compiled `research_extensions.ex` with `scanner_suppression.ex`, `cicd_rules.ex` and `security_errors.ex` standalone (Elixir 1.14; hex.pm is unreachable here) and ran `test/research_extensions_test.exs`: - **42/42** pass; - control: the new trap test fails against the previous module, and the "a trap does not hide a real swallow" test passes on both; - `mix format --check-formatted` passes. - **Blast radius (`re005_test_swallows_exit/1`, old → new):** - hypatia: 3 → **0**; - standards: 0 → 0; - echidna: 2 → 1 (the trap in `server-boot-gate.yml` clears; `live-provers.yml:475` is a real finding and remains). - **The `tests.yml` step**, extracted with `yq` and run on this tree: - clean → rc 0 PASS; - planted `believe_me` in `src/` → rc 1 FAIL; - planted in `lib/rules/` → PASS (excluded, as before); - `-- believe_me` comment → PASS; - a missing search directory → **rc 2 FAIL**, where the old step reports PASS. - `actionlint` 1.7.7: identical message sets before and after on both files (6 pre-existing in `tests.yml`, 0 in `build-gossamer-gui.yml`). ## Checklist - [ ] My commits are **signed**. The session's signing shim is platform-mediated. - [x] I ran the project's own checks/tests locally and they pass (scoped as above). - [x] New files carry the correct SPDX (none added). - [x] Docs are updated (the RE005 `@doc` states the trap rule). - [x] I have not introduced a soundness hole. Only whole `trap …` lines are skipped, and a real swallow elsewhere in the same step still fires (test). --- _Generated by [Claude Code](https://claude.ai/code/session_01CYR5bf5FBLS7cXQK72aVNM)_ Co-authored-by: Claude <noreply@anthropic.com> --- .github/workflows/build-gossamer-gui.yml | 4 ++- .github/workflows/tests.yml | 18 +++++++++--- lib/rules/research_extensions.ex | 12 +++++++- test/research_extensions_test.exs | 35 ++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-gossamer-gui.yml b/.github/workflows/build-gossamer-gui.yml index 89d936e5..d70dd1a2 100644 --- a/.github/workflows/build-gossamer-gui.yml +++ b/.github/workflows/build-gossamer-gui.yml @@ -217,7 +217,9 @@ jobs: if [ -z "$cli" ]; then echo "::error::No ephapax CLI binary produced by 'cargo build --release --workspace'." echo "Contents of $release_dir:" - ls -la "$release_dir" 2>/dev/null || true + # No mask: this branch fails either way, and a failing `ls` + # (missing dir) is itself the diagnosis, so let its error show. + ls -la "$release_dir" exit 1 fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 698f040d..f41b3e26 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -201,10 +201,20 @@ jobs: # legitimately mention these tokens as documentation # ("Zero believe_me." in src/Hypatia/ABI/RuleEngine.idr is the canonical # example). - DANGEROUS=$(find lib src \( -name '*.idr' -o -name '*.v' -o -name '*.lean' -o -name '*.hs' \) \ - -not -path 'lib/rules/*' \ - -exec grep -Hn 'believe_me\|assert_total\|\bsorry\b\|\bAdmitted\b\|unsafeCoerce' {} + 2>/dev/null \ - | grep -v ':[[:space:]]*--' || true) + # + # grep exits 1 when nothing matches, which is the PASS case, and 2 + # on a real error. Test the status rather than masking both with + # `|| true` (`lib/rules` is the only `rules` directory under lib/src). + set -euo pipefail + status=0 + HITS=$(grep -rHn --include='*.idr' --include='*.v' --include='*.lean' --include='*.hs' \ + --exclude-dir=rules \ + 'believe_me\|assert_total\|\bsorry\b\|\bAdmitted\b\|unsafeCoerce' lib src) || status=$? + if [ "$status" -gt 1 ]; then + echo "FAIL: grep error (exit $status)" + exit "$status" + fi + DANGEROUS=$(printf '%s' "$HITS" | sed '/:[[:space:]]*--/d') if [ -n "$DANGEROUS" ]; then echo "FAIL: Dangerous patterns found" echo "$DANGEROUS" diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index d8b1ad18..1be54388 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -587,6 +587,9 @@ defmodule Hypatia.Rules.ResearchExtensions do suite goes green while real failures pile up. Provenance: Datadog CI Visibility flake-masking patterns. + `|| true` on a `trap …` line is not counted: the handler runs after the + step's status is decided and cannot mask it. + Severity: `:warn`. Action: `:report`. """ def re005_test_swallows_exit(repo_path) do @@ -619,7 +622,14 @@ defmodule Hypatia.Rules.ResearchExtensions do has_continue_on_error? = Regex.match?(~r/continue-on-error:\s*true\b/, stripped_body) - has_or_true? = Regex.match?(~r/\|\|\s*true\b/, stripped_body) + # A `trap` handler's `|| true` is not a swallow. The step's exit status + # is decided before the EXIT trap runs, and a failing handler command + # under `set -e` (e.g. `kill` of an already-exited PID) would itself + # turn a PASSING step red, so the `|| true` is what keeps it honest. + has_or_true? = + stripped_body + |> String.replace(~r/^[ \t]*trap[ \t].*$/m, "") + |> then(&Regex.match?(~r/\|\|\s*true\b/, &1)) # A step carrying `hypatia:ignore RE005 -- <reason>` (or the # `hypatia: allow research_extensions/RE005` spelling) is reviewed — diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index 66be109a..f81872b3 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -415,6 +415,41 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do File.rm_rf!(repo) end + test "ignores || true inside a trap handler (cleanup, not the step's status)" do + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - name: Test suite under stress + run: | + set -euo pipefail + stress-ng --cpu 4 & + STRESS_PID=$! + trap 'kill "$STRESS_PID" 2>/dev/null || true' EXIT + cargo test + """) + + assert ResearchExtensions.re005_test_swallows_exit(repo) == [] + File.rm_rf!(repo) + end + + test "a trap does not hide a real swallow elsewhere in the step" do + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - name: Test suite under stress + run: | + trap 'kill "$PID" 2>/dev/null || true' EXIT + cargo test || true + """) + + assert [%{rule: "RE005"}] = ResearchExtensions.re005_test_swallows_exit(repo) + File.rm_rf!(repo) + end + test "ignores non-test steps with continue-on-error" do repo = create_repo_with_workflow("""