From 8f29c2154886cdc143e47f0ceb96eaa00c7fd8e5 Mon Sep 17 00:00:00 2001 From: yakimoto <66892052+yakimoto@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:20:05 -0400 Subject: [PATCH 1/6] 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 3dac59fae30f01e3f33af6bb666c88ece6ff49bd Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:32:39 +0000 Subject: [PATCH 2/6] ci: cover PR body edits under the required check and fail closed on filter errors Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 83 +++++++++++++++++------- scripts/public-repo-guard/body-policy.sh | 22 +++++-- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index bba2f67..cff872f 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -52,15 +52,23 @@ 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). + # PR `edited` events DO run this job: this is the one check name branch + # protection requires, so the body scan must produce a fresh verdict under it, + # or an edit could introduce a leak behind a stale green check. The tree-scan + # steps below skip `edited` individually (an edit does not change the tree). 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: - group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # `edited` runs get their own group and are never cancelled: each body + # version deserves a verdict, the run is seconds long, and a body-only run + # must never cancel (or be cancelled by) an in-flight tree scan, since a + # cancelled tree scan would leave a commit's tree unscanned. Tree runs keep + # cancel-in-progress: a superseded scan of an outdated commit is pure waste. + group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }}-${{ github.event.action == 'edited' && 'body' || 'tree' }} + cancel-in-progress: ${{ github.event.action != 'edited' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -70,6 +78,7 @@ jobs: # tarball's SHA-256 before extracting, so a tampered or MITM'd download can # never execute inside the security gate. - name: Install gitleaks (pinned + checksum-verified) + if: github.event.action != 'edited' env: GITLEAKS_VERSION: "8.30.1" GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" @@ -83,12 +92,14 @@ jobs: gitleaks version - name: gitleaks (secret scan — published tree) + if: github.event.action != 'edited' 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: content policy (WAVE trade-secret / internal-leak gate) + if: github.event.action != 'edited' env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: bash scripts/public-repo-guard/content-policy.sh . @@ -100,28 +111,57 @@ jobs: - name: body policy self-test (fixtures) run: bash scripts/public-repo-guard/tests/body-policy.test.sh + # A PR body is merge-blocking, so it is scanned HERE, inside the one check + # name branch protection requires. Scanning it only in a differently named + # job would let a body edit hide behind this check's stale green result. + # Same handling discipline as body-guard below: the untrusted text goes + # payload file to scratch file, never through a run: block or an env var. + - name: Materialize the untrusted PR title/body to a file + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bodyscan" + # An UNRECOGNIZED payload shape must fail, never quietly scan nothing + # and report a pass. + if [ "$(jq -r 'has("pull_request")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (guard)::Event payload contains no pull_request object; refusing to report a pass on an unscanned body." + exit 1 + fi + jq -r '[.pull_request.title, .pull_request.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 title/body, merge-blocking) + if: github.event_name == 'pull_request' + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" + # 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. + # TREE and (on PRs) the merge-blocking title/body; an issue or 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. + # Honest about what it can and cannot do. Issue and comment 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. PR bodies are NOT scanned here: they are merge-blocking, so + # they belong to `guard`, the check name branch protection actually requires. 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 == 'issues' || github.event_name == 'issue_comment' concurrency: - # Keyed on the specific PR / comment / issue rather than github.ref, because + # Keyed on the specific 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 }} + group: public-repo-guard-body-${{ github.event.comment.id || github.event.issue.number || github.ref }} cancel-in-progress: false runs-on: ubuntu-latest steps: @@ -137,7 +177,7 @@ jobs: # 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 + # in an environment variable, so shell metacharacters in a hostile body # have nothing to act on. jq is preinstalled on the GitHub-hosted images. - name: Materialize the untrusted title/body to a file run: | @@ -146,18 +186,17 @@ 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("issue") or has("comment")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no 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, + jq -r '[.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) + - name: body policy (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 index a0b421f..199a3de 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -46,13 +46,27 @@ check() { echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) scanning rule '$name' — failing closed." exit 2 fi + (( rc == 1 )) && return 0 # 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)" + # + # Each stage's exit code is checked EXPLICITLY: 1 (nothing survived the filter) + # is a normal clean result, but >=2 is a broken filter and must fail closed, + # exactly like the scan above. A bare `|| true` here would turn a filter crash + # into an empty match set: a false pass from the one stage meant to narrow, + # never erase, the raw hits. + local filtered matches frc + filtered="$(printf '%s' "$raw" | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]')"; frc=$? + if (( frc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the guard:allow filter for rule '$name'; failing closed." + exit 2 + fi + matches="$(printf '%s' "$filtered" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? + if (( frc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control filter for rule '$name'; failing closed." + exit 2 + 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 From d9a46a28b02979b3b123c3b806ebb9df22cd7ef1 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:38:59 +0000 Subject: [PATCH 3/6] ci: fix body-policy regex precision holes found in review Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 37 +++++++++++++++---- .../tests/body-policy.test.sh | 21 +++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 199a3de..b7346d3 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -32,6 +32,11 @@ VIOLATIONS=0 # the gate blocks its own pull requests and every security discussion — the # self-referential trap that gets a gate switched off. Ported verbatim in intent # from the client-side gate's allowlist, which was built for exactly this. +# +# Applied ONLY to the prose-level rules (internal-marker, private-repo-ops; see +# check() below). A hard credential or infrastructure identifier is a leak no +# matter how much the surrounding sentence discusses the gate, so those rules +# never get this exemption; `guard:allow ` stays the one visible escape. 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 @@ -62,11 +67,20 @@ check() { echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the guard:allow filter for rule '$name'; failing closed." exit 2 fi - matches="$(printf '%s' "$filtered" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? - if (( frc >= 2 )); then - echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control filter for rule '$name'; failing closed." - exit 2 - fi + # The about-the-control exemption applies to PROSE rules only. For the hard + # formats (credentials, IPs, account IDs, paths) a hit is a leak even on a + # line that names the gate, so filtering it out there would let a real key + # ride along with a mention of public-repo-guard. + case "$name" in + internal-marker|private-repo-ops) + matches="$(printf '%s' "$filtered" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? + if (( frc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control filter for rule '$name'; failing closed." + exit 2 + fi + ;; + *) matches="$filtered" ;; + esac [[ -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 @@ -111,7 +125,7 @@ check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'O # A quoted marker is also a trivial bypass, and that is an accepted trade. The # threat here is the ACCIDENTAL paste; a deliberate evader has easier routes, and # `guard:allow ` already exists as the honest, visible one. -check BLOCK internal-marker '(? Date: Thu, 6 Aug 2026 17:42:26 +0000 Subject: [PATCH 4/6] ci: run the full guard scan on every PR event and use fictional fixture names Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 26 +++++++-------- .../tests/body-policy.test.sh | 32 +++++++++++-------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index cff872f..eb19f82 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -53,22 +53,23 @@ jobs: name: Secrets + content policy # Skips issue/comment events (the tree scan has nothing to say about a comment, # and the org should not pay for a gitleaks run every time anyone posts one). - # PR `edited` events DO run this job: this is the one check name branch - # protection requires, so the body scan must produce a fresh verdict under it, - # or an edit could introduce a leak behind a stale green check. The tree-scan - # steps below skip `edited` individually (an edit does not change the tree). + # On pull_request it runs on EVERY action, `edited` included, and always runs + # the FULL scan. This job publishes the one check name branch protection + # requires, and GitHub evaluates a required check from the LATEST check run + # of that name on the commit: a partial (body-only) run would overwrite a red + # tree verdict with a green one, letting a body edit launder a failing secret + # scan. Re-scanning an unchanged tree on an edit is the price of a verdict + # that cannot be overwritten by a narrower one. if: >- github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' concurrency: - # `edited` runs get their own group and are never cancelled: each body - # version deserves a verdict, the run is seconds long, and a body-only run - # must never cancel (or be cancelled by) an in-flight tree scan, since a - # cancelled tree scan would leave a commit's tree unscanned. Tree runs keep - # cancel-in-progress: a superseded scan of an outdated commit is pure waste. - group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }}-${{ github.event.action == 'edited' && 'body' || 'tree' }} - cancel-in-progress: ${{ github.event.action != 'edited' }} + # cancel-in-progress is safe here BECAUSE every run of this job has + # identical full scope: whatever a superseded run would have said, its + # replacement re-checks in full. Nothing is left unscanned by a cancel. + group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -78,7 +79,6 @@ jobs: # tarball's SHA-256 before extracting, so a tampered or MITM'd download can # never execute inside the security gate. - name: Install gitleaks (pinned + checksum-verified) - if: github.event.action != 'edited' env: GITLEAKS_VERSION: "8.30.1" GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" @@ -92,14 +92,12 @@ jobs: gitleaks version - name: gitleaks (secret scan — published tree) - if: github.event.action != 'edited' 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: content policy (WAVE trade-secret / internal-leak gate) - if: github.event.action != 'edited' env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: bash scripts/public-repo-guard/content-policy.sh . diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 931309e..4d39a27 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -15,7 +15,13 @@ 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" +# +# Every name below is INVENTED. This file is public and sits inside +# scripts/public-repo-guard/, which the tree scan and gitleaks deliberately +# exclude (the gate cannot scan its own fixtures without blocking itself), so a +# REAL private repo name or credential name written here would be published with +# no check standing in its way. Keep the names obviously fictional. +export GUARD_PRIVATE_REPOS="example-private-alpha, example-private-beta, example-private-gamma" PASS=0; FAIL=0 @@ -39,20 +45,20 @@ 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_ROTATION_SECRET is bound on example-private-alpha 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-beta picks it up on deploy.' # Regression: `_` is a word character, so a `\b`-anchored class without it could -# never enter WAVE_VIEWPORT_LEASE_SECRET past its first segment in this order. +# never enter EXAMPLE_LEASE_ROTATION_SECRET past its first segment in this order. expect 1 'private repo FIRST + multi-segment credential name' \ - 'wave-gateway now reads WAVE_VIEWPORT_LEASE_SECRET at boot.' + 'example-private-alpha now reads EXAMPLE_LEASE_ROTATION_SECRET at boot.' # Regression: prose detail stays case-insensitive after case scoping was fixed. expect 1 'capitalized prose detail still blocks' \ - 'This adds a Service Binding from the worker to agent-money.' + 'This adds a Service Binding from the worker to example-private-gamma.' expect 1 'private repo + secret count' \ - 'wave-gateway went from 74 secrets to 75 after this change.' + 'example-private-alpha 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-gamma 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,23 +83,23 @@ 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-beta#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-alpha and example-private-beta 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: case sensitivity is scoped. Lowercase identifiers are everyday # code words, not SCREAMING_CASE credential names; a global (?i) blocked these. expect 0 'lowercase api_key near a private repo' \ - 'Companion to wave-transports#260; fixes the api_key parsing.' + 'Companion to example-private-beta#260; fixes the api_key parsing.' expect 0 'lowercase cache_key rename in a private repo' \ - 'wave-gateway: rename cache_key to slot_key.' + 'example-private-alpha: rename cache_key to slot_key.' 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-alpha 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 d271bc1c4bf3752cad0e8d3b8ecc775a04446574 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:49:42 +0000 Subject: [PATCH 5/6] ci: list all five install files and limit the control exemption to internal-marker Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 5 ++++- scripts/public-repo-guard/body-policy.sh | 22 +++++++++++-------- .../tests/body-policy.test.sh | 7 ++++-- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index eb19f82..fc386e4 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,11 +13,14 @@ 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 workflow executes +# every one of them; omit the test file and the required check fails on its +# self-test step on every push): # .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 b7346d3..fc3c8dd 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -33,10 +33,12 @@ VIOLATIONS=0 # 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. # -# Applied ONLY to the prose-level rules (internal-marker, private-repo-ops; see -# check() below). A hard credential or infrastructure identifier is a leak no -# matter how much the surrounding sentence discusses the gate, so those rules -# never get this exemption; `guard:allow ` stays the one visible escape. +# Applied ONLY to the internal-marker rule (see check() below). Every other +# rule matches content that leaks regardless of what the sentence is about: a +# credential, an IP, a path, or a private repo wired to operational detail is +# re-published even when the line quotes it to DISCUSS the gate, exactly like +# the redacted annotations below never echo a hit. For those rules, +# `guard:allow ` stays the one visible escape. 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 @@ -67,12 +69,14 @@ check() { echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the guard:allow filter for rule '$name'; failing closed." exit 2 fi - # The about-the-control exemption applies to PROSE rules only. For the hard - # formats (credentials, IPs, account IDs, paths) a hit is a leak even on a - # line that names the gate, so filtering it out there would let a real key - # ride along with a mention of public-repo-guard. + # The about-the-control exemption applies to internal-marker ONLY. That rule + # matches PHRASES, so a line discussing the gate trips it without leaking + # anything. Every other rule (credentials, IPs, paths, and the private-repo + # wiring-topology rule this file exists for) matches content whose mere + # presence is the leak; "body-policy would flag X bound on " + # publishes the topology just as surely as stating it outright. case "$name" in - internal-marker|private-repo-ops) + internal-marker) matches="$(printf '%s' "$filtered" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? if (( frc >= 2 )); then echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control filter for rule '$name'; failing closed." diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 4d39a27..1d5aab8 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -74,10 +74,13 @@ expect 1 'sentence-initial capitalized marker' \ AKID_FIXTURE="AKI""A1234567890ABCDEF" expect 1 'AWS access key id' \ "The failing job had ${AKID_FIXTURE} configured." -# Regression: the about-the-control exemption is prose-rules-only. A credential -# is a leak even on a line that names the gate. +# Regression: the about-the-control exemption is internal-marker-only. A +# credential or wiring topology is a leak even on a line that names the gate: +# quoting a hit to discuss it re-publishes it. expect 1 'credential on a line that also names the control' \ "public-repo-guard already flagged ${AKID_FIXTURE} once; reposting for context." +expect 1 'topology on a line that names the control' \ + 'body-policy should have flagged EXAMPLE_SECRET bound on example-private-alpha here.' expect 1 'internal tailscale IP' \ 'It resolves to 100.71.4.19 from inside the fleet.' From 52a7be55c4dbb4324175a3934227c09c4ca6a24e Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:59:49 +0000 Subject: [PATCH 6/6] fix: scope body-guard to the event's own text; demote abs-user-path to WARN for bodies Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 15 +++++++++++++-- scripts/public-repo-guard/body-policy.sh | 9 ++++++++- .../tests/body-policy.test.sh | 18 ++++++++++++++++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index fc386e4..596b8a4 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -191,8 +191,19 @@ jobs: echo "::error title=public-repo-guard (body-guard)::Event payload contains no issue/comment object; refusing to report a pass on an unscanned body." exit 1 fi - jq -r '[.issue.title, .issue.body, - .comment.body] + # Scan only the text THIS event delivered. On issue_comment the + # payload's .issue is the surrounding issue (or PULL REQUEST, whose + # .issue.title/.issue.body are the PR's own description), so scanning + # .issue.* on comment events would re-scan a PR body on every comment + # (duplicating the merge-blocking scan in `guard`) and, because + # issue/comment events run against the DEFAULT branch head, pin any + # failure to main rather than the PR. Nothing is left unscanned by + # the narrowing: every comment gets its own created/edited event, and + # issue bodies get theirs via issues opened/edited. + jq -r 'if has("comment") + then [.comment.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" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index fc3c8dd..76c1509 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -113,8 +113,15 @@ check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Em # 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' +# WARN, not BLOCK: a deliberate divergence from the FILE profile, for the same +# reason private-repo-ops diverges below. In a checked-in file an operator home +# path is AUTHORED content and blocking is right; in a body it is almost always +# pasted terminal output from a repro, and a gate that makes a reporter rewrite +# a stack trace (or sprinkle guard:allow) just to file a bug gets switched off. +# The hit is still annotated: a username is worth redacting, not worth blocking +# a merge over. # 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 WARN 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 diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 1d5aab8..06edb2e 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -59,8 +59,6 @@ expect 1 'private repo + secret count' \ 'example-private-alpha went from 74 secrets to 75 after this change.' expect 1 'private repo + service binding' \ 'This adds a service binding from the worker to example-private-gamma 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.' # Regression: the marker rule is case-insensitive — the most natural phrasing of @@ -97,6 +95,22 @@ expect 0 'lowercase api_key near a private repo' \ 'Companion to example-private-beta#260; fixes the api_key parsing.' expect 0 'lowercase cache_key rename in a private repo' \ 'example-private-alpha: rename cache_key to slot_key.' +# WARN for bodies, not BLOCK: pasted repro output routinely carries local home +# paths, and forcing a reporter to rewrite a stack trace to file a bug is the +# friction that gets a gate switched off. The divergence from the file gate is +# deliberate; see the rule's comment in body-policy.sh. +expect 0 'operator home path warns without blocking' \ + 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) +# The demotion must never quietly become deletion: the warning annotation still +# fires. No pipeline into `grep -q` here: under pipefail its early exit can +# SIGPIPE the producer and turn a MATCH into a spurious failure. +printf '%s\n' 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' > "$TMP/body.txt" # enforce-ignore (fixture) +WARN_OUT="$(bash "$SCRIPT" "$TMP/body.txt" 2>&1)" +if [[ "$WARN_OUT" == *'warning title=public-repo-guard (abs-user-path)'* ]]; then + PASS=$((PASS+1)); printf ' ok operator home path still emits its WARN annotation\n' +else + FAIL=$((FAIL+1)); printf ' FAIL operator home path — WARN annotation for abs-user-path missing\n' +fi 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' \