From 902db8e982c03ed23b467eec42a6951091e91d58 Mon Sep 17 00:00:00 2001 From: yakimoto <66892052+yakimoto@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:20:01 -0400 Subject: [PATCH 01/13] 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 a60bbec5ddc38d0163496d2b18853c5f997e3a63 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:30:37 +0000 Subject: [PATCH 02/13] ci: harden body-guard against PR-controlled gate code and allowlist laundering Address review findings: - body-guard now checks out the gate scripts from the base branch tip (pull_request.base.sha), not the PR merge ref, so a fork PR can no longer rewrite body-policy.sh to pass its own body. - The ABOUT_THE_CONTROL allowlist no longer applies to credential-format rules: a line naming the gate can still carry a live key. Only the explicit guard:allow marker exempts those. Fixture added. - Preflight the ripgrep build for PCRE2 (script + both install steps) so a non-PCRE2 rg fails with the real cause instead of an opaque scanner error. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 25 ++++++++++-- scripts/public-repo-guard/body-policy.sh | 40 +++++++++++++------ .../tests/body-policy.test.sh | 4 ++ 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index bba2f67..66a599d 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -85,8 +85,13 @@ 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) + - name: Install ripgrep (PCRE2 build required) + run: | + command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # Both policy scripts depend on `rg -P`. A ripgrep built without PCRE2 + # (some distro packages) makes every scan exit 2, which fails closed but + # reads as "scanner broke" with no hint why. Surface the real cause here. + rg --pcre2-version - name: content policy (WAVE trade-secret / internal-leak gate) env: @@ -127,13 +132,25 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # The gate must run from TRUSTED history. The default checkout on a + # pull_request event is the PR merge ref, so the PR's own copy of + # body-policy.sh would be the one that runs — a fork PR could edit it to + # exit 0 and wave its own body through. Unlike the tree scan (which must + # look at PR content), the body gate has no reason to execute anything + # PR-controlled: pin to the base branch tip. On issues/issue_comment + # events there is no PR ref and github.ref is already the default branch. + ref: ${{ github.event.pull_request.base.sha || github.ref }} # 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) + - name: Install ripgrep (PCRE2 build required) + run: | + command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # body-policy.sh depends on `rg -P`; fail HERE with the real cause rather + # than mid-scan with a bare "scanner broke" (see the tree job's step). + rg --pcre2-version # 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 a0b421f..375c546 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -19,12 +19,18 @@ # # 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. +# is any line matching the ABOUT-THE-CONTROL allowlist below — except for the +# credential-format rules, which honour only `guard:allow`: naming the gate must +# never launder a live key past it. 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; } +# Every rule below uses `rg -P`. A ripgrep built without PCRE2 (some distro +# packages) exits 2 on the first rule, which fails closed but reads as "scanner +# broke" with no hint why. Name the real cause up front — still exit 2 either way. +printf 'x' | rg -qP 'x' 2>/dev/null || { echo "::error::body-policy: this ripgrep build lacks PCRE2 (-P) support — install a PCRE2-enabled rg (the upstream release binaries are). Failing closed."; exit 2; } VIOLATIONS=0 @@ -34,9 +40,14 @@ VIOLATIONS=0 # 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 [exempt] +# exempt defaults to "about-exempt": lines matching ABOUT_THE_CONTROL are +# skipped. Credential-format rules pass "no-about-exempt" — a line that names +# the gate can still carry a live key, and talking ABOUT the control must never +# launder a credential PAST it. Only `guard:allow ` (explicit, visible +# in the public body) exempts those. check() { - local sev="$1" name="$2" re="$3" why="$4" + local sev="$1" name="$2" re="$3" why="$4" exempt="${5:-about-exempt}" [[ -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 +62,11 @@ 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 [[ "$exempt" == "about-exempt" ]]; 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 @@ -69,13 +83,15 @@ check() { } # --- 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' +# no-about-exempt: a credential-shaped string is a credential no matter what else +# its line says — mentioning "body-policy" next to a live key must not pass it. +check BLOCK stripe-live-key '(sk|rk)_live_[A-Za-z0-9]{16,}' 'Live Stripe secret/restricted key' no-about-exempt +check BLOCK stripe-account 'acct_[A-Za-z0-9]{16,}' 'Live Stripe account ID — financial infra, never publish' no-about-exempt +check BLOCK anthropic-key 'sk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{20,}' 'Real Anthropic API/admin key' no-about-exempt +check BLOCK github-pat 'github_pat_[A-Za-z0-9_]{30,}' 'GitHub fine-grained PAT' no-about-exempt +check BLOCK supabase-pat 'sbp_[a-f0-9]{40}' 'Supabase personal access token' no-about-exempt +check BLOCK aws-akid 'AKIA[0-9A-Z]{16}' 'AWS access key ID' no-about-exempt +check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Embedded private key material' no-about-exempt # --- Infrastructure identifiers ---------------------------------------------- # shellcheck disable=SC2016 # $CLOUDFLARE_ACCOUNT_ID is literal guidance text diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 13cc9bf..2161425 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -57,6 +57,10 @@ expect 1 'internal-only marker' \ AKID_FIXTURE="AKI""A1234567890ABCDEF" expect 1 'AWS access key id' \ "The failing job had ${AKID_FIXTURE} configured." +# Regression: the ABOUT-THE-CONTROL allowlist must not launder credentials — a +# line that names the gate by name can still carry a live key, and must block. +expect 1 'credential on a line naming the gate still blocks' \ + "body-policy flagged ${AKID_FIXTURE} here and public-repo-guard was right to." expect 1 'internal tailscale IP' \ 'It resolves to 100.71.4.19 from inside the fleet.' From a4f76724c992019b1e0a0caaf3efffb23297c381 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:32:29 +0000 Subject: [PATCH 03/13] ci: scope (?i) to repo names and drop the about-allowlist for infra identifiers Address review findings: - The private-repo-ops pattern opens with a global (?i) so repo names match case-insensitively, but that flag reached OPS_DETAIL and defeated its SCREAMING_CASE requirement: lowercase identifiers like api_key next to a repo name blocked clean PRs. The credential-name branch is now (?-i:...). - Infra identifiers (Cloudflare account_id, Tailscale IP, operator path) now ignore the ABOUT_THE_CONTROL allowlist like the credential formats do: a real internal IP on a line naming the gate is still a leak. - Fixtures for both. Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 16 ++++++++++++---- .../public-repo-guard/tests/body-policy.test.sh | 9 +++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 375c546..6ffad73 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -94,11 +94,14 @@ check BLOCK aws-akid 'AKIA[0-9A-Z]{16}' 'AW check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Embedded private key material' no-about-exempt # --- Infrastructure identifiers ---------------------------------------------- +# no-about-exempt for the same reason as the credential formats: a REAL internal +# IP or operator path on a line that happens to name the gate is still a leak. +# A discussion of these rules that needs a live example has `guard:allow`. # 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' +check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead' no-about-exempt +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' no-about-exempt # 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' +check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' no-about-exempt # --- Self-identified internal material --------------------------------------- # USE vs MENTION. A body that SAYS "internal-only" is leaking; a body that QUOTES @@ -131,7 +134,12 @@ check BLOCK internal-marker '(? Date: Thu, 6 Aug 2026 17:33:42 +0000 Subject: [PATCH 04/13] ci: install list must include the self-test the workflow runs (5 files, not 4) Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 66a599d..6548468 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,11 +13,12 @@ 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 # # 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 052c985adc23da72f51bb6862cbab67eeff84659 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:40:23 +0000 Subject: [PATCH 05/13] ci: don't fail body-guard on its own bootstrap PR The trusted-ref checkout means the installing PR's base has no body-policy.sh yet. That is exactly the pre-install state (no gate), and a PR head cannot cause it, so skip with a visible warning instead of exiting 127; the gate is live from the first event after merge. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 6548468..d14f3ab 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -178,4 +178,15 @@ jobs: - 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" + run: | + # BOOTSTRAP: the gate runs from the TRUSTED ref (see the checkout above), + # so on the very PR that first installs it the base branch has no script + # yet. That state is indistinguishable from "gate not installed" — where + # every repo was before — and a PR head cannot cause it, since only the + # base branch is checked out here. Skip with a visible warning rather + # than fail; the gate is live from the first event after the merge. + if [ ! -f scripts/public-repo-guard/body-policy.sh ]; then + echo "::warning title=public-repo-guard (body-guard)::body-policy.sh is not on the trusted ref yet (bootstrap PR installing the gate) — body scan skipped; it activates once this merges." + exit 0 + fi + bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" From c7bd36ec09669e3271a67301f770e23aea04b75a Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:41:57 +0000 Subject: [PATCH 06/13] ci: scan only the event's own text; warn when the private-repo rule is off - issue_comment runs now scan just the new comment body. Re-scanning the parent issue's title/body meant one old violation failed every future comment, with line numbers into a concatenated buffer that maps to nothing. Each text is judged exactly once, on the event that changes it. - An empty GUARD_PRIVATE_REPOS (fork runs may not receive org variables) now emits a visible warning instead of a quietly green check with the highest-value rule off. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index d14f3ab..abd740d 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -168,9 +168,15 @@ jobs: 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] + # Scan ONLY the object this event is about. An issue_comment payload also + # carries the parent issue's title/body, but re-scanning those on every + # comment would let one old violation in the issue body fail every future + # comment on it, with line numbers into a concatenated buffer that maps + # to nothing. The issue body already got its own verdict on its own + # `issues` event; each text gets judged exactly once, when it changes. + jq -r 'if has("comment") then [.comment.body] + elif has("pull_request") then [.pull_request.title, .pull_request.body] + else [.issue.title, .issue.body] end | 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" @@ -189,4 +195,11 @@ jobs: echo "::warning title=public-repo-guard (body-guard)::body-policy.sh is not on the trusted ref yet (bootstrap PR installing the gate) — body scan skipped; it activates once this merges." exit 0 fi + # An empty GUARD_PRIVATE_REPOS silently disables the private-repo + # proximity rule (the script deliberately skips it, for local runs). + # In CI that silence is a hazard — fork-triggered runs may not receive + # org variables — so make "off" visible instead of quietly green. + if [ -z "${GUARD_PRIVATE_REPOS:-}" ]; then + echo "::warning title=public-repo-guard (body-guard)::GUARD_PRIVATE_REPOS is empty for this run — the private-repo proximity rule is DISABLED. If this is a fork PR, org variables may not be exposed to it." + fi bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" From 3f28880775a58a724ed74d5a8226724af34f1119 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:51:03 +0000 Subject: [PATCH 07/13] ci: fail closed when the script vanishes post-install; catch SHOUTED banners Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 27 +++++++++++++------ scripts/public-repo-guard/body-policy.sh | 8 ++++-- .../tests/body-policy.test.sh | 11 ++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index abd740d..da471de 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -142,8 +142,12 @@ jobs: # events there is no PR ref and github.ref is already the default branch. ref: ${{ github.event.pull_request.base.sha || github.ref }} # 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 + # tree on every comment. The workflow file rides along solely so the + # bootstrap check below can tell "gate not installed on this ref yet" + # apart from "installed, but its script went missing" (which must fail). + sparse-checkout: | + scripts/public-repo-guard + .github/workflows/public-repo-guard.yml sparse-checkout-cone-mode: false - name: Install ripgrep (PCRE2 build required) @@ -187,13 +191,20 @@ jobs: run: | # BOOTSTRAP: the gate runs from the TRUSTED ref (see the checkout above), # so on the very PR that first installs it the base branch has no script - # yet. That state is indistinguishable from "gate not installed" — where - # every repo was before — and a PR head cannot cause it, since only the - # base branch is checked out here. Skip with a visible warning rather - # than fail; the gate is live from the first event after the merge. + # yet. But "script missing" alone must never mean "pass" — a rename, a + # sparse-checkout drift, or a partial install would turn the gate into a + # permanent green rubber stamp. Disambiguate with the workflow file on + # the SAME trusted ref: absent too → the gate simply is not installed + # there yet (bootstrap — where every repo was before), skip with a + # visible warning. Present without its script → broken install; fail + # closed like every other error path. if [ ! -f scripts/public-repo-guard/body-policy.sh ]; then - echo "::warning title=public-repo-guard (body-guard)::body-policy.sh is not on the trusted ref yet (bootstrap PR installing the gate) — body scan skipped; it activates once this merges." - exit 0 + if [ ! -f .github/workflows/public-repo-guard.yml ]; then + echo "::warning title=public-repo-guard (body-guard)::body-policy.sh is not on the trusted ref yet (bootstrap PR installing the gate) — body scan skipped; it activates once this merges." + exit 0 + fi + echo "::error title=public-repo-guard (body-guard)::the trusted ref carries this workflow but NOT scripts/public-repo-guard/body-policy.sh — broken install (rename, sparse-checkout drift, or partial copy). Refusing to report a pass on an unscanned body." + exit 1 fi # An empty GUARD_PRIVATE_REPOS silently disables the private-repo # proximity rule (the script deliberately skips it, for local runs). diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 6ffad73..b6d18ef 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -101,7 +101,7 @@ check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Em check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead' no-about-exempt 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' no-about-exempt # 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' no-about-exempt +check BLOCK abs-user-path '/(Users|home)/(?!runner/)[A-Za-z][A-Za-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' no-about-exempt # --- Self-identified internal material --------------------------------------- # USE vs MENTION. A body that SAYS "internal-only" is leaking; a body that QUOTES @@ -116,7 +116,11 @@ 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 '(? Date: Thu, 6 Aug 2026 17:58:22 +0000 Subject: [PATCH 08/13] ci: fall back to the pinned upstream rg when the image build lacks PCRE2 Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 37 +++++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index da471de..3359dc0 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -87,11 +87,24 @@ jobs: run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 - name: Install ripgrep (PCRE2 build 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) # Both policy scripts depend on `rg -P`. A ripgrep built without PCRE2 - # (some distro packages) makes every scan exit 2, which fails closed but - # reads as "scanner broke" with no hint why. Surface the real cause here. + # (some distro packages) makes every scan exit 2 — fail closed, but a + # runner-image change shipping such a build would leave the gate + # permanently red with no remediation. Use the preinstalled rg when it + # has PCRE2; otherwise fall back to the upstream release binary (always + # PCRE2-enabled), pinned and checksum-verified like gitleaks above. + if ! command -v rg >/dev/null || ! rg --pcre2-version >/dev/null 2>&1; then + 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 + fi rg --pcre2-version - name: content policy (WAVE trade-secret / internal-leak gate) @@ -151,10 +164,22 @@ jobs: sparse-checkout-cone-mode: false - name: Install ripgrep (PCRE2 build 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) - # body-policy.sh depends on `rg -P`; fail HERE with the real cause rather - # than mid-scan with a bare "scanner broke" (see the tree job's step). + # body-policy.sh depends on `rg -P`. Same fallback as the tree job's + # step: a PCRE2-less preinstalled rg must not leave the body gate + # permanently red — install the pinned, checksum-verified upstream + # release binary (always PCRE2-enabled) instead. + if ! command -v rg >/dev/null || ! rg --pcre2-version >/dev/null 2>&1; then + 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 + fi rg --pcre2-version # The body is read straight out of the event payload FILE and written to From 31526af31146bd672211530fedc3a19400a52fb6 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:07:10 +0000 Subject: [PATCH 09/13] test: use fictional private-repo and secret names in body-policy fixtures Co-authored-by: Codesmith --- .../tests/body-policy.test.sh | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index fdfae10..24c8195 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -15,7 +15,9 @@ 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" +# Deliberately fictional (this file is public and exempt from the tree gate): +# any names exercise the same regexes, so no real private repo name belongs here. +export GUARD_PRIVATE_REPOS="example-private-one, example-private-two, example-private-three" PASS=0; FAIL=0 @@ -39,13 +41,13 @@ 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: EXAMPLE_LEASE_SECRET is bound on example-private-one now.' expect 1 'private repo + credential name, reverse order' \ - 'The MOQ_JOIN_SECRET was added; wave-transports picks it up on deploy.' + 'The EXAMPLE_JOIN_SECRET was added; example-private-two picks it up on deploy.' expect 1 'private repo + secret count' \ - 'wave-gateway went from 74 secrets to 75 after this change.' + 'example-private-one 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 example-private-three 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' \ @@ -77,9 +79,9 @@ expect 1 'internal tailscale IP' \ # --- 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 example-private-two#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 example-private-one and example-private-two 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' \ @@ -90,11 +92,11 @@ expect 0 'talking about the control' \ # not leak into the SCREAMING_CASE credential-name branch — everyday lowercase # identifiers near a private repo are not operational topology. expect 0 'lowercase identifier near a private repo is not ops detail' \ - 'We updated wave-gateway to read the api_key from config now.' + 'We updated example-private-one to read the api_key from config now.' expect 0 'another lowercase identifier near a private repo' \ - 'Fix wave-transports: the retry_token handling was wrong.' + 'Fix example-private-two: the retry_token handling was wrong.' 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: example-private-one 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 From 212859dbda36207a6dd169e334c8f95051e6626b Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:14:23 +0000 Subject: [PATCH 10/13] ci: reset bash's command hash after installing fallback ripgrep Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 3359dc0..b8395ff 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -104,6 +104,10 @@ jobs: 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 + # Drop the cached path of the preinstalled rg (hashed by the probe + # above), or the check below would re-run the old PCRE2-less binary + # and fail the step right after a successful install. + hash -r fi rg --pcre2-version @@ -179,6 +183,10 @@ jobs: 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 + # Drop the cached path of the preinstalled rg (hashed by the probe + # above), or the check below would re-run the old PCRE2-less binary + # and fail the step right after a successful install. + hash -r fi rg --pcre2-version From c0c85f63627cbbee83d2691e6374306e939637cf Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:24:24 +0000 Subject: [PATCH 11/13] fix: catch multi-segment credential names after a private repo name Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 12 ++++++++++++ scripts/public-repo-guard/body-policy.sh | 7 ++++++- scripts/public-repo-guard/tests/body-policy.test.sh | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index b8395ff..2a79f48 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -157,6 +157,18 @@ jobs: # look at PR content), the body gate has no reason to execute anything # PR-controlled: pin to the base branch tip. On issues/issue_comment # events there is no PR ref and github.ref is already the default branch. + # + # Known limit, considered and accepted: on pull_request the workflow FILE + # itself comes from the PR head, so this trusted-ref design holds only for + # PRs that do not edit this workflow. That edit is loudly visible in the + # PR's own diff, and the same move neuters the REQUIRED tree check too + # (which must run PR content, so pull_request_target cannot protect it) — + # the class is closed org-side, by requiring review on workflow changes, + # not by a per-job trigger swap. Swapping this job to pull_request_target + # would even WEAKEN the gate: its run would attach a skipped + # "Secrets + content policy" check to the head SHA, and a skipped + # required check satisfies branch protection — a workflow-deleting PR, + # unmergeable today ("Expected — waiting"), would become mergeable. ref: ${{ github.event.pull_request.base.sha || github.ref }} # Only the gate's own scripts are needed — no reason to pay for the whole # tree on every comment. The workflow file rides along solely so the diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index b6d18ef..f8afe10 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -143,7 +143,12 @@ if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then # SCREAMING_CASE requirement is the whole point of this branch — without the # override, everyday lowercase identifiers like `api_key` or `retry_token` # near a repo name would read as operational detail and block clean PRs. - OPS_DETAIL='(?:(?-i:[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)' + # The leading segment allows underscores ([A-Z][A-Z0-9_]*): a multi-segment name + # like EXAMPLE_LEASE_SECRET must match from its FIRST character. Without them the + # only viable sub-match (LEASE_SECRET) starts right after an underscore — a word + # character — so the \b the composed pattern requires before OPS_DETAIL can never + # sit there, and the name-then-detail order silently missed every such name. + OPS_DETAIL='(?:(?-i:[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 diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 24c8195..4bbf699 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -44,6 +44,12 @@ expect 1 'private repo + credential name' \ 'Flip is live: EXAMPLE_LEASE_SECRET is bound on example-private-one now.' expect 1 'private repo + credential name, reverse order' \ 'The EXAMPLE_JOIN_SECRET was added; example-private-two picks it up on deploy.' +# Regression: a multi-segment name after the repo name. The credential branch once +# barred underscores from its leading segment, so the only sub-match started right +# after an underscore — where the \b the pattern requires can never sit — and the +# name-then-detail order missed every multi-segment credential name. +expect 1 'private repo then multi-segment credential name' \ + 'example-private-one now reads EXAMPLE_LEASE_SECRET at boot.' expect 1 'private repo + secret count' \ 'example-private-one went from 74 secrets to 75 after this change.' expect 1 'private repo + service binding' \ @@ -95,6 +101,10 @@ expect 0 'lowercase identifier near a private repo is not ops detail' \ 'We updated example-private-one to read the api_key from config now.' expect 0 'another lowercase identifier near a private repo' \ 'Fix example-private-two: the retry_token handling was wrong.' +# Multi-segment lowercase stays clean too: underscores in the leading segment must +# widen only the SCREAMING_CASE branch, never re-admit everyday identifiers. +expect 0 'multi-segment lowercase identifier near a private repo' \ + 'example-private-one now reads the lease_rotation_key from config.' expect 0 'explicit guard:allow with a reason' \ 'Example for the docs: example-private-one holds EXAMPLE_SECRET — guard:allow documented-example' expect 0 'ordinary clean body' \ From 3b997a70e7732c83e80e4ca6e2752382f6b98c39 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:31:33 +0000 Subject: [PATCH 12/13] fix: run the tree scan on PR edited events so a body edit cannot skip the required check Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 2a79f48..7271eb0 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -53,10 +53,19 @@ 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). + # and the org should not pay for a gitleaks run every time anyone posts one). + # + # Deliberately does NOT skip `edited`, even though a title/body edit cannot + # change the tree. This workflow subscribes to `edited` for the body gate, and + # a job-level skip still emits a check run under this job's name — the ONE + # required check — with conclusion `skipped` on the PR head SHA. A skipped + # required check satisfies branch protection, so skipping here would let a + # body edit re-report a FAILED tree scan as satisfied on the same commit, + # without a re-scan. The redundant scan is the price of an honest verdict. + # (Issue/comment skips are safe: those check runs attach to the default + # branch tip, never to a PR head, so no merge gate ever reads them.) if: >- - (github.event_name == 'pull_request' && github.event.action != 'edited') + github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' concurrency: From 75f4f7a2f4560869c214f924e14cd7734d4873e1 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 22:01:20 +0000 Subject: [PATCH 13/13] fix: bootstrap-skip body-guard when the trusted-ref workflow predates the body gate Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 7271eb0..977f731 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -181,8 +181,10 @@ jobs: ref: ${{ github.event.pull_request.base.sha || github.ref }} # Only the gate's own scripts are needed — no reason to pay for the whole # tree on every comment. The workflow file rides along solely so the - # bootstrap check below can tell "gate not installed on this ref yet" - # apart from "installed, but its script went missing" (which must fail). + # bootstrap check below can tell "the body gate is not installed on this + # ref yet" apart from "installed, but its script went missing" (which + # must fail) — by looking at whether the trusted-ref copy of this + # workflow references body-policy.sh at all. sparse-checkout: | scripts/public-repo-guard .github/workflows/public-repo-guard.yml @@ -248,16 +250,19 @@ jobs: # yet. But "script missing" alone must never mean "pass" — a rename, a # sparse-checkout drift, or a partial install would turn the gate into a # permanent green rubber stamp. Disambiguate with the workflow file on - # the SAME trusted ref: absent too → the gate simply is not installed - # there yet (bootstrap — where every repo was before), skip with a - # visible warning. Present without its script → broken install; fail - # closed like every other error path. + # the SAME trusted ref: if that copy never mentions body-policy.sh (the + # file is absent, or it is an OLDER tree-only version of this gate — + # exactly what a repo upgrading from the 27-repo shape has on its base + # branch), the body gate simply is not installed there yet (bootstrap), + # skip with a visible warning. If the trusted-ref workflow DOES + # reference the script and the script is missing → broken install; + # fail closed like every other error path. if [ ! -f scripts/public-repo-guard/body-policy.sh ]; then - if [ ! -f .github/workflows/public-repo-guard.yml ]; then - echo "::warning title=public-repo-guard (body-guard)::body-policy.sh is not on the trusted ref yet (bootstrap PR installing the gate) — body scan skipped; it activates once this merges." + if ! grep -q 'scripts/public-repo-guard/body-policy\.sh' .github/workflows/public-repo-guard.yml 2>/dev/null; then + echo "::warning title=public-repo-guard (body-guard)::body-policy.sh is not on the trusted ref yet (bootstrap PR installing or upgrading the gate) — body scan skipped; it activates once this merges." exit 0 fi - echo "::error title=public-repo-guard (body-guard)::the trusted ref carries this workflow but NOT scripts/public-repo-guard/body-policy.sh — broken install (rename, sparse-checkout drift, or partial copy). Refusing to report a pass on an unscanned body." + echo "::error title=public-repo-guard (body-guard)::the trusted ref's workflow references scripts/public-repo-guard/body-policy.sh but the script is NOT there — broken install (rename, sparse-checkout drift, or partial copy). Refusing to report a pass on an unscanned body." exit 1 fi # An empty GUARD_PRIVATE_REPOS silently disables the private-repo