From d18e2047f7a53101ea6f2f7fa844fb90c62fac6d Mon Sep 17 00:00:00 2001 From: yakimoto <66892052+yakimoto@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:19:52 -0400 Subject: [PATCH 01/12] ci: this repo's public-repo-guard never scanned a single issue or comment body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured across all 28 public wave-av repos (claude-workstation#1747, #1794): TWO coverage shapes satisfy the one required check name `Secrets + content policy`. 27 repos triggers: pull_request, push, workflow_dispatch jobs: guard 1 repo triggers: + issues, issue_comment jobs: + body-guard This repo is in the 27. All 28 report the same green check. The outlier is wave-moq-edge, and its own comment says why it matters: "`edited` matters as much as `opened`: a body can be made to leak long after the PR is first raised, and until this workflow covered it, nothing ever re-scanned." A PR/issue/comment BODY is exactly as world-readable as the tree, and until now it was scanned by nothing server-side. That gap was not theoretical on wave-moq-edge: a PR was blocked for naming a private repo in wrangler.toml while the very same name, with more operational detail attached, sat unchallenged in its body. WHAT LANDS HERE — the bundle the workflow's own header names, minus what this repo already has (.gitleaks.toml and content-policy.sh are already vendored): .github/workflows/public-repo-guard.yml replaced (73 -> 163 lines) scripts/public-repo-guard/body-policy.sh new, mode 100755 scripts/public-repo-guard/tests/body-policy.test.sh new, mode 100755 Copied from wave-moq-edge, which has run this shape in production. Modes preserved via the git trees API — the contents API would have created both scripts 100644. HONEST ABOUT WHAT IT CAN DO. On a PR this PREVENTS the merge. On an issue or comment the text is already public the moment it posts, so this is DETECTION: it says go redact, fast. Only a client-side pre-write hook stops that class before publication. Also inherited from the reference: concurrency moves from workflow-level to PER JOB, because the two jobs want opposite behaviour. A workflow-level group forced one policy on both, and rapid body edits cancelled the tree job repeatedly — every cancelled check-run stays attached to the commit, so the PR reported UNSTABLE while the live runs were green. The body gate ships with its own fixtures and runs them in CI. Its NEGATIVE cases are the load-bearing half: a leak gate that blocks legitimate cross-repo references gets switched off, and then it protects nothing. Refs wave-av/claude-workstation#1747. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/public-repo-guard.yml | 100 ++++++++++++- scripts/public-repo-guard/body-policy.sh | 139 ++++++++++++++++++ .../tests/body-policy.test.sh | 108 ++++++++++++++ 3 files changed, 342 insertions(+), 5 deletions(-) create mode 100755 scripts/public-repo-guard/body-policy.sh create mode 100755 scripts/public-repo-guard/tests/body-policy.test.sh diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 719718a..bba2f67 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,10 +13,11 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all three files together: +# To install on a new repo, copy all four files together: # .github/workflows/public-repo-guard.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh +# scripts/public-repo-guard/body-policy.sh # # Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The # goal is "what is public right now is clean", so a shallow checkout is sufficient. @@ -25,24 +26,44 @@ name: public-repo-guard # path glob to a repo-root `.guardignore`, or extend the repo-local `.gitleaks.toml`. on: + # `edited` matters as much as `opened`: a body can be made to leak long after the + # PR is first raised, and until this workflow covered it, nothing ever re-scanned. pull_request: + types: [opened, edited, reopened, synchronize] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] push: branches: [main, master] workflow_dispatch: +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. permissions: contents: read -concurrency: - group: public-repo-guard-${{ github.ref }} - cancel-in-progress: true +# Concurrency is per JOB, not per workflow: the two jobs want opposite behaviour. +# A workflow-level group would force one policy on both, and it showed: rapid body +# edits cancelled the tree job over and over, and every cancelled check-run stays +# attached to the commit, so the PR reported UNSTABLE while the live runs were green. jobs: guard: name: Secrets + content policy + # Skips issue/comment events (the tree scan has nothing to say about a comment, + # and the org should not pay for a gitleaks run every time anyone posts one) and + # skips `edited` (a title or body edit does not change the tree). + if: >- + (github.event_name == 'pull_request' && github.event.action != 'edited') + || github.event_name == 'push' + || github.event_name == 'workflow_dispatch' + concurrency: + group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release @@ -71,3 +92,72 @@ jobs: env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: bash scripts/public-repo-guard/content-policy.sh . + + # The body gate's own fixtures. Its negatives are the load-bearing half — a + # leak gate that blocks legitimate cross-repo references gets switched off, + # and then it protects nothing. Runs here so a regression is caught by CI + # rather than by a leak. + - name: body policy self-test (fixtures) + run: bash scripts/public-repo-guard/tests/body-policy.test.sh + + # The other half of a public repo's surface. `guard` above scans the published + # TREE; a PR/issue/comment BODY is just as world-readable and, until this job, + # was scanned by nothing server-side. That gap was real, not theoretical: a PR + # was blocked for naming a private repo in wrangler.toml while the very same + # name, with more operational detail attached, sat unchallenged in its body. + # + # Honest about what it can and cannot do. On a PR this PREVENTS the merge. On an + # issue or comment the text is already public the moment it posts, so this is + # detection — it tells us to go redact, fast. Only the client-side pre-write hook + # can stop that class before publication. + body-guard: + name: Body content policy + if: github.event_name == 'pull_request' || github.event_name == 'issues' || github.event_name == 'issue_comment' + concurrency: + # Keyed on the specific PR / comment / issue rather than github.ref, because + # issue events all report the default branch and a ref-keyed group would let + # two comments cancel each other, leaving one unscanned. + # + # cancel-in-progress is deliberately FALSE. Every version of a body deserves a + # verdict, the job is seconds long, and a cancelled check-run lingers on the + # commit and makes an otherwise-green PR look broken. + group: public-repo-guard-body-${{ github.event.pull_request.number || github.event.comment.id || github.event.issue.number || github.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Only the gate's own scripts are needed — no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + - name: Install ripgrep + run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + + # The body is read straight out of the event payload FILE and written to + # another file. It is never interpolated into a run: block and never placed + # in an environment variable, so shell metacharacters in a hostile PR body + # have nothing to act on. jq is preinstalled on the GitHub-hosted images. + - name: Materialize the untrusted title/body to a file + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bodyscan" + # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and + # report a pass. If the event schema ever moves, this job must go red + # rather than become a green rubber stamp over an unscanned body. + if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment object — refusing to report a pass on an unscanned body." + exit 1 + fi + jq -r '[.pull_request.title, .pull_request.body, + .issue.title, .issue.body, + .comment.body] + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh new file mode 100755 index 0000000..a0b421f --- /dev/null +++ b/scripts/public-repo-guard/body-policy.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# WAVE public-repo BODY policy — the internal-leak gate for PR/issue/comment text. +# +# Companion to content-policy.sh. That script scans the published working TREE; +# this one scans the other half of a public repo's surface: pull-request titles +# and bodies, issue bodies, and comment bodies. Those are equally world-readable +# and, until this script existed, were scanned by NOTHING server-side. That gap +# was not theoretical — a PR was merged whose wrangler.toml was correctly BLOCKED +# for naming a private repo while the PR body named the same repo, with more +# operational detail attached, and sailed through. +# +# Usage: scripts/public-repo-guard/body-policy.sh +# holds the untrusted text, already materialized to disk. It is passed as +# a PATH and only ever read — the body is never interpolated into a command line +# or an environment variable, so no amount of shell metacharacters in a PR body +# can influence what runs here. +# +# Exit: 0 clean · 1 blocking violation · 2 scanner error (fail closed). +# +# Allowlisting: a line carrying `guard:allow ` is exempt (an accidental +# leak never carries the marker; a deliberate one is visible in a public diff), as +# is any line matching the ABOUT-THE-CONTROL allowlist below. +set -uo pipefail + +FILE="${1:-}" +[[ -n "$FILE" && -f "$FILE" ]] || { echo "::error::body-policy: usage: body-policy.sh "; exit 2; } +command -v rg >/dev/null 2>&1 || { echo "::error::body-policy: ripgrep (rg) required"; exit 2; } + +VIOLATIONS=0 + +# Lines that TALK ABOUT the control rather than leaking through it. Without this, +# the gate blocks its own pull requests and every security discussion — the +# self-referential trap that gets a gate switched off. Ported verbatim in intent +# from the client-side gate's allowlist, which was built for exactly this. +ABOUT_THE_CONTROL='(public-repo-guard|body-policy|content-policy|public-github-write-gate|\bNDA\s+(gate|guard|policy|denylist|sweep|scan|hook)\b|\bno\s+NDA\b|responsib\w*\s+disclos|SECURITY\.md)' + +# check +check() { + local sev="$1" name="$2" re="$3" why="$4" + [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } + # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes + # because its scanner broke is worse than no gate: it reports success. + local raw rc + raw="$(rg -nP --no-filename -- "$re" "$FILE" 2>/dev/null)"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) scanning rule '$name' — failing closed." + exit 2 + fi + # Filter with rg, not grep: BSD/macOS grep has no -P, so a `grep -P` allowlist + # silently errors out locally while working on GNU/CI — the gate would then + # disagree with itself depending on where it ran. rg is already required above. + local matches + matches="$(printf '%s' "$raw" \ + | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \ + | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + [[ -z "$matches" ]] && return 0 + local count; count="$(printf '%s\n' "$matches" | grep -c '')" + # Print the LINE NUMBER only — never the matched text. This annotation is itself + # world-readable, so echoing the hit would re-publish the very thing we caught. + echo "::group::[$sev] $name — $why" + printf '%s\n' "$matches" | sed -E 's/^([0-9]+):.*/ line \1: «match redacted — view the body to see it»/' + echo "::endgroup::" + if [[ "$sev" == "BLOCK" ]]; then + echo "::error title=public-repo-guard ($name)::$why — $count occurrence(s) in the title/body. Edit the body to remove it, then re-run." + VIOLATIONS=$((VIOLATIONS+1)) + else + echo "::warning title=public-repo-guard ($name)::$why — $count occurrence(s) (non-blocking; review)." + fi +} + +# --- Credential formats — never legitimate in prose -------------------------- +check BLOCK stripe-live-key '(sk|rk)_live_[A-Za-z0-9]{16,}' 'Live Stripe secret/restricted key' +check BLOCK stripe-account 'acct_[A-Za-z0-9]{16,}' 'Live Stripe account ID — financial infra, never publish' +check BLOCK anthropic-key 'sk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{20,}' 'Real Anthropic API/admin key' +check BLOCK github-pat 'github_pat_[A-Za-z0-9_]{30,}' 'GitHub fine-grained PAT' +check BLOCK supabase-pat 'sbp_[a-f0-9]{40}' 'Supabase personal access token' +check BLOCK aws-akid 'AKIA[0-9A-Z]{16}' 'AWS access key ID' +check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Embedded private key material' + +# --- Infrastructure identifiers ---------------------------------------------- +# shellcheck disable=SC2016 # $CLOUDFLARE_ACCOUNT_ID is literal guidance text +check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead' +check BLOCK internal-ip '100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]{1,3}\.[0-9]{1,3}' 'Internal Tailscale-CGNAT IP (100.64.0.0/10) — internal fleet address' +# shellcheck disable=SC2016 # $HOME is literal guidance text +check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' + +# --- Self-identified internal material --------------------------------------- +# USE vs MENTION. A body that SAYS "internal-only" is leaking; a body that QUOTES +# the phrase is describing a policy — including this one. The lookarounds exempt a +# marker wrapped in straight, smart, or backtick quotes. +# +# Not hypothetical: the first run of this job failed on its own pull request, +# because a review bot had edited the PR body to summarize the change and its +# summary quoted the phrase verbatim. The line-level allowlist could not help — +# that line named no gate. Only use-vs-mention separates the two. +# +# A quoted marker is also a trivial bypass, and that is an accepted trade. The +# threat here is the ACCIDENTAL paste; a deliberate evader has easier routes, and +# `guard:allow ` already exists as the honest, visible one. +check BLOCK internal-marker '(?#260"). A gate that fires on all of +# those gets switched off, and then it protects nothing. +# +# So a bare mention stays silent. What fires is a private repo name within ~140 +# characters of INTERNAL OPERATIONAL DETAIL — a SCREAMING_CASE credential NAME, a +# secret-binding verb, a service binding, or a secret COUNT. That is the topology +# of what is wired to what, and it is the shape that actually leaked. +# +# Names are NOT hardcoded (this file is public); CI injects them via the +# GUARD_PRIVATE_REPOS variable. Unset locally → this check is skipped. +if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then + OPS_DETAIL='(?:[A-Z][A-Z0-9]*_(?:SECRET|TOKEN|KEY|PASSWORD)|wrangler\s+secret|secret\s+(?:is\s+)?(?:bound|binding|list)|(?:is\s+)?bound\s+on|service\s+binding|\d{2,}\s+secrets)' + _ALT='' + IFS=', ' read -r -a _PRIV <<< "$GUARD_PRIVATE_REPOS" + for _name in "${_PRIV[@]}"; do + [[ -z "$_name" ]] && continue + # Regex-escape so metacharacters in a name match literally. + _esc="$(printf '%s' "$_name" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g')" + _ALT="${_ALT:+$_ALT|}${_esc}" + done + if [[ -n "$_ALT" ]]; then + # Both orders: name-then-detail and detail-then-name. + check BLOCK private-repo-ops \ + "(?i)\\b(?:${_ALT})\\b[^\\n]{0,140}?\\b${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?:${_ALT})\\b" \ + 'A private WAVE repo named alongside internal operational detail (credential name, secret binding, or secret count) — the wiring topology is not public' + fi +fi + +if (( VIOLATIONS > 0 )); then + echo "::error::public-repo-guard: $VIOLATIONS blocking body-policy violation(s) — see annotations above." + exit 1 +fi +echo "public-repo-guard: body policy OK" diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh new file mode 100755 index 0000000..13cc9bf --- /dev/null +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Fixture tests for body-policy.sh. +# +# Deliberately fixture-only: the gate is NEVER proved by writing a real leak into a +# live public PR body, because doing so would publish the exact thing it guards. +# +# The negatives here are the load-bearing half. A leak gate that blocks everything +# is trivially "correct" and useless — it gets disabled within a week. The bare +# cross-reference case below is the one that keeps this gate deployable. +set -uo pipefail + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/body-policy.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# The names the real gate is configured with come from an org variable; the tests +# pin their own so they are hermetic and do not depend on CI configuration. +export GUARD_PRIVATE_REPOS="wave-gateway, wave-transports, agent-money" + +PASS=0; FAIL=0 + +# expect +expect() { + local want="$1" name="$2" body="$3" out rc + printf '%s\n' "$body" > "$TMP/body.txt" + out="$(bash "$SCRIPT" "$TMP/body.txt" 2>&1)"; rc=$? + if [[ "$rc" == "$want" ]]; then + PASS=$((PASS+1)); printf ' ok %s\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit %s, got %s\n%s\n' "$name" "$want" "$rc" "$out" + fi + # The annotation is world-readable; a hit must never echo the matched text. + if [[ "$rc" == 1 ]] && printf '%s' "$out" | grep -qF "$body"; then + FAIL=$((FAIL+1)); printf ' FAIL %s — LEAKED the matched text into the annotation\n' "$name" + fi +} + +echo "body-policy fixtures" + +# --- must BLOCK --------------------------------------------------------------- +expect 1 'private repo + credential name' \ + 'Flip is live: WAVE_VIEWPORT_LEASE_SECRET is bound on wave-gateway now.' +expect 1 'private repo + credential name, reverse order' \ + 'The MOQ_JOIN_SECRET was added; wave-transports picks it up on deploy.' +expect 1 'private repo + secret count' \ + 'wave-gateway went from 74 secrets to 75 after this change.' +expect 1 'private repo + service binding' \ + 'This adds a service binding from the worker to agent-money for settlement.' +expect 1 'operator home path' \ + 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) +expect 1 'internal-only marker' \ + 'Attaching the internal-only rollout plan for context.' +# Assembled at run time rather than written as a literal: a fixture that LOOKS like +# a live AWS key trips this repo's own pre-commit secret scanners (it did, on the +# first draft). Splitting the prefix keeps the fixture exercising the real regex +# without parking a credential-shaped string in source. +AKID_FIXTURE="AKI""A1234567890ABCDEF" +expect 1 'AWS access key id' \ + "The failing job had ${AKID_FIXTURE} configured." +expect 1 'internal tailscale IP' \ + 'It resolves to 100.71.4.19 from inside the fleet.' + +# --- must PASS (precision — these keep the gate deployable) ------------------- +expect 0 'bare private-repo cross-reference' \ + 'This is the companion change to wave-transports#260; merge that one first.' +expect 0 'two private repos, no operational detail' \ + 'Both wave-gateway and wave-transports will need a follow-up for this.' +expect 0 'credential NAME with no private repo nearby' \ + 'The handler now reads SOME_API_TOKEN from the environment instead of a literal.' +expect 0 'public runner path is not an operator path' \ + 'CI checks out to /home/runner/work/repo/repo before the scan runs.' # enforce-ignore (fixture) +expect 0 'talking about the control' \ + 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' +expect 0 'explicit guard:allow with a reason' \ + 'Example for the docs: wave-gateway holds EXAMPLE_SECRET — guard:allow documented-example' +expect 0 'ordinary clean body' \ + 'Bumps the draft revision and regenerates the fixtures. No behaviour change.' +# Regression: the first CI run of this job failed on its own PR, because a review +# bot edited the body to summarize the change and quoted the marker verbatim. +expect 0 'marker MENTIONED in straight quotes is a description' \ + 'Blocks infra identifiers and markers (account_id, home paths, "internal-only" text).' +expect 0 'marker MENTIONED in a code span' \ + 'The rule matches `internal-only` and `for internal use` in body text.' +expect 0 'marker MENTIONED in smart quotes' \ + 'Blocks operator home paths and “internal-only” text.' +expect 1 'marker USED unquoted still blocks' \ + 'Attaching the internal-only rollout plan; do not share outside the team.' + +# --- fail closed -------------------------------------------------------------- +# Invoked directly, not through expect(): expect() always materializes a file, so +# it cannot reach these paths. A gate that returns "OK" when it was handed nothing +# to scan is the failure mode this whole file exists to prevent. +for case in "no argument at all::" "nonexistent path::$TMP/does-not-exist.txt"; do + name="${case%%::*}"; arg="${case##*::}" + if [[ -n "$arg" ]]; then bash "$SCRIPT" "$arg" >/dev/null 2>&1; else bash "$SCRIPT" >/dev/null 2>&1; fi + rc=$? + if [[ "$rc" == 2 ]]; then + PASS=$((PASS+1)); printf ' ok %s → exit 2 (fails closed)\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit 2, got %s\n' "$name" "$rc" + fi +done + +echo " ---" +if (( FAIL > 0 )); then + echo " $PASS passed, $FAIL FAILED"; exit 1 +fi +echo " $PASS passed, 0 failed" From c2b1eaa709348968bbe54f4245012b84594c1371 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:29:42 +0000 Subject: [PATCH 02/12] fix: scope the ABOUT_THE_CONTROL allowlist to prose-shaped rules only Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 24 ++++++++++++++----- .../tests/body-policy.test.sh | 7 ++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index a0b421f..6fbcd3d 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -32,11 +32,20 @@ VIOLATIONS=0 # the gate blocks its own pull requests and every security discussion — the # self-referential trap that gets a gate switched off. Ported verbatim in intent # from the client-side gate's allowlist, which was built for exactly this. +# +# Scoped PER RULE, not globally: only the PROSE-shaped rules (internal-marker, +# private-repo-ops) accept it, because only those can fire on a sentence that +# merely discusses the gate. A credential-shaped VALUE is a leak no matter what +# else shares its line — "public-repo-guard caught AKIA…" still contains the key — +# so the credential and infrastructure rules ignore this list and only the +# explicit, visible `guard:allow ` can exempt them. ABOUT_THE_CONTROL='(public-repo-guard|body-policy|content-policy|public-github-write-gate|\bNDA\s+(gate|guard|policy|denylist|sweep|scan|hook)\b|\bno\s+NDA\b|responsib\w*\s+disclos|SECURITY\.md)' -# check +# check [about-ok] +# Pass "about-ok" as the 5th arg to additionally exempt lines matching +# ABOUT_THE_CONTROL. Only prose-shaped rules should opt in (see above). check() { - local sev="$1" name="$2" re="$3" why="$4" + local sev="$1" name="$2" re="$3" why="$4" about="${5:-}" [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes # because its scanner broke is worse than no gate: it reports success. @@ -51,8 +60,10 @@ check() { # disagree with itself depending on where it ran. rg is already required above. local matches matches="$(printf '%s' "$raw" \ - | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \ - | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' || true)" + if [[ "$about" == "about-ok" ]]; then + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + fi [[ -z "$matches" ]] && return 0 local count; count="$(printf '%s\n' "$matches" | grep -c '')" # Print the LINE NUMBER only — never the matched text. This annotation is itself @@ -97,7 +108,7 @@ check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'O # A quoted marker is also a trivial bypass, and that is an accepted trade. The # threat here is the ACCIDENTAL paste; a deliberate evader has easier routes, and # `guard:allow ` already exists as the honest, visible one. -check BLOCK internal-marker '(?" still contains the key — so naming the control must not exempt it. +expect 1 'credential still blocks on a line naming the control' \ + "public-repo-guard flagged ${AKID_FIXTURE} in the logs — see SECURITY.md." # --- must PASS (precision — these keep the gate deployable) ------------------- expect 0 'bare private-repo cross-reference' \ @@ -73,6 +78,8 @@ expect 0 'talking about the control' \ 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' expect 0 'explicit guard:allow with a reason' \ 'Example for the docs: wave-gateway holds EXAMPLE_SECRET — guard:allow documented-example' +expect 0 'credential on a control line passes only via explicit guard:allow' \ + "public-repo-guard docs cite ${AKID_FIXTURE} as the test fixture — guard:allow documented-fixture" expect 0 'ordinary clean body' \ 'Bumps the draft revision and regenerates the fixtures. No behaviour change.' # Regression: the first CI run of this job failed on its own PR, because a review From f51aeb54a1f17e339d8c1c4cfed4b9caafb29dec Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:33:05 +0000 Subject: [PATCH 03/12] fix: scope (?i) to repo names, synthetic fixture names, verify rg PCRE2 at install Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 22 +++++++++++--- scripts/public-repo-guard/body-policy.sh | 8 +++-- .../tests/body-policy.test.sh | 30 +++++++++++++------ 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index bba2f67..f68ea52 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -85,8 +85,15 @@ jobs: - name: gitleaks (secret scan — published tree) run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 - - name: Install ripgrep - run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # Both policy scripts scan with `rg -P`, which needs a PCRE2-enabled build. + # The apt package is not guaranteed to ship one, and a PCRE2-less rg makes + # every rule fail closed with an opaque per-rule error — so verify PCRE2 at + # install time, where the failure names the actual problem. + - name: Install ripgrep (PCRE2 required) + run: | + command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + rg --pcre2-version >/dev/null \ + || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; exit 1; } - name: content policy (WAVE trade-secret / internal-leak gate) env: @@ -132,8 +139,15 @@ jobs: sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false - - name: Install ripgrep - run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # Both policy scripts scan with `rg -P`, which needs a PCRE2-enabled build. + # The apt package is not guaranteed to ship one, and a PCRE2-less rg makes + # every rule fail closed with an opaque per-rule error — so verify PCRE2 at + # install time, where the failure names the actual problem. + - name: Install ripgrep (PCRE2 required) + run: | + command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + rg --pcre2-version >/dev/null \ + || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; exit 1; } # The body is read straight out of the event payload FILE and written to # another file. It is never interpolated into a run: block and never placed diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 6fbcd3d..4ecb9d0 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -136,9 +136,13 @@ if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then _ALT="${_ALT:+$_ALT|}${_esc}" done if [[ -n "$_ALT" ]]; then - # Both orders: name-then-detail and detail-then-name. + # Both orders: name-then-detail and detail-then-name. Case-insensitivity is + # scoped to the REPO NAME alternation only — (?i:...) not a global (?i) — so + # OPS_DETAIL keeps its deliberate SCREAMING_CASE requirement. A global flag + # made lowercase prose like "adds the api_key plumbing" count as operational + # detail, and a gate that blocks harmless text gets switched off. check BLOCK private-repo-ops \ - "(?i)\\b(?:${_ALT})\\b[^\\n]{0,140}?\\b${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?:${_ALT})\\b" \ + "\\b(?i:${_ALT})\\b[^\\n]{0,140}?\\b${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?i:${_ALT})\\b" \ 'A private WAVE repo named alongside internal operational detail (credential name, secret binding, or secret count) — the wiring topology is not public' \ about-ok fi diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index b63a71b..a1a7c05 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -14,8 +14,12 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT # The names the real gate is configured with come from an org variable; the tests -# pin their own so they are hermetic and do not depend on CI configuration. -export GUARD_PRIVATE_REPOS="wave-gateway, wave-transports, agent-money" +# pin their own so they are hermetic and do not depend on CI configuration. The +# pinned names are deliberately SYNTHETIC: this file is world-readable and the +# tree scanners exclude scripts/public-repo-guard/**, so a real private-repo name +# written here would be published unscanned — the exact leak the gate exists to +# block. Never swap these for real names. +export GUARD_PRIVATE_REPOS="acme-private-gateway, acme-private-transports, acme-private-billing" PASS=0; FAIL=0 @@ -39,13 +43,17 @@ echo "body-policy fixtures" # --- must BLOCK --------------------------------------------------------------- expect 1 'private repo + credential name' \ - 'Flip is live: WAVE_VIEWPORT_LEASE_SECRET is bound on wave-gateway now.' + 'Flip is live: WAVE_VIEWPORT_LEASE_SECRET is bound on acme-private-gateway now.' expect 1 'private repo + credential name, reverse order' \ - 'The MOQ_JOIN_SECRET was added; wave-transports picks it up on deploy.' + 'The MOQ_JOIN_SECRET was added; acme-private-transports picks it up on deploy.' expect 1 'private repo + secret count' \ - 'wave-gateway went from 74 secrets to 75 after this change.' + 'acme-private-gateway went from 74 secrets to 75 after this change.' expect 1 'private repo + service binding' \ - 'This adds a service binding from the worker to agent-money for settlement.' + 'This adds a service binding from the worker to acme-private-billing for settlement.' +# Case-insensitivity is scoped to the repo NAME, not the whole pattern: a cased +# variant of the name still blocks, while OPS_DETAIL stays SCREAMING_CASE-only. +expect 1 'mixed-case private repo name + credential name still blocks' \ + 'ACME-Private-Gateway reads the ROLLOUT_SECRET at boot after this change.' expect 1 'operator home path' \ 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) expect 1 'internal-only marker' \ @@ -67,17 +75,21 @@ expect 1 'credential still blocks on a line naming the control' \ # --- must PASS (precision — these keep the gate deployable) ------------------- expect 0 'bare private-repo cross-reference' \ - 'This is the companion change to wave-transports#260; merge that one first.' + 'This is the companion change to acme-private-transports#260; merge that one first.' expect 0 'two private repos, no operational detail' \ - 'Both wave-gateway and wave-transports will need a follow-up for this.' + 'Both acme-private-gateway and acme-private-transports will need a follow-up for this.' expect 0 'credential NAME with no private repo nearby' \ 'The handler now reads SOME_API_TOKEN from the environment instead of a literal.' +# Regression for a global (?i) that made lowercase prose count as OPS_DETAIL: +# "api_key" is ordinary description, not a SCREAMING_CASE credential name. +expect 0 'lowercase credential-ish word near a private repo is prose' \ + 'Companion to acme-private-transports#260 — adds the api_key plumbing to the client.' expect 0 'public runner path is not an operator path' \ 'CI checks out to /home/runner/work/repo/repo before the scan runs.' # enforce-ignore (fixture) expect 0 'talking about the control' \ 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' expect 0 'explicit guard:allow with a reason' \ - 'Example for the docs: wave-gateway holds EXAMPLE_SECRET — guard:allow documented-example' + 'Example for the docs: acme-private-gateway holds EXAMPLE_SECRET — guard:allow documented-example' expect 0 'credential on a control line passes only via explicit guard:allow' \ "public-repo-guard docs cite ${AKID_FIXTURE} as the test fixture — guard:allow documented-fixture" expect 0 'ordinary clean body' \ From 4406ee340fa8ae87b56e1193f8d9a0aa1bcc047f Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:36:03 +0000 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20scan=20PR=20review=20and=20review-?= =?UTF-8?q?comment=20bodies=20=E2=80=94=20a=20review=20is=20the=20same=20p?= =?UTF-8?q?ublic=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index f68ea52..6618ea2 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -34,6 +34,13 @@ on: types: [opened, edited] issue_comment: types: [created, edited] + # A review's summary and its inline comments are the SAME public surface as an + # issue comment, but GitHub emits them on their own events — without these two, + # text posted in a review would never reach body-guard. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] push: branches: [main, master] workflow_dispatch: @@ -108,8 +115,8 @@ jobs: run: bash scripts/public-repo-guard/tests/body-policy.test.sh # The other half of a public repo's surface. `guard` above scans the published - # TREE; a PR/issue/comment BODY is just as world-readable and, until this job, - # was scanned by nothing server-side. That gap was real, not theoretical: a PR + # TREE; a PR/issue/comment/review BODY is just as world-readable and, until this + # job, was scanned by nothing server-side. That gap was real, not theoretical: a PR # was blocked for naming a private repo in wrangler.toml while the very same # name, with more operational detail attached, sat unchallenged in its body. # @@ -119,7 +126,12 @@ jobs: # can stop that class before publication. body-guard: name: Body content policy - if: github.event_name == 'pull_request' || github.event_name == 'issues' || github.event_name == 'issue_comment' + if: >- + github.event_name == 'pull_request' + || github.event_name == 'issues' + || github.event_name == 'issue_comment' + || github.event_name == 'pull_request_review' + || github.event_name == 'pull_request_review_comment' concurrency: # Keyed on the specific PR / comment / issue rather than github.ref, because # issue events all report the default branch and a ref-keyed group would let @@ -160,18 +172,19 @@ jobs: # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and # report a pass. If the event schema ever moves, this job must go red # rather than become a green rubber stamp over an unscanned body. - if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment")' "$GITHUB_EVENT_PATH")" != "true" ]; then - echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment object — refusing to report a pass on an unscanned body." + if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment") or has("review")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment/review object — refusing to report a pass on an unscanned body." exit 1 fi jq -r '[.pull_request.title, .pull_request.body, .issue.title, .issue.body, + .review.body, .comment.body] | map(select(. != null)) | join("\n")' \ "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" - - name: body policy (PR / issue / comment text) + - name: body policy (PR / issue / comment / review text) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" From 3f607732307b6a9d2fde472f7168060a3f49174d Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:44:00 +0000 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20drop=20the=20leading=20\b=20before?= =?UTF-8?q?=20OPS=5FDETAIL=20=E2=80=94=20it=20barred=20multi-segment=20cre?= =?UTF-8?q?dential=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 9 ++++++++- scripts/public-repo-guard/tests/body-policy.test.sh | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 4ecb9d0..656e576 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -141,8 +141,15 @@ if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then # OPS_DETAIL keeps its deliberate SCREAMING_CASE requirement. A global flag # made lowercase prose like "adds the api_key plumbing" count as operational # detail, and a gate that blocks harmless text gets switched off. + # + # No \b in front of OPS_DETAIL in either order: underscore is a word + # character, so [A-Z][A-Z0-9]*_ anchored at a word boundary can never reach + # the final segment of a multi-part name like WAVE_VIEWPORT_LEASE_SECRET + # (the only \b is before WAVE, and [A-Z0-9]* cannot cross underscores). A + # leading \b silently limited the name-then-detail order to two-segment + # credential names while the reverse order matched all of them. check BLOCK private-repo-ops \ - "\\b(?i:${_ALT})\\b[^\\n]{0,140}?\\b${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?i:${_ALT})\\b" \ + "\\b(?i:${_ALT})\\b[^\\n]{0,140}?${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?i:${_ALT})\\b" \ 'A private WAVE repo named alongside internal operational detail (credential name, secret binding, or secret count) — the wiring topology is not public' \ about-ok fi diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index a1a7c05..b11dfe0 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -42,10 +42,17 @@ expect() { echo "body-policy fixtures" # --- must BLOCK --------------------------------------------------------------- +# No secondary trigger in this body: the multi-segment credential NAME is the +# only OPS_DETAIL present, so this fixture proves the credential-name branch +# itself fires in the name-then-detail order. An earlier wording also said +# "is bound on", which independently satisfied OPS_DETAIL and kept this test +# green while a leading \b silently broke the branch it exists to prove. expect 1 'private repo + credential name' \ - 'Flip is live: WAVE_VIEWPORT_LEASE_SECRET is bound on acme-private-gateway now.' + 'Flip is live: acme-private-gateway now reads WAVE_VIEWPORT_LEASE_SECRET at boot.' expect 1 'private repo + credential name, reverse order' \ 'The MOQ_JOIN_SECRET was added; acme-private-transports picks it up on deploy.' +expect 1 'private repo + secret-binding phrase' \ + 'Flip is live: the lease secret is bound on acme-private-gateway now.' expect 1 'private repo + secret count' \ 'acme-private-gateway went from 74 secrets to 75 after this change.' expect 1 'private repo + service binding' \ From 066920e8b7c2b8fde92ebc81705f9d20ccf4a534 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:49:36 +0000 Subject: [PATCH 06/12] docs: the install manifest names all five files the workflow executes Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 6618ea2..18005b2 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,11 +13,13 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all four files together: +# To install on a new repo, copy all five files together: # .github/workflows/public-repo-guard.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh # scripts/public-repo-guard/body-policy.sh +# scripts/public-repo-guard/tests/body-policy.test.sh (run unconditionally as a +# self-test step below — omit it and the gate fails on every push) # # Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The # goal is "what is public right now is clean", so a shallow checkout is sufficient. From 6e05711df0554137321b4752e5b6bdde713187c5 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:00:55 +0000 Subject: [PATCH 07/12] fix(guard): fail closed in CI when GUARD_PRIVATE_REPOS is empty, parse newline-separated lists Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 31 +++++++++++-- .../tests/body-policy.test.sh | 44 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 656e576..0d05a81 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -124,18 +124,41 @@ check BLOCK internal-marker '(? +# Same contract as expect(), but runs the script under an explicit environment +# instead of the file-level export, so these cases stay hermetic no matter where +# the suite itself runs (CI exports GITHUB_ACTIONS=true; a laptop does not). +expect_with_env() { + local want="$1" name="$2" body="$3"; shift 3 + printf '%s\n' "$body" > "$TMP/body.txt" + local rc + env "$@" bash "$SCRIPT" "$TMP/body.txt" >/dev/null 2>&1; rc=$? + if [[ "$rc" == "$want" ]]; then + PASS=$((PASS+1)); printf ' ok %s\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s: want exit %s, got %s\n' "$name" "$want" "$rc" + fi +} + +# A newline-separated variable is the natural way to enter a multi-line Actions +# variable, and `read` without -d '' stopped at the first line, silently leaving +# every later name unguarded. The fixture leaks the THIRD name: it only blocks +# if the whole value was parsed. +expect_with_env 1 'newline-separated repo list still guards names after line one' \ + 'Flip is live: acme-private-billing now reads WAVE_VIEWPORT_LEASE_SECRET at boot.' \ + GUARD_PRIVATE_REPOS=$'acme-private-gateway\nacme-private-transports\nacme-private-billing' + +# In CI an empty GUARD_PRIVATE_REPOS is a misconfiguration, not a pass: the +# workflow always injects the env line, so empty means the org variable is gone. +# The gate must go red rather than run with its headline rule silently disabled. +expect_with_env 2 'empty GUARD_PRIVATE_REPOS in CI fails closed' \ + 'An ordinary clean body with nothing to hide.' \ + GUARD_PRIVATE_REPOS= GITHUB_ACTIONS=true +expect_with_env 2 'separator-only GUARD_PRIVATE_REPOS in CI fails closed' \ + 'An ordinary clean body with nothing to hide.' \ + GUARD_PRIVATE_REPOS=' , , ' GITHUB_ACTIONS=true + +# Locally the documented skip still applies: developers do not carry the org's +# private-repo list, and the other rules still run. +expect_with_env 0 'empty GUARD_PRIVATE_REPOS locally skips the rule, still passes' \ + 'An ordinary clean body with nothing to hide.' \ + GUARD_PRIVATE_REPOS= GITHUB_ACTIONS= +expect_with_env 1 'empty GUARD_PRIVATE_REPOS locally still enforces other rules' \ + 'Attaching the internal-only rollout plan for context.' \ + GUARD_PRIVATE_REPOS= GITHUB_ACTIONS= + # --- fail closed -------------------------------------------------------------- # Invoked directly, not through expect(): expect() always materializes a file, so # it cannot reach these paths. A gate that returns "OK" when it was handed nothing From f4b6d0ef09cf39f02b2f62e917644a68d7b81145 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:11:11 +0000 Subject: [PATCH 08/12] ci: isolate body-guard concurrency per comment and rescan tree on base retarget Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 18005b2..6fe8df1 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -62,9 +62,13 @@ jobs: name: Secrets + content policy # Skips issue/comment events (the tree scan has nothing to say about a comment, # and the org should not pay for a gitleaks run every time anyone posts one) and - # skips `edited` (a title or body edit does not change the tree). + # skips `edited` (a title or body edit does not change the tree) EXCEPT a + # base-branch retarget: that also arrives as `edited` (payload carries + # `changes.base`), and it DOES change the merge ref this job scans, with no + # `synchronize` guaranteed to follow. if: >- - (github.event_name == 'pull_request' && github.event.action != 'edited') + (github.event_name == 'pull_request' + && (github.event.action != 'edited' || github.event.changes.base != null)) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' concurrency: @@ -135,14 +139,23 @@ jobs: || github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment' concurrency: - # Keyed on the specific PR / comment / issue rather than github.ref, because - # issue events all report the default branch and a ref-keyed group would let - # two comments cancel each other, leaving one unscanned. + # Keyed on the specific comment / review / PR / issue rather than github.ref, + # because issue events all report the default branch and a ref-keyed group + # would let two comments cancel each other, leaving one unscanned. + # + # The comment/review id comes FIRST, before the PR number: review events also + # carry a top-level `pull_request` object, so a PR-number-first key put a + # batch review's N inline comments (N near-simultaneous events, each with + # DIFFERENT text) into one group, and GitHub keeps only ONE pending run per + # group: queueing a new run cancels the previously pending one, permanently + # dropping those bodies unscanned. Per-id keying isolates each comment and + # review; only re-edits of the SAME body (PR/issue title+body) share a group, + # where the surviving run scans the newest version. # # cancel-in-progress is deliberately FALSE. Every version of a body deserves a # verdict, the job is seconds long, and a cancelled check-run lingers on the # commit and makes an otherwise-green PR look broken. - group: public-repo-guard-body-${{ github.event.pull_request.number || github.event.comment.id || github.event.issue.number || github.ref }} + group: public-repo-guard-body-${{ github.event.comment.id || github.event.review.id || github.event.pull_request.number || github.event.issue.number || github.ref }} cancel-in-progress: false runs-on: ubuntu-latest steps: From 6c14407d59c78baca2a0869e1f05d84fb8df385f Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:20:43 +0000 Subject: [PATCH 09/12] fix: parse newline-separated GUARD_PRIVATE_REPOS in tree scan; correct body-guard merge-gate claim Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 9 ++++++--- scripts/public-repo-guard/content-policy.sh | 11 +++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 6fe8df1..19a3f82 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -126,10 +126,13 @@ jobs: # was blocked for naming a private repo in wrangler.toml while the very same # name, with more operational detail attached, sat unchallenged in its body. # - # Honest about what it can and cannot do. On a PR this PREVENTS the merge. On an + # Honest about what it can and cannot do. On a PR this check gates the merge + # only once the org ruleset requires the `Body content policy` context; the + # ruleset today requires only `Secrets + content policy` (the `guard` job), so + # until that rollout lands a failing body scan is advisory on PRs too. On an # issue or comment the text is already public the moment it posts, so this is - # detection — it tells us to go redact, fast. Only the client-side pre-write hook - # can stop that class before publication. + # detection either way: it tells us to go redact, fast. Only the client-side + # pre-write hook can stop that class before publication. body-guard: name: Body content policy if: >- diff --git a/scripts/public-repo-guard/content-policy.sh b/scripts/public-repo-guard/content-policy.sh index 4f67fb8..4479406 100755 --- a/scripts/public-repo-guard/content-policy.sh +++ b/scripts/public-repo-guard/content-policy.sh @@ -97,9 +97,16 @@ check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' # Private WAVE repo/product names that must never appear in a public tree. The # names are NOT hardcoded here (this file is itself public) — they are supplied # at run time via GUARD_PRIVATE_REPOS (CI injects it from an org-level Actions -# variable), comma- or space-separated. Unset locally → this check is skipped. +# variable), comma-, space-, or newline-separated. Unset locally → this check is +# skipped. if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then - IFS=', ' read -r -a _PRIV <<< "$GUARD_PRIVATE_REPOS" + # `-d ''` reads the WHOLE value, not just its first line: a plain `read` stops + # at the first newline, so a newline-separated Actions variable (the natural + # shape for a multi-line list) silently dropped every name after the first. + # Same parse as body-policy.sh, so both gates agree on the same org variable. + # (read hits EOF looking for the NUL delimiter and returns nonzero after + # filling the array; benign.) + IFS=$', \t\n' read -r -d '' -a _PRIV <<< "$GUARD_PRIVATE_REPOS" || true for _name in "${_PRIV[@]}"; do [[ -z "$_name" ]] && continue # Regex-escape the name so metacharacters in a repo name (., -, etc.) match From 4a64de04cc4f641099f692cd9df0fbc300aa7d00 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:32:08 +0000 Subject: [PATCH 10/12] ci: split body-guard into its own workflow so the required check never reports skipped A job-level 'if' skip still creates a check run (conclusion 'skipped') on the PR head SHA, required checks treat 'skipped' as satisfied, and the newest check run of a name wins: with 'edited' (and review events) in the tree gate's trigger list, editing a PR description or posting a review re-reported 'Secrets + content policy' as skipped, laundering an earlier genuine failure on the same commit into a mergeable PR. The tree workflow now triggers only on events it really scans, running a full scan on 'edited' (preserving base-retarget coverage), and body-guard moves to public-repo-guard-body.yml with the issue/comment/review triggers. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard-body.yml | 122 +++++++++++++++++ .github/workflows/public-repo-guard.yml | 136 +++---------------- 2 files changed, 140 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/public-repo-guard-body.yml diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml new file mode 100644 index 0000000..f4e19f0 --- /dev/null +++ b/.github/workflows/public-repo-guard-body.yml @@ -0,0 +1,122 @@ +name: public-repo-guard-body + +# The other half of a public repo's surface. public-repo-guard.yml scans the +# published TREE; a PR/issue/comment/review BODY is just as world-readable and, +# until this job, was scanned by nothing server-side. That gap was real, not +# theoretical: a PR was blocked for naming a private repo in wrangler.toml while +# the very same name, with more operational detail attached, sat unchallenged in +# its body. +# +# A SEPARATE workflow file on purpose, not a second job next to the tree gate. +# The tree gate's `Secrets + content policy` check is required by the org ruleset, +# and a job-level `if` skip still creates a check run (conclusion `skipped`) on +# the PR head SHA. Required checks treat `skipped` as satisfied and the newest +# check run of a name wins, so if the tree gate's workflow also fired on +# issue/comment/review events (only to skip the tree job), any comment or review +# on a PR would launder an earlier genuine tree-scan FAILURE on the same commit +# into a mergeable PR. Keeping these triggers in their own file means the tree +# gate never emits a skipped verdict. Copy BOTH workflow files together; the full +# six-file install manifest lives in public-repo-guard.yml's header. +# +# Honest about what it can and cannot do. On a PR this check gates the merge +# only once the org ruleset requires the `Body content policy` context; the +# ruleset today requires only `Secrets + content policy` (the tree gate), so +# until that rollout lands a failing body scan is advisory on PRs too. On an +# issue or comment the text is already public the moment it posts, so this is +# detection either way: it tells us to go redact, fast. Only the client-side +# pre-write hook can stop that class before publication. + +on: + # `edited` matters as much as `opened`: a body can be made to leak long after the + # PR is first raised, and until this workflow covered it, nothing ever re-scanned. + # `synchronize` keeps a `Body content policy` verdict on every head SHA, so the + # check is already present on each commit if the ruleset rollout makes it required. + pull_request: + types: [opened, edited, reopened, synchronize] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + # A review's summary and its inline comments are the SAME public surface as an + # issue comment, but GitHub emits them on their own events — without these two, + # text posted in a review would never reach body-guard. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. +permissions: + contents: read + +jobs: + # No job-level `if`: every event in the trigger list above gets a real scan, so + # this job never reports a skipped conclusion over an unscanned body. + body-guard: + name: Body content policy + concurrency: + # Keyed on the specific comment / review / PR / issue rather than github.ref, + # because issue events all report the default branch and a ref-keyed group + # would let two comments cancel each other, leaving one unscanned. + # + # The comment/review id comes FIRST, before the PR number: review events also + # carry a top-level `pull_request` object, so a PR-number-first key put a + # batch review's N inline comments (N near-simultaneous events, each with + # DIFFERENT text) into one group, and GitHub keeps only ONE pending run per + # group: queueing a new run cancels the previously pending one, permanently + # dropping those bodies unscanned. Per-id keying isolates each comment and + # review; only re-edits of the SAME body (PR/issue title+body) share a group, + # where the surviving run scans the newest version. + # + # cancel-in-progress is deliberately FALSE. Every version of a body deserves a + # verdict, the job is seconds long, and a cancelled check-run lingers on the + # commit and makes an otherwise-green PR look broken. + group: public-repo-guard-body-${{ github.event.comment.id || github.event.review.id || github.event.pull_request.number || github.event.issue.number || github.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Only the gate's own scripts are needed — no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + # The policy script scans with `rg -P`, which needs a PCRE2-enabled build. + # The apt package is not guaranteed to ship one, and a PCRE2-less rg makes + # every rule fail closed with an opaque per-rule error — so verify PCRE2 at + # install time, where the failure names the actual problem. + - name: Install ripgrep (PCRE2 required) + run: | + command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + rg --pcre2-version >/dev/null \ + || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; exit 1; } + + # The body is read straight out of the event payload FILE and written to + # another file. It is never interpolated into a run: block and never placed + # in an environment variable, so shell metacharacters in a hostile PR body + # have nothing to act on. jq is preinstalled on the GitHub-hosted images. + - name: Materialize the untrusted title/body to a file + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bodyscan" + # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and + # report a pass. If the event schema ever moves, this job must go red + # rather than become a green rubber stamp over an unscanned body. + if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment") or has("review")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment/review object — refusing to report a pass on an unscanned body." + exit 1 + fi + jq -r '[.pull_request.title, .pull_request.body, + .issue.title, .issue.body, + .review.body, + .comment.body] + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment / review text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 19a3f82..6abd952 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,8 +13,10 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all five files together: +# To install on a new repo, copy all six files together: # .github/workflows/public-repo-guard.yml +# .github/workflows/public-repo-guard-body.yml (the body gate; a SEPARATE file +# on purpose, see that file's header for why it must not live here) # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh # scripts/public-repo-guard/body-policy.sh @@ -28,49 +30,34 @@ name: public-repo-guard # path glob to a repo-root `.guardignore`, or extend the repo-local `.gitleaks.toml`. on: - # `edited` matters as much as `opened`: a body can be made to leak long after the - # PR is first raised, and until this workflow covered it, nothing ever re-scanned. + # This file's trigger list must contain ONLY events on which `guard` performs a + # real scan. The job is the required `Secrets + content policy` check, and a + # job-level `if` skip still creates a check run (conclusion `skipped`) on the + # PR head SHA. Required checks treat `skipped` as satisfied and the newest check + # run of a name wins, so one skipped run would launder an earlier genuine FAILURE + # on the same commit into a mergeable PR. That is why the body gate, with its + # issue/comment/review triggers, lives in public-repo-guard-body.yml instead of + # here, and why `edited` below runs a full scan rather than being filtered out: + # a title edit re-scans an unchanged merge ref (same verdict, small cost), and a + # base-branch retarget (which also arrives as `edited`, with no `synchronize` + # guaranteed to follow) re-scans the NEW merge ref it actually changes. pull_request: types: [opened, edited, reopened, synchronize] - issues: - types: [opened, edited] - issue_comment: - types: [created, edited] - # A review's summary and its inline comments are the SAME public surface as an - # issue comment, but GitHub emits them on their own events — without these two, - # text posted in a review would never reach body-guard. - pull_request_review: - types: [submitted, edited] - pull_request_review_comment: - types: [created, edited] push: branches: [main, master] workflow_dispatch: # `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get -# a write token or repo secrets just because a gate wanted to read its body. +# a write token or repo secrets just because a gate wanted to read its tree. permissions: contents: read -# Concurrency is per JOB, not per workflow: the two jobs want opposite behaviour. -# A workflow-level group would force one policy on both, and it showed: rapid body -# edits cancelled the tree job over and over, and every cancelled check-run stays -# attached to the commit, so the PR reported UNSTABLE while the live runs were green. - jobs: guard: name: Secrets + content policy - # Skips issue/comment events (the tree scan has nothing to say about a comment, - # and the org should not pay for a gitleaks run every time anyone posts one) and - # skips `edited` (a title or body edit does not change the tree) EXCEPT a - # base-branch retarget: that also arrives as `edited` (payload carries - # `changes.base`), and it DOES change the merge ref this job scans, with no - # `synchronize` guaranteed to follow. - if: >- - (github.event_name == 'pull_request' - && (github.event.action != 'edited' || github.event.changes.base != null)) - || github.event_name == 'push' - || github.event_name == 'workflow_dispatch' + # No job-level `if`: every event this workflow triggers on gets a real scan. + # See the trigger comment above: a skipped run of a required check is not + # neutral, it REPLACES a failing verdict on the same head SHA. concurrency: group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -119,90 +106,3 @@ jobs: # rather than by a leak. - name: body policy self-test (fixtures) run: bash scripts/public-repo-guard/tests/body-policy.test.sh - - # The other half of a public repo's surface. `guard` above scans the published - # TREE; a PR/issue/comment/review BODY is just as world-readable and, until this - # job, was scanned by nothing server-side. That gap was real, not theoretical: a PR - # was blocked for naming a private repo in wrangler.toml while the very same - # name, with more operational detail attached, sat unchallenged in its body. - # - # Honest about what it can and cannot do. On a PR this check gates the merge - # only once the org ruleset requires the `Body content policy` context; the - # ruleset today requires only `Secrets + content policy` (the `guard` job), so - # until that rollout lands a failing body scan is advisory on PRs too. On an - # issue or comment the text is already public the moment it posts, so this is - # detection either way: it tells us to go redact, fast. Only the client-side - # pre-write hook can stop that class before publication. - body-guard: - name: Body content policy - if: >- - github.event_name == 'pull_request' - || github.event_name == 'issues' - || github.event_name == 'issue_comment' - || github.event_name == 'pull_request_review' - || github.event_name == 'pull_request_review_comment' - concurrency: - # Keyed on the specific comment / review / PR / issue rather than github.ref, - # because issue events all report the default branch and a ref-keyed group - # would let two comments cancel each other, leaving one unscanned. - # - # The comment/review id comes FIRST, before the PR number: review events also - # carry a top-level `pull_request` object, so a PR-number-first key put a - # batch review's N inline comments (N near-simultaneous events, each with - # DIFFERENT text) into one group, and GitHub keeps only ONE pending run per - # group: queueing a new run cancels the previously pending one, permanently - # dropping those bodies unscanned. Per-id keying isolates each comment and - # review; only re-edits of the SAME body (PR/issue title+body) share a group, - # where the surviving run scans the newest version. - # - # cancel-in-progress is deliberately FALSE. Every version of a body deserves a - # verdict, the job is seconds long, and a cancelled check-run lingers on the - # commit and makes an otherwise-green PR look broken. - group: public-repo-guard-body-${{ github.event.comment.id || github.event.review.id || github.event.pull_request.number || github.event.issue.number || github.ref }} - cancel-in-progress: false - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Only the gate's own scripts are needed — no reason to pay for the whole - # tree on every comment. - sparse-checkout: scripts/public-repo-guard - sparse-checkout-cone-mode: false - - # Both policy scripts scan with `rg -P`, which needs a PCRE2-enabled build. - # The apt package is not guaranteed to ship one, and a PCRE2-less rg makes - # every rule fail closed with an opaque per-rule error — so verify PCRE2 at - # install time, where the failure names the actual problem. - - name: Install ripgrep (PCRE2 required) - run: | - command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) - rg --pcre2-version >/dev/null \ - || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; exit 1; } - - # The body is read straight out of the event payload FILE and written to - # another file. It is never interpolated into a run: block and never placed - # in an environment variable, so shell metacharacters in a hostile PR body - # have nothing to act on. jq is preinstalled on the GitHub-hosted images. - - name: Materialize the untrusted title/body to a file - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/bodyscan" - # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and - # report a pass. If the event schema ever moves, this job must go red - # rather than become a green rubber stamp over an unscanned body. - if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment") or has("review")' "$GITHUB_EVENT_PATH")" != "true" ]; then - echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment/review object — refusing to report a pass on an unscanned body." - exit 1 - fi - jq -r '[.pull_request.title, .pull_request.body, - .issue.title, .issue.body, - .review.body, - .comment.body] - | map(select(. != null)) | join("\n")' \ - "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" - echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" - - - name: body policy (PR / issue / comment / review text) - env: - GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" From 8a8c43bd076e2e123a31020d38c57db4c9266cf5 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:43:04 +0000 Subject: [PATCH 11/12] fix(ci): pin ripgrep, drop persisted checkout credentials, stop edited events cancelling tree scans Co-authored-by: Codesmith --- .github/workflows/public-repo-guard-body.yml | 22 ++++++++++---- .github/workflows/public-repo-guard.yml | 30 ++++++++++++++++---- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml index f4e19f0..0ba07e0 100644 --- a/.github/workflows/public-repo-guard-body.yml +++ b/.github/workflows/public-repo-guard-body.yml @@ -82,14 +82,26 @@ jobs: # tree on every comment. sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false + # The gate only reads the tree; no later step may find the workflow token + # in .git/config — matches every other workflow in this repo. + persist-credentials: false # The policy script scans with `rg -P`, which needs a PCRE2-enabled build. - # The apt package is not guaranteed to ship one, and a PCRE2-less rg makes - # every rule fail closed with an opaque per-rule error — so verify PCRE2 at - # install time, where the failure names the actual problem. - - name: Install ripgrep (PCRE2 required) + # Neither the runner image nor Ubuntu's apt package guarantees one, and a + # PCRE2-less rg makes every rule fail closed — so install the pinned upstream + # musl release (built with PCRE2), checksum-verified like gitleaks in the tree + # gate, and verify PCRE2 at install time, where the failure names the problem. + - name: Install ripgrep (pinned + checksum-verified, PCRE2 required) + env: + RIPGREP_VERSION: "14.1.1" + RIPGREP_SHA256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" run: | - command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + curl -fsSL --proto '=https' --tlsv1.2 -o ripgrep.tar.gz \ + "https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl.tar.gz" + echo "${RIPGREP_SHA256} ripgrep.tar.gz" | sha256sum -c - + tar -xzf ripgrep.tar.gz --strip-components=1 "ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl/rg" + sudo install -m 0755 rg /usr/local/bin/rg + rm -f rg ripgrep.tar.gz rg --pcre2-version >/dev/null \ || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; exit 1; } diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 6abd952..d874038 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -60,10 +60,19 @@ jobs: # neutral, it REPLACES a failing verdict on the same head SHA. concurrency: group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # `edited` fires on every title/body edit (bots edit PR bodies constantly) and + # re-scans an UNCHANGED merge ref — so it must not cancel an in-flight run on + # the same head SHA, or every body edit leaves a cancelled check-run attached + # to the commit. `synchronize` (and push) DO supersede the previous merge ref, + # so those still cancel the now-stale scan. + cancel-in-progress: ${{ github.event.action != 'edited' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The gate only reads the tree; no later step may find the workflow token + # in .git/config — matches every other workflow in this repo. + persist-credentials: false # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release @@ -86,12 +95,21 @@ jobs: run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 # Both policy scripts scan with `rg -P`, which needs a PCRE2-enabled build. - # The apt package is not guaranteed to ship one, and a PCRE2-less rg makes - # every rule fail closed with an opaque per-rule error — so verify PCRE2 at - # install time, where the failure names the actual problem. - - name: Install ripgrep (PCRE2 required) + # Neither the runner image nor Ubuntu's apt package guarantees one, and a + # PCRE2-less rg makes every rule fail closed — so install the pinned upstream + # musl release (built with PCRE2), checksum-verified like gitleaks above, and + # verify PCRE2 at install time, where the failure names the actual problem. + - name: Install ripgrep (pinned + checksum-verified, PCRE2 required) + env: + RIPGREP_VERSION: "14.1.1" + RIPGREP_SHA256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" run: | - command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + curl -fsSL --proto '=https' --tlsv1.2 -o ripgrep.tar.gz \ + "https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl.tar.gz" + echo "${RIPGREP_SHA256} ripgrep.tar.gz" | sha256sum -c - + tar -xzf ripgrep.tar.gz --strip-components=1 "ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl/rg" + sudo install -m 0755 rg /usr/local/bin/rg + rm -f rg ripgrep.tar.gz rg --pcre2-version >/dev/null \ || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; exit 1; } From d2e41394798ed1d09f6dbe3d99c9f9333ac7c05f Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:52:06 +0000 Subject: [PATCH 12/12] fix: fail closed in the tree gate on empty GUARD_PRIVATE_REPOS; match cased internal markers Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 8 ++++++- scripts/public-repo-guard/content-policy.sh | 24 ++++++++++++++++--- .../tests/body-policy.test.sh | 10 ++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 0d05a81..cf5cacc 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -108,7 +108,13 @@ check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'O # A quoted marker is also a trivial bypass, and that is an accepted trade. The # threat here is the ACCIDENTAL paste; a deliberate evader has easier routes, and # `guard:allow ` already exists as the honest, visible one. -check BLOCK internal-marker '(?