From 48959437b4ff4e2a88cd679fb2210e4f99fbda36 Mon Sep 17 00:00:00 2001 From: yakimoto <66892052+yakimoto@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:19:27 -0400 Subject: [PATCH 01/11] 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 c6d097aff3887676f51acce2de3de4c348594b81 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:29:26 +0000 Subject: [PATCH 02/11] fix(ci): scope (?i) to repo names and never skip the tree scan on edited PRs Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 14 ++++++++++---- scripts/public-repo-guard/body-policy.sh | 7 +++++-- .../public-repo-guard/tests/body-policy.test.sh | 7 +++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index bba2f67..5b8d097 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -51,11 +51,17 @@ permissions: 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). + # Skips issue/comment events only (the tree scan has nothing to say about a + # comment, and their check-runs attach to the default branch, never a PR head, + # so that skip cannot mask a PR verdict). `edited` PR events deliberately DO + # run even though a body edit does not change the tree: a job-level skip still + # posts a fresh check-run with conclusion `skipped` against the same head SHA, + # and branch protection reads the LATEST check-run of a given name and treats + # `skipped` as passing — so skipping on `edited` let a body edit silently + # replace a failing tree verdict with a mergeable one. Re-scanning an + # unchanged tree is the cheap side of that trade. 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: diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index a0b421f..36d1898 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -125,9 +125,12 @@ 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 with (?i:...) — a leading (?i) would + # spill across the whole pattern and make the deliberately SCREAMING_CASE-only + # OPS_DETAIL match everyday prose like `api_key`. 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' fi fi diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 13cc9bf..74795bf 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -67,6 +67,13 @@ 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.' +# Regression: a leading (?i) once spilled case-insensitivity across the whole +# private-repo-ops pattern, so a lowercase everyday word like `api_key` counted +# as operational detail and blocked any body that also named a private repo. +expect 0 'lowercase identifier near a private repo is not operational detail' \ + 'Fix wave-gateway: the api_key header is now lowercase.' +expect 0 'lowercase token word near a private repo is not operational detail' \ + 'Docs for wave-transports: pass your access_token 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' \ From afd55b89374a604884f7bfb4a1e1e10b144b92ed Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:32:23 +0000 Subject: [PATCH 03/11] fix(ci): credential rules ignore allowlists; install manifest lists the test file Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 4 +- scripts/public-repo-guard/body-policy.sh | 39 +++++++++++++------ .../tests/body-policy.test.sh | 5 +++ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 5b8d097..f9fb496 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 (the last is executed +# by the guard job's self-test step — omitting it fails the workflow at run time): # .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. diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 36d1898..1a4c117 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -19,7 +19,10 @@ # # 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. EXCEPTION: rules +# declared `--no-exempt` (the credential formats) ignore both allowlists — a real +# secret is never legitimate in prose, so no marker or discussion context can +# make publishing one acceptable. set -uo pipefail FILE="${1:-}" @@ -34,8 +37,13 @@ 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 [--no-exempt] +# --no-exempt: skip the guard:allow / ABOUT_THE_CONTROL line exemptions. For +# credential formats: a live key is a leak even on a line that names this gate +# or carries an allow marker, so no line-level context may suppress the hit. check() { + local exempt=1 + [[ "$1" == "--no-exempt" ]] && { exempt=0; shift; } 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 @@ -50,9 +58,13 @@ check() { # 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)" + if (( exempt )); then + matches="$(printf '%s' "$raw" \ + | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \ + | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + else + matches="$raw" + 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 +81,16 @@ 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-exempt: these formats match REAL secrets, not discussion of secrets, so +# neither `guard:allow` nor talking about the control may suppress a hit. If a +# doc genuinely needs a key-shaped example, truncate it below the rule's floor. +check --no-exempt BLOCK stripe-live-key '(sk|rk)_live_[A-Za-z0-9]{16,}' 'Live Stripe secret/restricted key' +check --no-exempt BLOCK stripe-account 'acct_[A-Za-z0-9]{16,}' 'Live Stripe account ID — financial infra, never publish' +check --no-exempt BLOCK anthropic-key 'sk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{20,}' 'Real Anthropic API/admin key' +check --no-exempt BLOCK github-pat 'github_pat_[A-Za-z0-9_]{30,}' 'GitHub fine-grained PAT' +check --no-exempt BLOCK supabase-pat 'sbp_[a-f0-9]{40}' 'Supabase personal access token' +check --no-exempt BLOCK aws-akid 'AKIA[0-9A-Z]{16}' 'AWS access key ID' +check --no-exempt BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Embedded private key material' # --- 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 74795bf..7a25241 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -57,6 +57,11 @@ expect 1 'internal-only marker' \ AKID_FIXTURE="AKI""A1234567890ABCDEF" expect 1 'AWS access key id' \ "The failing job had ${AKID_FIXTURE} configured." +# Credential rules are --no-exempt: no line-level context makes a live key OK. +expect 1 'guard:allow does NOT exempt a credential' \ + "Example key: ${AKID_FIXTURE} — guard:allow documented-example" +expect 1 'talking about the control does NOT exempt a credential' \ + "body-policy caught ${AKID_FIXTURE} in a comment last week." expect 1 'internal tailscale IP' \ 'It resolves to 100.71.4.19 from inside the fleet.' From a849089f253351af00a19583efabde8974c6195d Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:35:05 +0000 Subject: [PATCH 04/11] fix(ci): scan review bodies and inline review comments too Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 58 +++++++++++++++---------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index f9fb496..5cce4fd 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -36,6 +36,13 @@ on: types: [opened, edited] issue_comment: types: [created, edited] + # `issue_comment` only covers the top-level conversation. Inline diff comments + # and review summary bodies are separate surfaces, equally world-readable, and + # were the last body text nothing scanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] push: branches: [main, master] workflow_dispatch: @@ -53,19 +60,20 @@ permissions: jobs: guard: name: Secrets + content policy - # Skips issue/comment events only (the tree scan has nothing to say about a - # comment, and their check-runs attach to the default branch, never a PR head, - # so that skip cannot mask a PR verdict). `edited` PR events deliberately DO - # run even though a body edit does not change the tree: a job-level skip still - # posts a fresh check-run with conclusion `skipped` against the same head SHA, - # and branch protection reads the LATEST check-run of a given name and treats - # `skipped` as passing — so skipping on `edited` let a body edit silently + # Skips ONLY issues/issue_comment events (the tree scan has nothing to say + # about a comment, and their check-runs attach to the default branch, never a + # PR head, so that skip cannot mask a PR verdict). Every OTHER event runs the + # tree scan even when it cannot have changed the tree (`edited`, review + # events): those runs attach check-runs to the PR head SHA, a job-level skip + # still posts a fresh check-run with conclusion `skipped` there, and branch + # protection reads the LATEST check-run of a given name and treats `skipped` + # as passing — so skipping would let a body edit or review comment silently # replace a failing tree verdict with a mergeable one. Re-scanning an - # unchanged tree is the cheap side of that trade. + # unchanged tree is the cheap side of that trade. Written as a denylist so a + # future trigger fails toward scanning, not toward a green rubber stamp. if: >- - github.event_name == 'pull_request' - || github.event_name == 'push' - || github.event_name == 'workflow_dispatch' + github.event_name != 'issues' + && github.event_name != 'issue_comment' concurrency: group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -109,18 +117,23 @@ 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 - # was blocked for naming a private repo in wrangler.toml while the very same + # 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 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. + # issue, comment, or review 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' + 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 @@ -154,18 +167,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, - .comment.body] + .comment.body, + .review.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 a3aaf652e3e1380e45701fe842e97724f7f40cae Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:44:55 +0000 Subject: [PATCH 05/11] fix(ci): key tree-scan concurrency on the scanned SHA; run body-policy from the default branch Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 38 ++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 5cce4fd..74a2c1b 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -74,8 +74,13 @@ jobs: if: >- github.event_name != 'issues' && github.event_name != 'issue_comment' + # Keyed on the SHA being scanned, NOT the PR number: review events carry the + # PR's number too, so a number-keyed group let a review submitted mid-scan + # cancel the PR's in-flight tree scan. Two runs share a group only when they + # would scan the SAME tree (rapid body edits, review chatter on one head); + # a new push gets a new SHA, a new group, and a run nothing can cancel. concurrency: - group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} + group: public-repo-guard-tree-${{ github.event.pull_request.head.sha || github.sha }} cancel-in-progress: true runs-on: ubuntu-latest steps: @@ -146,10 +151,28 @@ jobs: cancel-in-progress: false runs-on: ubuntu-latest steps: + # The gate's script comes from the base repo's DEFAULT BRANCH, never from + # the PR merge ref: body-guard exists to grade untrusted text, and a fork + # PR that edits body-policy.sh (say, to `exit 0`) must not get to grade its + # own body with its own scanner. Nothing else is needed from the tree: the + # text under scan comes from the event payload, not the checkout. Only the + # gate's own scripts are fetched: no reason to pay for the whole tree on + # every comment. - 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. + ref: ${{ github.event.repository.default_branch }} + path: trusted + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + # Bootstrap only: on the PR that installs the gate, the default branch does + # not have body-policy.sh yet, so fall back to the PR's own copy. Once the + # bundle is merged the trusted copy always exists, this step never runs, + # and a fork PR's edits to the script are ignored. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: ${{ hashFiles('trusted/scripts/public-repo-guard/body-policy.sh') == '' }} + with: + path: bootstrap sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false @@ -182,4 +205,11 @@ jobs: - 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" + run: | + set -euo pipefail + script="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$script" ]; then + echo "::notice title=public-repo-guard (body-guard)::body-policy.sh is not on the default branch yet; bootstrap run using this PR's own copy." + script="bootstrap/scripts/public-repo-guard/body-policy.sh" + fi + bash "$script" "$RUNNER_TEMP/bodyscan/body.txt" From 5e2f376b8ad955af20640b5272afb2faafc7f242 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:55:23 +0000 Subject: [PATCH 06/11] ci: pin guard's gate executables to the default branch on review events Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 66 +++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 74a2c1b..aa9dd40 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -86,6 +86,60 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Review events are the one privileged path through this job: for a fork PR, + # `pull_request` runs with a fork-scoped read-only token, but + # `pull_request_review` / `pull_request_review_comment` run in the BASE + # repository's context (org/repo variables resolvable, base-repo token), + # while the checkout above still resolves the PR merge ref, i.e. the fork's + # code. The tree is only ever scanned as DATA, so that stays. But the gate's + # own executables (.gitleaks.toml, content-policy.sh, the self-test) must + # not be taken from the untrusted tree in that context, or a fork PR that + # edits them gains code execution in a base-repo run the moment a maintainer + # reviews it. On review events this step pins the bundle to the base repo's + # default branch, materialized OUTSIDE the workspace so the trusted copies + # are never themselves scanned as tree content. Every other event keeps the + # tree's own copies: `pull_request` is unprivileged for forks, `push` / + # `workflow_dispatch` only ever run base-repo code, and a PR that edits the + # gate must be tested against its own edits. + - name: Pin gate executables to the default branch (review events) + id: gate + env: + IS_REVIEW_EVENT: ${{ github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment' }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + if [ "$IS_REVIEW_EVENT" != "true" ]; then + echo "dir=." >> "$GITHUB_OUTPUT" + echo "selftest=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + trusted="$RUNNER_TEMP/trusted-gate" + mkdir -p "$trusted/scripts/public-repo-guard/tests" + git fetch --depth 1 origin "refs/heads/$DEFAULT_BRANCH" + # .gitleaks.toml and content-policy.sh are install PREREQUISITES of this + # workflow. If the default branch lacks either, refuse to run rather + # than fall back to executing the PR tree's copy in a privileged run. + for f in .gitleaks.toml scripts/public-repo-guard/content-policy.sh; do + if ! git show "FETCH_HEAD:$f" > "$trusted/$f" 2>/dev/null; then + echo "::error title=public-repo-guard (guard)::$f is missing from '$DEFAULT_BRANCH'; refusing to execute the PR tree's copy in a privileged review-event run." + exit 1 + fi + done + # Bootstrap only: on the PR that installs the gate, the default branch + # does not carry body-policy.sh or its fixtures yet. Skip the self-test + # here rather than run the PR's copy (that would reopen the exact hole + # this step closes); every pull_request event still runs the tree's own + # fixtures in an unprivileged context, so coverage is not lost. + selftest=true + for f in scripts/public-repo-guard/body-policy.sh scripts/public-repo-guard/tests/body-policy.test.sh; do + if ! git show "FETCH_HEAD:$f" > "$trusted/$f" 2>/dev/null; then + echo "::notice title=public-repo-guard (guard)::$f is not on '$DEFAULT_BRANCH' yet; skipping the body-policy self-test for this review-event run." + selftest=false + fi + done + echo "dir=$trusted" >> "$GITHUB_OUTPUT" + echo "selftest=$selftest" >> "$GITHUB_OUTPUT" + # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release # tarball's SHA-256 before extracting, so a tampered or MITM'd download can @@ -104,7 +158,9 @@ jobs: gitleaks version - name: gitleaks (secret scan — published tree) - run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 + env: + GATE_DIR: ${{ steps.gate.outputs.dir }} + run: gitleaks detect --no-git --source . --config "$GATE_DIR/.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) @@ -112,14 +168,18 @@ jobs: - name: content policy (WAVE trade-secret / internal-leak gate) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - run: bash scripts/public-repo-guard/content-policy.sh . + GATE_DIR: ${{ steps.gate.outputs.dir }} + run: bash "$GATE_DIR/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 + if: ${{ steps.gate.outputs.selftest == 'true' }} + env: + GATE_DIR: ${{ steps.gate.outputs.dir }} + run: bash "$GATE_DIR/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 From b29cf348c85901ee2a71c2ad70a4d158646eab14 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:05:07 +0000 Subject: [PATCH 07/11] ci: use synthetic fixture names and close the fork bootstrap fallback Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 27 ++++++++++++++----- .../tests/body-policy.test.sh | 26 ++++++++++-------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index aa9dd40..f35837a 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -226,11 +226,18 @@ jobs: sparse-checkout-cone-mode: false # Bootstrap only: on the PR that installs the gate, the default branch does - # not have body-policy.sh yet, so fall back to the PR's own copy. Once the - # bundle is merged the trusted copy always exists, this step never runs, - # and a fork PR's edits to the script are ignored. + # not have body-policy.sh yet, so fall back to the PR's own copy — but ONLY + # for a same-repo pull request. A same-repo author already has write access, + # so their copy grants nothing they do not have; a fork PR replacing + # body-policy.sh (say, with `exit 0`) must never get to grade its own body + # with its own scanner, even during the bootstrap window, so fork PRs (and + # every non-PR event) fail closed below instead of falling back. Once the + # bundle is merged the trusted copy always exists and this step never runs. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: ${{ hashFiles('trusted/scripts/public-repo-guard/body-policy.sh') == '' }} + if: >- + hashFiles('trusted/scripts/public-repo-guard/body-policy.sh') == '' + && github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository with: path: bootstrap sparse-checkout: scripts/public-repo-guard @@ -269,7 +276,15 @@ jobs: set -euo pipefail script="trusted/scripts/public-repo-guard/body-policy.sh" if [ ! -f "$script" ]; then - echo "::notice title=public-repo-guard (body-guard)::body-policy.sh is not on the default branch yet; bootstrap run using this PR's own copy." - script="bootstrap/scripts/public-repo-guard/body-policy.sh" + if [ -f "bootstrap/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::notice title=public-repo-guard (body-guard)::body-policy.sh is not on the default branch yet; bootstrap run using this same-repo PR's own copy." + script="bootstrap/scripts/public-repo-guard/body-policy.sh" + else + # No trusted copy and no same-repo bootstrap copy: this is a fork + # PR (or a non-PR event) during the bootstrap window. Refuse to + # grade a body with a scanner the untrusted tree supplies. + echo "::error title=public-repo-guard (body-guard)::body-policy.sh is not on the default branch and this event's tree is untrusted; refusing to execute the PR's own scanner. Merge the guard bundle to the default branch first." + exit 1 + fi fi bash "$script" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 7a25241..aa6e03f 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 +# names (and every credential name / count in the fixtures below) are DELIBERATELY +# synthetic: this file is public, and body-policy.sh's own rule is that real +# private-repo names are never hardcoded. The tests only need self-consistency +# between this variable and the fixture text. +export GUARD_PRIVATE_REPOS="example-private-a, example-private-b, example-private-c" PASS=0; FAIL=0 @@ -39,13 +43,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-a 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-b picks it up on deploy.' expect 1 'private repo + secret count' \ - 'wave-gateway went from 74 secrets to 75 after this change.' + 'example-private-a went from 12 secrets to 13 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-c.' 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,24 +71,24 @@ 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-b#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-a and example-private-b 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: a leading (?i) once spilled case-insensitivity across the whole # private-repo-ops pattern, so a lowercase everyday word like `api_key` counted # as operational detail and blocked any body that also named a private repo. expect 0 'lowercase identifier near a private repo is not operational detail' \ - 'Fix wave-gateway: the api_key header is now lowercase.' + 'Fix example-private-a: the api_key header is now lowercase.' expect 0 'lowercase token word near a private repo is not operational detail' \ - 'Docs for wave-transports: pass your access_token to the client.' + 'Docs for example-private-b: pass your access_token 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: example-private-a 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 7789f63fcfa3707ce6c2fac079e709b857240d55 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:14:55 +0000 Subject: [PATCH 08/11] ci: key body-guard concurrency on comment/review id before PR number Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index f35837a..10e1d30 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -200,14 +200,21 @@ 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 most specific identifier of the BODY under scan, not on the + # PR/issue or github.ref. Ordering matters: review-event payloads carry the + # PR object too, so `pull_request.number` first would fold every review + # comment and review summary on one PR into a single group — and GitHub + # keeps at most ONE pending run per group (even with cancel-in-progress: + # false), so a burst of review comments would silently drop some bodies + # unscanned. `comment.id` / `review.id` give each distinct body its own + # group; PR and issue events (which carry neither) fall through to their + # number, where sharing a group is CORRECT: successive body edits supersede + # each other, and the latest pending run always scans the current body. # # 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 7f119d05c28428c4014c94f0543b992e27237c39 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:25:24 +0000 Subject: [PATCH 09/11] fix(guard): fail closed on empty GUARD_PRIVATE_REPOS in CI and on exemption-filter errors Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 49 +++++++++++++------ .../tests/body-policy.test.sh | 20 ++++++++ 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 1a4c117..1f92f15 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -57,11 +57,21 @@ check() { # 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. + # Each filter's exit code is checked the same way as the primary scan: 1 (all + # lines filtered) is a clean result, but >=2 is a scanner error and FAILS + # CLOSED — a swallowed filter error would empty `matches` and pass the rule. local matches if (( exempt )); then - matches="$(printf '%s' "$raw" \ - | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \ - | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + matches="$(printf '%s' "$raw" | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]')"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) applying the guard:allow exemption for rule '$name' — failing closed." + exit 2 + fi + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) applying the about-the-control exemption for rule '$name' — failing closed." + exit 2 + fi else matches="$raw" fi @@ -128,10 +138,16 @@ check BLOCK internal-marker '(? 0 )); then diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index aa6e03f..46d67bc 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -103,6 +103,26 @@ expect 1 'marker USED unquoted still blocks' \ 'Attaching the internal-only rollout plan; do not share outside the team.' # --- fail closed -------------------------------------------------------------- +# GUARD_PRIVATE_REPOS is the one rule fed by configuration, so a configuration +# mistake must go red in CI, never green: an unset/mis-typed org variable would +# otherwise disable the headline rule while the check still reports a pass. The +# silent skip stays local-only, where the org's private-repo list is unknowable. +printf '%s\n' 'Bumps the draft revision. No behaviour change.' > "$TMP/clean.txt" +# guardcfg +guardcfg() { + local want="$1" name="$2"; shift 2 + env -u GUARD_PRIVATE_REPOS -u GITHUB_ACTIONS "$@" bash "$SCRIPT" "$TMP/clean.txt" >/dev/null 2>&1 + local rc=$? + if [[ "$rc" == "$want" ]]; then + PASS=$((PASS+1)); printf ' ok %s → exit %s\n' "$name" "$want" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit %s, got %s\n' "$name" "$want" "$rc" + fi +} +guardcfg 2 'unset GUARD_PRIVATE_REPOS in CI fails closed' GITHUB_ACTIONS=true +guardcfg 2 'whitespace-only GUARD_PRIVATE_REPOS in CI fails closed' GITHUB_ACTIONS=true GUARD_PRIVATE_REPOS=' , ' +guardcfg 0 'unset GUARD_PRIVATE_REPOS locally skips the rule' + # 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. From a5711c921764e1427d2e29ad9b1cfb755042ccaa Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 20:39:06 +0000 Subject: [PATCH 10/11] fix(ci): let same-repo review events use the bootstrap fallback in body-guard Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 10e1d30..665c0b7 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -233,19 +233,28 @@ jobs: sparse-checkout-cone-mode: false # Bootstrap only: on the PR that installs the gate, the default branch does - # not have body-policy.sh yet, so fall back to the PR's own copy — but ONLY - # for a same-repo pull request. A same-repo author already has write access, - # so their copy grants nothing they do not have; a fork PR replacing + # not have body-policy.sh yet, so fall back to the PR's own copy, but ONLY + # for a same-repo pull request. That covers review events on one too: + # reviews and inline comments on the install PR carry the same pull_request + # object, and the copy executed is still the same-repo author's, whose + # write access already lets them ship it. A fork PR replacing # body-policy.sh (say, with `exit 0`) must never get to grade its own body # with its own scanner, even during the bootstrap window, so fork PRs (and - # every non-PR event) fail closed below instead of falling back. Once the - # bundle is merged the trusted copy always exists and this step never runs. + # every non-PR event) fail closed below instead of falling back. The PR + # head SHA is named explicitly because review events would otherwise + # checkout the default branch, which is exactly the tree that lacks the + # script; for a same-repo PR that SHA always exists in this repository. + # Once the bundle is merged the trusted copy always exists and this step + # never runs. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: >- hashFiles('trusted/scripts/public-repo-guard/body-policy.sh') == '' - && github.event_name == 'pull_request' + && (github.event_name == 'pull_request' + || github.event_name == 'pull_request_review' + || github.event_name == 'pull_request_review_comment') && github.event.pull_request.head.repo.full_name == github.repository with: + ref: ${{ github.event.pull_request.head.sha }} path: bootstrap sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false From 2a09911325d7822e84250637ee4c27fa15d41848 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 20:46:37 +0000 Subject: [PATCH 11/11] fix(guard): drop the \b that hid multi-part credential names from the name-first proximity rule Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 9 ++++++++- scripts/public-repo-guard/tests/body-policy.test.sh | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 1f92f15..a1918d4 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -161,8 +161,15 @@ if [[ -n "$_ALT" ]]; then # scoped to the repo-name alternation with (?i:...) — a leading (?i) would # spill across the whole pattern and make the deliberately SCREAMING_CASE-only # OPS_DETAIL match everyday prose like `api_key`. + # + # No \b brackets OPS_DETAIL in either direction. Its credential alternative + # cannot span an underscore, so inside a multi-part name like WAVE_API_TOKEN + # the only sub-match (API_TOKEN) sits after `_`, a word character, and a + # leading \b silently killed the name-first direction for exactly those + # names. The alternation's own anchors ([A-Z] start, keyword tail) already + # bound what it can touch, and the detail-then-name direction never had one. 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' elif [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then echo "::error title=public-repo-guard (private-repo-ops)::GUARD_PRIVATE_REPOS resolved empty in CI — the private-repo proximity rule would silently not run. Set the org/repo Actions variable vars.GUARD_PRIVATE_REPOS; failing closed rather than reporting a pass that enforces nothing." diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 46d67bc..f11b2fa 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -46,6 +46,12 @@ expect 1 'private repo + credential name' \ 'Flip is live: EXAMPLE_LEASE_SECRET is bound on example-private-a now.' expect 1 'private repo + credential name, reverse order' \ 'The EXAMPLE_JOIN_SECRET was added; example-private-b picks it up on deploy.' +# Regression: a \b before the credential name once killed the name-first +# direction for multi-part names — inside EXAMPLE_API_TOKEN the pattern can only +# match API_TOKEN, which sits after `_`, a word character. Note the line carries +# no other operational phrase, so only the credential-name alternative can fire. +expect 1 'private repo + multi-part credential name, name first' \ + 'example-private-a reads EXAMPLE_API_TOKEN at startup.' expect 1 'private repo + secret count' \ 'example-private-a went from 12 secrets to 13 after this change.' expect 1 'private repo + service binding' \