From 34ecb6abe64885f22587dd0e7249b555af79aac1 Mon Sep 17 00:00:00 2001 From: yakimoto <66892052+yakimoto@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:20:37 -0400 Subject: [PATCH 01/12] ci: this repo's public-repo-guard never scanned a single issue or comment body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured across all 28 public wave-av repos (claude-workstation#1747, #1794): TWO coverage shapes satisfy the one required check name `Secrets + content policy`. 27 repos triggers: pull_request, push, workflow_dispatch jobs: guard 1 repo triggers: + issues, issue_comment jobs: + body-guard This repo is in the 27. All 28 report the same green check. The outlier is wave-moq-edge, and its own comment says why it matters: "`edited` matters as much as `opened`: a body can be made to leak long after the PR is first raised, and until this workflow covered it, nothing ever re-scanned." A PR/issue/comment BODY is exactly as world-readable as the tree, and until now it was scanned by nothing server-side. That gap was not theoretical on wave-moq-edge: a PR was blocked for naming a private repo in wrangler.toml while the very same name, with more operational detail attached, sat unchallenged in its body. WHAT LANDS HERE — the bundle the workflow's own header names, minus what this repo already has (.gitleaks.toml and content-policy.sh are already vendored): .github/workflows/public-repo-guard.yml replaced (73 -> 163 lines) scripts/public-repo-guard/body-policy.sh new, mode 100755 scripts/public-repo-guard/tests/body-policy.test.sh new, mode 100755 Copied from wave-moq-edge, which has run this shape in production. Modes preserved via the git trees API — the contents API would have created both scripts 100644. HONEST ABOUT WHAT IT CAN DO. On a PR this PREVENTS the merge. On an issue or comment the text is already public the moment it posts, so this is DETECTION: it says go redact, fast. Only a client-side pre-write hook stops that class before publication. Also inherited from the reference: concurrency moves from workflow-level to PER JOB, because the two jobs want opposite behaviour. A workflow-level group forced one policy on both, and rapid body edits cancelled the tree job repeatedly — every cancelled check-run stays attached to the commit, so the PR reported UNSTABLE while the live runs were green. The body gate ships with its own fixtures and runs them in CI. Its NEGATIVE cases are the load-bearing half: a leak gate that blocks legitimate cross-repo references gets switched off, and then it protects nothing. Refs wave-av/claude-workstation#1747. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/public-repo-guard.yml | 100 ++++++++++++- scripts/public-repo-guard/body-policy.sh | 139 ++++++++++++++++++ .../tests/body-policy.test.sh | 108 ++++++++++++++ 3 files changed, 342 insertions(+), 5 deletions(-) create mode 100755 scripts/public-repo-guard/body-policy.sh create mode 100755 scripts/public-repo-guard/tests/body-policy.test.sh diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 719718a..bba2f67 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,10 +13,11 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all three files together: +# To install on a new repo, copy all four files together: # .github/workflows/public-repo-guard.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh +# scripts/public-repo-guard/body-policy.sh # # Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The # goal is "what is public right now is clean", so a shallow checkout is sufficient. @@ -25,24 +26,44 @@ name: public-repo-guard # path glob to a repo-root `.guardignore`, or extend the repo-local `.gitleaks.toml`. on: + # `edited` matters as much as `opened`: a body can be made to leak long after the + # PR is first raised, and until this workflow covered it, nothing ever re-scanned. pull_request: + types: [opened, edited, reopened, synchronize] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] push: branches: [main, master] workflow_dispatch: +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. permissions: contents: read -concurrency: - group: public-repo-guard-${{ github.ref }} - cancel-in-progress: true +# Concurrency is per JOB, not per workflow: the two jobs want opposite behaviour. +# A workflow-level group would force one policy on both, and it showed: rapid body +# edits cancelled the tree job over and over, and every cancelled check-run stays +# attached to the commit, so the PR reported UNSTABLE while the live runs were green. jobs: guard: name: Secrets + content policy + # Skips issue/comment events (the tree scan has nothing to say about a comment, + # and the org should not pay for a gitleaks run every time anyone posts one) and + # skips `edited` (a title or body edit does not change the tree). + if: >- + (github.event_name == 'pull_request' && github.event.action != 'edited') + || github.event_name == 'push' + || github.event_name == 'workflow_dispatch' + concurrency: + group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release @@ -71,3 +92,72 @@ jobs: env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: bash scripts/public-repo-guard/content-policy.sh . + + # The body gate's own fixtures. Its negatives are the load-bearing half — a + # leak gate that blocks legitimate cross-repo references gets switched off, + # and then it protects nothing. Runs here so a regression is caught by CI + # rather than by a leak. + - name: body policy self-test (fixtures) + run: bash scripts/public-repo-guard/tests/body-policy.test.sh + + # The other half of a public repo's surface. `guard` above scans the published + # TREE; a PR/issue/comment BODY is just as world-readable and, until this job, + # was scanned by nothing server-side. That gap was real, not theoretical: a PR + # was blocked for naming a private repo in wrangler.toml while the very same + # name, with more operational detail attached, sat unchallenged in its body. + # + # Honest about what it can and cannot do. On a PR this PREVENTS the merge. On an + # issue or comment the text is already public the moment it posts, so this is + # detection — it tells us to go redact, fast. Only the client-side pre-write hook + # can stop that class before publication. + body-guard: + name: Body content policy + if: github.event_name == 'pull_request' || github.event_name == 'issues' || github.event_name == 'issue_comment' + concurrency: + # Keyed on the specific PR / comment / issue rather than github.ref, because + # issue events all report the default branch and a ref-keyed group would let + # two comments cancel each other, leaving one unscanned. + # + # cancel-in-progress is deliberately FALSE. Every version of a body deserves a + # verdict, the job is seconds long, and a cancelled check-run lingers on the + # commit and makes an otherwise-green PR look broken. + group: public-repo-guard-body-${{ github.event.pull_request.number || github.event.comment.id || github.event.issue.number || github.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Only the gate's own scripts are needed — no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + - name: Install ripgrep + run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + + # The body is read straight out of the event payload FILE and written to + # another file. It is never interpolated into a run: block and never placed + # in an environment variable, so shell metacharacters in a hostile PR body + # have nothing to act on. jq is preinstalled on the GitHub-hosted images. + - name: Materialize the untrusted title/body to a file + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bodyscan" + # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and + # report a pass. If the event schema ever moves, this job must go red + # rather than become a green rubber stamp over an unscanned body. + if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment object — refusing to report a pass on an unscanned body." + exit 1 + fi + jq -r '[.pull_request.title, .pull_request.body, + .issue.title, .issue.body, + .comment.body] + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh new file mode 100755 index 0000000..a0b421f --- /dev/null +++ b/scripts/public-repo-guard/body-policy.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# WAVE public-repo BODY policy — the internal-leak gate for PR/issue/comment text. +# +# Companion to content-policy.sh. That script scans the published working TREE; +# this one scans the other half of a public repo's surface: pull-request titles +# and bodies, issue bodies, and comment bodies. Those are equally world-readable +# and, until this script existed, were scanned by NOTHING server-side. That gap +# was not theoretical — a PR was merged whose wrangler.toml was correctly BLOCKED +# for naming a private repo while the PR body named the same repo, with more +# operational detail attached, and sailed through. +# +# Usage: scripts/public-repo-guard/body-policy.sh +# holds the untrusted text, already materialized to disk. It is passed as +# a PATH and only ever read — the body is never interpolated into a command line +# or an environment variable, so no amount of shell metacharacters in a PR body +# can influence what runs here. +# +# Exit: 0 clean · 1 blocking violation · 2 scanner error (fail closed). +# +# Allowlisting: a line carrying `guard:allow ` is exempt (an accidental +# leak never carries the marker; a deliberate one is visible in a public diff), as +# is any line matching the ABOUT-THE-CONTROL allowlist below. +set -uo pipefail + +FILE="${1:-}" +[[ -n "$FILE" && -f "$FILE" ]] || { echo "::error::body-policy: usage: body-policy.sh "; exit 2; } +command -v rg >/dev/null 2>&1 || { echo "::error::body-policy: ripgrep (rg) required"; exit 2; } + +VIOLATIONS=0 + +# Lines that TALK ABOUT the control rather than leaking through it. Without this, +# the gate blocks its own pull requests and every security discussion — the +# self-referential trap that gets a gate switched off. Ported verbatim in intent +# from the client-side gate's allowlist, which was built for exactly this. +ABOUT_THE_CONTROL='(public-repo-guard|body-policy|content-policy|public-github-write-gate|\bNDA\s+(gate|guard|policy|denylist|sweep|scan|hook)\b|\bno\s+NDA\b|responsib\w*\s+disclos|SECURITY\.md)' + +# check +check() { + local sev="$1" name="$2" re="$3" why="$4" + [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } + # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes + # because its scanner broke is worse than no gate: it reports success. + local raw rc + raw="$(rg -nP --no-filename -- "$re" "$FILE" 2>/dev/null)"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) scanning rule '$name' — failing closed." + exit 2 + fi + # Filter with rg, not grep: BSD/macOS grep has no -P, so a `grep -P` allowlist + # silently errors out locally while working on GNU/CI — the gate would then + # disagree with itself depending on where it ran. rg is already required above. + local matches + matches="$(printf '%s' "$raw" \ + | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \ + | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + [[ -z "$matches" ]] && return 0 + local count; count="$(printf '%s\n' "$matches" | grep -c '')" + # Print the LINE NUMBER only — never the matched text. This annotation is itself + # world-readable, so echoing the hit would re-publish the very thing we caught. + echo "::group::[$sev] $name — $why" + printf '%s\n' "$matches" | sed -E 's/^([0-9]+):.*/ line \1: «match redacted — view the body to see it»/' + echo "::endgroup::" + if [[ "$sev" == "BLOCK" ]]; then + echo "::error title=public-repo-guard ($name)::$why — $count occurrence(s) in the title/body. Edit the body to remove it, then re-run." + VIOLATIONS=$((VIOLATIONS+1)) + else + echo "::warning title=public-repo-guard ($name)::$why — $count occurrence(s) (non-blocking; review)." + fi +} + +# --- Credential formats — never legitimate in prose -------------------------- +check BLOCK stripe-live-key '(sk|rk)_live_[A-Za-z0-9]{16,}' 'Live Stripe secret/restricted key' +check BLOCK stripe-account 'acct_[A-Za-z0-9]{16,}' 'Live Stripe account ID — financial infra, never publish' +check BLOCK anthropic-key 'sk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{20,}' 'Real Anthropic API/admin key' +check BLOCK github-pat 'github_pat_[A-Za-z0-9_]{30,}' 'GitHub fine-grained PAT' +check BLOCK supabase-pat 'sbp_[a-f0-9]{40}' 'Supabase personal access token' +check BLOCK aws-akid 'AKIA[0-9A-Z]{16}' 'AWS access key ID' +check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Embedded private key material' + +# --- Infrastructure identifiers ---------------------------------------------- +# shellcheck disable=SC2016 # $CLOUDFLARE_ACCOUNT_ID is literal guidance text +check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead' +check BLOCK internal-ip '100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]{1,3}\.[0-9]{1,3}' 'Internal Tailscale-CGNAT IP (100.64.0.0/10) — internal fleet address' +# shellcheck disable=SC2016 # $HOME is literal guidance text +check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' + +# --- Self-identified internal material --------------------------------------- +# USE vs MENTION. A body that SAYS "internal-only" is leaking; a body that QUOTES +# the phrase is describing a policy — including this one. The lookarounds exempt a +# marker wrapped in straight, smart, or backtick quotes. +# +# Not hypothetical: the first run of this job failed on its own pull request, +# because a review bot had edited the PR body to summarize the change and its +# summary quoted the phrase verbatim. The line-level allowlist could not help — +# that line named no gate. Only use-vs-mention separates the two. +# +# A quoted marker is also a trivial bypass, and that is an accepted trade. The +# threat here is the ACCIDENTAL paste; a deliberate evader has easier routes, and +# `guard:allow ` already exists as the honest, visible one. +check BLOCK internal-marker '(?#260"). A gate that fires on all of +# those gets switched off, and then it protects nothing. +# +# So a bare mention stays silent. What fires is a private repo name within ~140 +# characters of INTERNAL OPERATIONAL DETAIL — a SCREAMING_CASE credential NAME, a +# secret-binding verb, a service binding, or a secret COUNT. That is the topology +# of what is wired to what, and it is the shape that actually leaked. +# +# Names are NOT hardcoded (this file is public); CI injects them via the +# GUARD_PRIVATE_REPOS variable. Unset locally → this check is skipped. +if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then + OPS_DETAIL='(?:[A-Z][A-Z0-9]*_(?:SECRET|TOKEN|KEY|PASSWORD)|wrangler\s+secret|secret\s+(?:is\s+)?(?:bound|binding|list)|(?:is\s+)?bound\s+on|service\s+binding|\d{2,}\s+secrets)' + _ALT='' + IFS=', ' read -r -a _PRIV <<< "$GUARD_PRIVATE_REPOS" + for _name in "${_PRIV[@]}"; do + [[ -z "$_name" ]] && continue + # Regex-escape so metacharacters in a name match literally. + _esc="$(printf '%s' "$_name" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g')" + _ALT="${_ALT:+$_ALT|}${_esc}" + done + if [[ -n "$_ALT" ]]; then + # Both orders: name-then-detail and detail-then-name. + check BLOCK private-repo-ops \ + "(?i)\\b(?:${_ALT})\\b[^\\n]{0,140}?\\b${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?:${_ALT})\\b" \ + 'A private WAVE repo named alongside internal operational detail (credential name, secret binding, or secret count) — the wiring topology is not public' + fi +fi + +if (( VIOLATIONS > 0 )); then + echo "::error::public-repo-guard: $VIOLATIONS blocking body-policy violation(s) — see annotations above." + exit 1 +fi +echo "public-repo-guard: body policy OK" diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh new file mode 100755 index 0000000..13cc9bf --- /dev/null +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Fixture tests for body-policy.sh. +# +# Deliberately fixture-only: the gate is NEVER proved by writing a real leak into a +# live public PR body, because doing so would publish the exact thing it guards. +# +# The negatives here are the load-bearing half. A leak gate that blocks everything +# is trivially "correct" and useless — it gets disabled within a week. The bare +# cross-reference case below is the one that keeps this gate deployable. +set -uo pipefail + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/body-policy.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# The names the real gate is configured with come from an org variable; the tests +# pin their own so they are hermetic and do not depend on CI configuration. +export GUARD_PRIVATE_REPOS="wave-gateway, wave-transports, agent-money" + +PASS=0; FAIL=0 + +# expect +expect() { + local want="$1" name="$2" body="$3" out rc + printf '%s\n' "$body" > "$TMP/body.txt" + out="$(bash "$SCRIPT" "$TMP/body.txt" 2>&1)"; rc=$? + if [[ "$rc" == "$want" ]]; then + PASS=$((PASS+1)); printf ' ok %s\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit %s, got %s\n%s\n' "$name" "$want" "$rc" "$out" + fi + # The annotation is world-readable; a hit must never echo the matched text. + if [[ "$rc" == 1 ]] && printf '%s' "$out" | grep -qF "$body"; then + FAIL=$((FAIL+1)); printf ' FAIL %s — LEAKED the matched text into the annotation\n' "$name" + fi +} + +echo "body-policy fixtures" + +# --- must BLOCK --------------------------------------------------------------- +expect 1 'private repo + credential name' \ + 'Flip is live: WAVE_VIEWPORT_LEASE_SECRET is bound on wave-gateway now.' +expect 1 'private repo + credential name, reverse order' \ + 'The MOQ_JOIN_SECRET was added; wave-transports picks it up on deploy.' +expect 1 'private repo + secret count' \ + 'wave-gateway went from 74 secrets to 75 after this change.' +expect 1 'private repo + service binding' \ + 'This adds a service binding from the worker to agent-money for settlement.' +expect 1 'operator home path' \ + 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) +expect 1 'internal-only marker' \ + 'Attaching the internal-only rollout plan for context.' +# Assembled at run time rather than written as a literal: a fixture that LOOKS like +# a live AWS key trips this repo's own pre-commit secret scanners (it did, on the +# first draft). Splitting the prefix keeps the fixture exercising the real regex +# without parking a credential-shaped string in source. +AKID_FIXTURE="AKI""A1234567890ABCDEF" +expect 1 'AWS access key id' \ + "The failing job had ${AKID_FIXTURE} configured." +expect 1 'internal tailscale IP' \ + 'It resolves to 100.71.4.19 from inside the fleet.' + +# --- must PASS (precision — these keep the gate deployable) ------------------- +expect 0 'bare private-repo cross-reference' \ + 'This is the companion change to wave-transports#260; merge that one first.' +expect 0 'two private repos, no operational detail' \ + 'Both wave-gateway and wave-transports will need a follow-up for this.' +expect 0 'credential NAME with no private repo nearby' \ + 'The handler now reads SOME_API_TOKEN from the environment instead of a literal.' +expect 0 'public runner path is not an operator path' \ + 'CI checks out to /home/runner/work/repo/repo before the scan runs.' # enforce-ignore (fixture) +expect 0 'talking about the control' \ + 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' +expect 0 'explicit guard:allow with a reason' \ + 'Example for the docs: wave-gateway holds EXAMPLE_SECRET — guard:allow documented-example' +expect 0 'ordinary clean body' \ + 'Bumps the draft revision and regenerates the fixtures. No behaviour change.' +# Regression: the first CI run of this job failed on its own PR, because a review +# bot edited the body to summarize the change and quoted the marker verbatim. +expect 0 'marker MENTIONED in straight quotes is a description' \ + 'Blocks infra identifiers and markers (account_id, home paths, "internal-only" text).' +expect 0 'marker MENTIONED in a code span' \ + 'The rule matches `internal-only` and `for internal use` in body text.' +expect 0 'marker MENTIONED in smart quotes' \ + 'Blocks operator home paths and “internal-only” text.' +expect 1 'marker USED unquoted still blocks' \ + 'Attaching the internal-only rollout plan; do not share outside the team.' + +# --- fail closed -------------------------------------------------------------- +# Invoked directly, not through expect(): expect() always materializes a file, so +# it cannot reach these paths. A gate that returns "OK" when it was handed nothing +# to scan is the failure mode this whole file exists to prevent. +for case in "no argument at all::" "nonexistent path::$TMP/does-not-exist.txt"; do + name="${case%%::*}"; arg="${case##*::}" + if [[ -n "$arg" ]]; then bash "$SCRIPT" "$arg" >/dev/null 2>&1; else bash "$SCRIPT" >/dev/null 2>&1; fi + rc=$? + if [[ "$rc" == 2 ]]; then + PASS=$((PASS+1)); printf ' ok %s → exit 2 (fails closed)\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit 2, got %s\n' "$name" "$rc" + fi +done + +echo " ---" +if (( FAIL > 0 )); then + echo " $PASS passed, $FAIL FAILED"; exit 1 +fi +echo " $PASS passed, 0 failed" From fe06bd02a507d6b9a41beba4d39d1539ddd22502 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:31:49 +0000 Subject: [PATCH 02/12] ci: scope (?i) to repo names, probe PCRE2, complete install manifest, sync template Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 12 +- scripts/public-repo-guard/body-policy.sh | 11 +- .../tests/body-policy.test.sh | 4 + workflow-templates/README.md | 23 ++-- workflow-templates/public-repo-guard.yml | 108 +++++++++++++++++- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index bba2f67..79ff581 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,11 +13,12 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all four files together: +# To install on a new repo, copy all five files together: # .github/workflows/public-repo-guard.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh # scripts/public-repo-guard/body-policy.sh +# scripts/public-repo-guard/tests/body-policy.test.sh # # Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The # goal is "what is public right now is clean", so a shallow checkout is sufficient. @@ -160,4 +161,11 @@ jobs: - name: body policy (PR / issue / comment text) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" + # The existence assertion turns "the sparse checkout resolved to nothing" + # into a named failure instead of bash's opaque "No such file or directory". + run: | + if [ ! -f scripts/public-repo-guard/body-policy.sh ]; then + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the sparse checkout — failing closed." + exit 2 + fi + 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..1d3674b 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -25,6 +25,10 @@ set -uo pipefail FILE="${1:-}" [[ -n "$FILE" && -f "$FILE" ]] || { echo "::error::body-policy: usage: body-policy.sh "; exit 2; } command -v rg >/dev/null 2>&1 || { echo "::error::body-policy: ripgrep (rg) required"; exit 2; } +# Every rule below uses -P (PCRE2), and not every distro ships rg with it built +# in. Probe once up front so a PCRE2-less build fails with ONE clear message +# instead of an opaque "ripgrep failed (exit 2)" on every rule. +rg --pcre2-version >/dev/null 2>&1 || { echo "::error::body-policy: this ripgrep build lacks PCRE2 (-P) support — failing closed"; exit 2; } VIOLATIONS=0 @@ -125,9 +129,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 only — a bare `(?i)` prefix would bleed + # into OPS_DETAIL and make its SCREAMING_CASE credential pattern match + # everyday lowercase prose like "api_key", blocking legitimate bodies. 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..4133744 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -67,6 +67,10 @@ 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 bare (?i) prefix once made the SCREAMING_CASE credential pattern +# case-blind, so lowercase "api_key" near a private repo name blocked the body. +expect 0 'lowercase credential-ish word near a private repo' \ + 'Companion to wave-gateway#12: fixes the api_key parsing bug in 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' \ diff --git a/workflow-templates/README.md b/workflow-templates/README.md index b961805..f3ed45a 100644 --- a/workflow-templates/README.md +++ b/workflow-templates/README.md @@ -20,7 +20,7 @@ or use GitHub's "New workflow" UI and pick the WAVE template. **For:** **every** WAVE public repo. This is the standing pre-publication gate. -**What it does:** two complementary checks on each PR / push — +**What it does:** three complementary checks — 1. **gitleaks** — formatted secrets (API keys, tokens, private keys), using the canonical `.gitleaks.toml` at the root of this repo (placeholders / fixtures allowlisted). @@ -28,10 +28,15 @@ or use GitHub's "New workflow" UI and pick the WAVE template. live Stripe account IDs (`acct_…`), hardcoded Cloudflare `account_id`s, developer absolute paths (`/Users/…`), references to private WAVE repos, and committed `.env` files. +3. **body-policy.sh** — the same leak classes in PR / issue / comment TEXT, + which is equally world-readable and was previously scanned by nothing + server-side. On a PR it blocks the merge; on an issue or comment it detects + so the text can be redacted fast. -Both the config and the script are fetched from `wave-av/.github` at run time, so -the rules live in exactly one place across the org. A repo may ship its own -`.gitleaks.toml` to extend the rules locally. +The config and the scripts are VENDORED into each repo alongside the workflow — +they are NOT fetched at run time, so the gate is fully reviewable and cannot be +reprogrammed out-of-band. A repo may extend its local `.gitleaks.toml` to add +rules. **Private-repo name denylist:** the list of private repo/product names to block is *not* baked into the (public) script. Set an org-level Actions **variable** @@ -41,9 +46,13 @@ check is skipped when the variable is empty. **Allowlisting:** annotate a verified-safe line with `# guard:allow `, or add a path glob to a `.guardignore` at the repo root. -**Install + enforce:** copy `public-repo-guard.yml` into `.github/workflows/` -(or use the "New workflow" UI), then add `public-repo-guard / Secrets + content -policy` to the branch's required status checks so it blocks merges. +**Install + enforce:** copy the five files the workflow's header lists — +`public-repo-guard.yml` into `.github/workflows/`, plus `.gitleaks.toml`, +`scripts/public-repo-guard/content-policy.sh`, +`scripts/public-repo-guard/body-policy.sh`, and +`scripts/public-repo-guard/tests/body-policy.test.sh` — then add +`public-repo-guard / Secrets + content policy` to the branch's required status +checks so it blocks merges. ## How to add a new template diff --git a/workflow-templates/public-repo-guard.yml b/workflow-templates/public-repo-guard.yml index 719718a..79ff581 100644 --- a/workflow-templates/public-repo-guard.yml +++ b/workflow-templates/public-repo-guard.yml @@ -13,10 +13,12 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all three files together: +# To install on a new repo, copy all five files together: # .github/workflows/public-repo-guard.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh +# scripts/public-repo-guard/body-policy.sh +# scripts/public-repo-guard/tests/body-policy.test.sh # # Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The # goal is "what is public right now is clean", so a shallow checkout is sufficient. @@ -25,24 +27,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 +93,79 @@ 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 }} + # The existence assertion turns "the sparse checkout resolved to nothing" + # into a named failure instead of bash's opaque "No such file or directory". + run: | + if [ ! -f scripts/public-repo-guard/body-policy.sh ]; then + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the sparse checkout — failing closed." + exit 2 + fi + bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" From 6413636471f4c10b8acd5d1a6282cd3866f1f528 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:34:42 +0000 Subject: [PATCH 03/12] ci: fail closed on filter errors, scope about-the-control allowlist to prose rules Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 38 ++++++++++++++----- .../tests/body-policy.test.sh | 4 ++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 1d3674b..2d04bfc 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -18,8 +18,9 @@ # 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. +# leak never carries the marker; a deliberate one is visible in a public diff). +# PROSE rules additionally exempt lines matching the ABOUT-THE-CONTROL allowlist +# below; credential-FORMAT rules never do — a real key is a leak on any line. set -uo pipefail FILE="${1:-}" @@ -36,14 +37,21 @@ 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. +# +# Scope: PROSE rules only (the ones tagged `about-exempt` below). A credential- +# FORMAT hit is a leak no matter what else the line says — a real key next to +# the words "public-repo-guard" is still a real key — so for those rules the +# only exemption is the explicit, visible `guard:allow ` marker. ABOUT_THE_CONTROL='(public-repo-guard|body-policy|content-policy|public-github-write-gate|\bNDA\s+(gate|guard|policy|denylist|sweep|scan|hook)\b|\bno\s+NDA\b|responsib\w*\s+disclos|SECURITY\.md)' -# check +# check [about-exempt] check() { - local sev="$1" name="$2" re="$3" why="$4" + local sev="$1" name="$2" re="$3" why="$4" scope="${5:-}" [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes - # because its scanner broke is worse than no gate: it reports success. + # because its scanner broke is worse than no gate: it reports success. The same + # contract applies to every FILTER stage below: exit 1 ("nothing left") is a + # normal outcome, exit >=2 must never be read as "no matches". local raw rc raw="$(rg -nP --no-filename -- "$re" "$FILE" 2>/dev/null)"; rc=$? if (( rc >= 2 )); then @@ -54,9 +62,18 @@ 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)" + 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 filter for rule '$name' — failing closed." + exit 2 + fi + if [[ "$scope" == "about-exempt" ]]; then + 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 filter for rule '$name' — failing closed." + exit 2 + fi + 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 @@ -101,7 +118,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:49:15 +0000 Subject: [PATCH 04/12] ci: cover review bodies, pin policy to base ref, drop pending-cancelling concurrency Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 85 +++++++++++++++++------- workflow-templates/README.md | 17 +++-- workflow-templates/public-repo-guard.yml | 85 +++++++++++++++++------- 3 files changed, 130 insertions(+), 57 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 79ff581..e53e0a1 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -31,6 +31,13 @@ on: # PR is first raised, and until this workflow covered it, nothing ever re-scanned. pull_request: types: [opened, edited, reopened, synchronize] + # Review summaries and inline review comments are SEPARATE events (GitHub does + # not deliver either as `issue_comment`) and are just as world-readable, so + # without these two a reviewer could publish the same content unscanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] issues: types: [opened, edited] issue_comment: @@ -44,10 +51,16 @@ on: permissions: contents: read -# Concurrency is per JOB, not per workflow: the two jobs want opposite behaviour. -# A workflow-level group would force one policy on both, and it showed: rapid body -# edits cancelled the tree job over and over, and every cancelled check-run stays -# attached to the commit, so the PR reported UNSTABLE while the live runs were green. +# Concurrency: only the tree job declares a group. The body job deliberately has +# NONE: a concurrency group holds at most one PENDING run, so under a burst of +# edits the queued scans in between get cancelled even with cancel-in-progress +# false, and those body versions never receive a verdict. The body job is seconds +# long; letting every event run to completion is the cheap, correct option. The +# tree job keeps a cancelling group (a push supersedes the tree), and history +# shows why the body side must not share it: when the group was workflow-level, +# 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: @@ -102,8 +115,8 @@ jobs: run: bash scripts/public-repo-guard/tests/body-policy.test.sh # The other half of a public repo's surface. `guard` above scans the published - # TREE; a PR/issue/comment BODY is just as world-readable and, until this job, - # was scanned by nothing server-side. That gap was real, not theoretical: a PR + # TREE; a PR/review/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. # @@ -113,26 +126,41 @@ jobs: # can stop that class before publication. body-guard: name: Body content policy - if: github.event_name == 'pull_request' || github.event_name == 'issues' || github.event_name == 'issue_comment' - 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 + # No concurrency group here, deliberately: see the note above the jobs block. + if: >- + github.event_name == 'pull_request' + || github.event_name == 'pull_request_review' + || github.event_name == 'pull_request_review_comment' + || github.event_name == 'issues' + || github.event_name == 'issue_comment' runs-on: ubuntu-latest steps: + # TRUSTED policy, not the PR's. On pull_request-family events the default + # checkout is the PR merge ref, so the gate would execute whatever + # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 + # (or to read what CI hands it) and this check would still report green. + # Pinning the checkout to the BASE revision means the PR under scan can + # never define the policy that judges it. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Only the gate's own scripts are needed — no reason to pay for the whole + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted + # Only the gate's own scripts are needed; no reason to pay for the whole # tree on every comment. sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false + # The PR's own copy, consulted in exactly ONE case: the base revision has no + # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo + # the base always has the script, so a hostile PR cannot reach this fallback + # by deleting or editing its copy; the trusted checkout above still wins. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: startsWith(github.event_name, 'pull_request') + with: + path: incoming + 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) @@ -147,25 +175,32 @@ 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, + .review.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) + - name: body policy (PR / issue / comment / review text) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} # The existence assertion turns "the sparse checkout resolved to nothing" # into a named failure instead of bash's opaque "No such file or directory". run: | - if [ ! -f scripts/public-repo-guard/body-policy.sh ]; then - echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the sparse checkout — failing closed." - exit 2 + POLICY="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$POLICY" ]; then + if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." + POLICY="incoming/scripts/public-repo-guard/body-policy.sh" + else + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." + exit 2 + fi fi - bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" + bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/workflow-templates/README.md b/workflow-templates/README.md index f3ed45a..69a1848 100644 --- a/workflow-templates/README.md +++ b/workflow-templates/README.md @@ -28,10 +28,10 @@ or use GitHub's "New workflow" UI and pick the WAVE template. live Stripe account IDs (`acct_…`), hardcoded Cloudflare `account_id`s, developer absolute paths (`/Users/…`), references to private WAVE repos, and committed `.env` files. -3. **body-policy.sh** — the same leak classes in PR / issue / comment TEXT, - which is equally world-readable and was previously scanned by nothing - server-side. On a PR it blocks the merge; on an issue or comment it detects - so the text can be redacted fast. +3. **body-policy.sh** — the same leak classes in PR / review / issue / comment + TEXT, which is equally world-readable and was previously scanned by nothing + server-side. On a PR it blocks the merge; on an issue, comment, or review it + detects so the text can be redacted fast. The config and the scripts are VENDORED into each repo alongside the workflow — they are NOT fetched at run time, so the gate is fully reviewable and cannot be @@ -50,9 +50,12 @@ add a path glob to a `.guardignore` at the repo root. `public-repo-guard.yml` into `.github/workflows/`, plus `.gitleaks.toml`, `scripts/public-repo-guard/content-policy.sh`, `scripts/public-repo-guard/body-policy.sh`, and -`scripts/public-repo-guard/tests/body-policy.test.sh` — then add -`public-repo-guard / Secrets + content policy` to the branch's required status -checks so it blocks merges. +`scripts/public-repo-guard/tests/body-policy.test.sh` — then add BOTH check +names, `public-repo-guard / Secrets + content policy` and +`public-repo-guard / Body content policy`, to the branch's required status +checks. The tree check alone does not gate body edits: on an `edited` event +only the body job runs, so without the second required check a failing body +scan leaves the PR mergeable. ## How to add a new template diff --git a/workflow-templates/public-repo-guard.yml b/workflow-templates/public-repo-guard.yml index 79ff581..e53e0a1 100644 --- a/workflow-templates/public-repo-guard.yml +++ b/workflow-templates/public-repo-guard.yml @@ -31,6 +31,13 @@ on: # PR is first raised, and until this workflow covered it, nothing ever re-scanned. pull_request: types: [opened, edited, reopened, synchronize] + # Review summaries and inline review comments are SEPARATE events (GitHub does + # not deliver either as `issue_comment`) and are just as world-readable, so + # without these two a reviewer could publish the same content unscanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] issues: types: [opened, edited] issue_comment: @@ -44,10 +51,16 @@ on: permissions: contents: read -# Concurrency is per JOB, not per workflow: the two jobs want opposite behaviour. -# A workflow-level group would force one policy on both, and it showed: rapid body -# edits cancelled the tree job over and over, and every cancelled check-run stays -# attached to the commit, so the PR reported UNSTABLE while the live runs were green. +# Concurrency: only the tree job declares a group. The body job deliberately has +# NONE: a concurrency group holds at most one PENDING run, so under a burst of +# edits the queued scans in between get cancelled even with cancel-in-progress +# false, and those body versions never receive a verdict. The body job is seconds +# long; letting every event run to completion is the cheap, correct option. The +# tree job keeps a cancelling group (a push supersedes the tree), and history +# shows why the body side must not share it: when the group was workflow-level, +# 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: @@ -102,8 +115,8 @@ jobs: run: bash scripts/public-repo-guard/tests/body-policy.test.sh # The other half of a public repo's surface. `guard` above scans the published - # TREE; a PR/issue/comment BODY is just as world-readable and, until this job, - # was scanned by nothing server-side. That gap was real, not theoretical: a PR + # TREE; a PR/review/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. # @@ -113,26 +126,41 @@ jobs: # can stop that class before publication. body-guard: name: Body content policy - if: github.event_name == 'pull_request' || github.event_name == 'issues' || github.event_name == 'issue_comment' - 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 + # No concurrency group here, deliberately: see the note above the jobs block. + if: >- + github.event_name == 'pull_request' + || github.event_name == 'pull_request_review' + || github.event_name == 'pull_request_review_comment' + || github.event_name == 'issues' + || github.event_name == 'issue_comment' runs-on: ubuntu-latest steps: + # TRUSTED policy, not the PR's. On pull_request-family events the default + # checkout is the PR merge ref, so the gate would execute whatever + # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 + # (or to read what CI hands it) and this check would still report green. + # Pinning the checkout to the BASE revision means the PR under scan can + # never define the policy that judges it. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Only the gate's own scripts are needed — no reason to pay for the whole + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted + # Only the gate's own scripts are needed; no reason to pay for the whole # tree on every comment. sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false + # The PR's own copy, consulted in exactly ONE case: the base revision has no + # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo + # the base always has the script, so a hostile PR cannot reach this fallback + # by deleting or editing its copy; the trusted checkout above still wins. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: startsWith(github.event_name, 'pull_request') + with: + path: incoming + 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) @@ -147,25 +175,32 @@ 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, + .review.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) + - name: body policy (PR / issue / comment / review text) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} # The existence assertion turns "the sparse checkout resolved to nothing" # into a named failure instead of bash's opaque "No such file or directory". run: | - if [ ! -f scripts/public-repo-guard/body-policy.sh ]; then - echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the sparse checkout — failing closed." - exit 2 + POLICY="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$POLICY" ]; then + if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." + POLICY="incoming/scripts/public-repo-guard/body-policy.sh" + else + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." + exit 2 + fi fi - bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" + bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" From 07d1dbd903b6dd0d4d29c18ff68b5435399eb189 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:51:27 +0000 Subject: [PATCH 05/12] ci: scan only what the event changed, so one bad body cannot redden every later comment Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 15 +++++++++++---- workflow-templates/public-repo-guard.yml | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index e53e0a1..5dcac65 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -179,10 +179,17 @@ jobs: 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, - .review.body, - .issue.title, .issue.body, - .comment.body] + # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets + # its own verdict on its own event (a PR/issue body on opened/edited, a + # review on submitted/edited, a comment on created/edited). Folding the + # parent body into every comment scan would let one bad, unedited body + # keep every later comment red regardless of its content — exactly the + # permanent-noise failure mode that gets a gate switched off. + jq -r 'if has("comment") then [.comment.body] + elif has("review") then [.review.body] + elif has("pull_request") then [.pull_request.title, .pull_request.body] + else [.issue.title, .issue.body] + end | map(select(. != null)) | join("\n")' \ "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" diff --git a/workflow-templates/public-repo-guard.yml b/workflow-templates/public-repo-guard.yml index e53e0a1..5dcac65 100644 --- a/workflow-templates/public-repo-guard.yml +++ b/workflow-templates/public-repo-guard.yml @@ -179,10 +179,17 @@ jobs: 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, - .review.body, - .issue.title, .issue.body, - .comment.body] + # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets + # its own verdict on its own event (a PR/issue body on opened/edited, a + # review on submitted/edited, a comment on created/edited). Folding the + # parent body into every comment scan would let one bad, unedited body + # keep every later comment red regardless of its content — exactly the + # permanent-noise failure mode that gets a gate switched off. + jq -r 'if has("comment") then [.comment.body] + elif has("review") then [.review.body] + elif has("pull_request") then [.pull_request.title, .pull_request.body] + else [.issue.title, .issue.body] + end | map(select(. != null)) | join("\n")' \ "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" From 0e6354e4e974f088c5613184956b8617480473d6 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:04:11 +0000 Subject: [PATCH 06/12] ci: split the body gate into its own workflow file; a skipped tree job superseded a failing required check Co-authored-by: Codesmith --- .github/workflows/public-repo-guard-body.yml | 148 ++++++++++++++++ .github/workflows/public-repo-guard.yml | 161 +++--------------- scripts/public-repo-guard/body-policy.sh | 6 +- .../tests/body-policy.test.sh | 6 + workflow-templates/README.md | 20 ++- .../public-repo-guard-body.properties.json | 7 + workflow-templates/public-repo-guard-body.yml | 148 ++++++++++++++++ workflow-templates/public-repo-guard.yml | 161 +++--------------- 8 files changed, 381 insertions(+), 276 deletions(-) create mode 100644 .github/workflows/public-repo-guard-body.yml create mode 100644 workflow-templates/public-repo-guard-body.properties.json create mode 100644 workflow-templates/public-repo-guard-body.yml diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml new file mode 100644 index 0000000..d57062f --- /dev/null +++ b/.github/workflows/public-repo-guard-body.yml @@ -0,0 +1,148 @@ +# Same display name as public-repo-guard.yml, deliberately: the two files are one +# gate, and sharing the name keeps the check contexts reading exactly as branch +# protection requires them — `public-repo-guard / Secrets + content policy` and +# `public-repo-guard / Body content policy`. +name: public-repo-guard + +# Pre-publication content gate for WAVE public repos — the BODY half. The tree +# workflow (public-repo-guard.yml) scans the published TREE; a PR/review/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. +# +# A SEPARATE FILE from the tree scan, and the split is load-bearing, not +# cosmetic. A job disabled by `if:` still publishes a check run under its context +# name with a non-failing `skipped` conclusion, and branch protection reads the +# LATEST check run per context. When both jobs shared one workflow, every +# body-only event (a description edit, a review comment) re-triggered the whole +# file, and the skipped tree job emitted a fresh `skipped` result for the same +# head SHA — superseding a FAILING `Secrets + content policy` run and making a PR +# the secret scan had rejected mergeable. Two files mean a body event can never +# produce a check run for the tree context, and vice versa. +# +# 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. +# +# To install on a new repo, copy all six files together (list in the tree +# workflow's header). + +on: + # `edited` matters as much as `opened`: a body can be made to leak long after the + # PR is first raised, and until this workflow covered it, nothing ever re-scanned. + # `synchronize` is here because this check is REQUIRED: every new head SHA needs + # a body verdict of its own or the merge box waits forever on an expected check. + pull_request: + types: [opened, edited, reopened, synchronize] + # Review summaries and inline review comments are SEPARATE events (GitHub does + # not deliver either as `issue_comment`) and are just as world-readable, so + # without these two a reviewer could publish the same content unscanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. +permissions: + contents: read + +# No concurrency group, deliberately: a group holds at most one PENDING run, so +# under a burst of edits the queued scans in between get cancelled even with +# cancel-in-progress false, and those body versions never receive a verdict. The +# body job is seconds long; letting every event run to completion is the cheap, +# correct option. History shows why it must not share the tree job's cancelling +# group either: when the group was workflow-level, 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: + body-guard: + name: Body content policy + # No `if:` — every trigger of this file is a body event. That is not just + # tidiness: a job-level skip would emit a `skipped` check run under this + # context, the exact superseding hazard this file exists to remove. + runs-on: ubuntu-latest + steps: + # TRUSTED policy, not the PR's. On pull_request-family events the default + # checkout is the PR merge ref, so the gate would execute whatever + # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 + # (or to read what CI hands it) and this check would still report green. + # Pinning the checkout to the BASE revision means the PR under scan can + # never define the policy that judges it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted + # Only the gate's own scripts are needed; no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + # The PR's own copy, consulted in exactly ONE case: the base revision has no + # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo + # the base always has the script, so a hostile PR cannot reach this fallback + # by deleting or editing its copy; the trusted checkout above still wins. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: startsWith(github.event_name, 'pull_request') + with: + path: incoming + 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") 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 + # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets + # its own verdict on its own event (a PR/issue body on opened/edited, a + # review on submitted/edited, a comment on created/edited). Folding the + # parent body into every comment scan would let one bad, unedited body + # keep every later comment red regardless of its content — exactly the + # permanent-noise failure mode that gets a gate switched off. + jq -r 'if has("comment") then [.comment.body] + elif has("review") then [.review.body] + elif has("pull_request") then [.pull_request.title, .pull_request.body] + else [.issue.title, .issue.body] + end + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment / review text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + # The existence assertion turns "the sparse checkout resolved to nothing" + # into a named failure instead of bash's opaque "No such file or directory". + run: | + POLICY="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$POLICY" ]; then + if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." + POLICY="incoming/scripts/public-repo-guard/body-policy.sh" + else + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." + exit 2 + fi + fi + bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 5dcac65..ecc4a69 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -1,11 +1,22 @@ name: public-repo-guard -# Pre-publication content gate for WAVE public repos. Two complementary checks: +# Pre-publication content gate for WAVE public repos — the TREE half. Two +# complementary checks: # 1. gitleaks — formatted secrets (API keys, tokens, private keys) in the tree. # 2. content-policy.sh — WAVE-specific leaks gitleaks misses: live Stripe account # IDs, hardcoded Cloudflare account_ids, developer absolute paths, references # to private WAVE repos, and committed .env files. # +# The BODY half (PR/review/issue/comment text) lives in its own file, +# public-repo-guard-body.yml, and the split is load-bearing, not cosmetic. A job +# disabled by `if:` still publishes a check run under its context name with a +# non-failing `skipped` conclusion, and branch protection reads the LATEST check +# run per context. When body events triggered this file, every body edit or +# review comment made the skipped tree job emit a fresh `skipped` result for the +# same head SHA — superseding a FAILING `Secrets + content policy` run and making +# a PR the secret scan had rejected mergeable. Separate files mean a body event +# can never produce a check run for the tree context, and vice versa. +# # Self-contained by design: the gitleaks config (.gitleaks.toml) and the policy # script (scripts/public-repo-guard/content-policy.sh) are VENDORED into the repo # alongside this workflow — they are NOT fetched at run time. The gate is therefore @@ -13,8 +24,9 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all five files together: +# To install on a new repo, copy all six files together: # .github/workflows/public-repo-guard.yml +# .github/workflows/public-repo-guard-body.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh # scripts/public-repo-guard/body-policy.sh @@ -27,51 +39,28 @@ 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] - # Review summaries and inline review comments are SEPARATE events (GitHub does - # not deliver either as `issue_comment`) and are just as world-readable, so - # without these two a reviewer could publish the same content unscanned. - pull_request_review: - types: [submitted, edited] - pull_request_review_comment: - types: [created, edited] - issues: - types: [opened, edited] - issue_comment: - types: [created, edited] + # No `edited`, deliberately: a title or body edit does not change the tree, + # and per the header it must not emit a tree check run either — a skipped run + # for an unchanged tree would supersede a failing one. Body edits are the + # body workflow's job. + types: [opened, reopened, synchronize] push: branches: [main, master] workflow_dispatch: # `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get -# a write token or repo secrets just because a gate wanted to read its body. +# a write token or repo secrets just because a gate wanted to read its tree. permissions: contents: read -# Concurrency: only the tree job declares a group. The body job deliberately has -# NONE: a concurrency group holds at most one PENDING run, so under a burst of -# edits the queued scans in between get cancelled even with cancel-in-progress -# false, and those body versions never receive a verdict. The body job is seconds -# long; letting every event run to completion is the cheap, correct option. The -# tree job keeps a cancelling group (a push supersedes the tree), and history -# shows why the body side must not share it: when the group was workflow-level, -# 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' + # A cancelling group: a push supersedes the tree, and every trigger of this + # file is a tree event, so nothing body-shaped can cancel a tree run (the + # failure mode that plagued the old workflow-level group — see the body + # workflow's header for that history). concurrency: group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -109,105 +98,7 @@ jobs: # 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. + # and then it protects nothing. Runs here (the tree job) because the script + # under test is tree content and only changes when the tree does. - 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/review/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 - # No concurrency group here, deliberately: see the note above the jobs block. - if: >- - github.event_name == 'pull_request' - || github.event_name == 'pull_request_review' - || github.event_name == 'pull_request_review_comment' - || github.event_name == 'issues' - || github.event_name == 'issue_comment' - runs-on: ubuntu-latest - steps: - # TRUSTED policy, not the PR's. On pull_request-family events the default - # checkout is the PR merge ref, so the gate would execute whatever - # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 - # (or to read what CI hands it) and this check would still report green. - # Pinning the checkout to the BASE revision means the PR under scan can - # never define the policy that judges it. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha || github.sha }} - path: trusted - # Only the gate's own scripts are needed; no reason to pay for the whole - # tree on every comment. - sparse-checkout: scripts/public-repo-guard - sparse-checkout-cone-mode: false - - # The PR's own copy, consulted in exactly ONE case: the base revision has no - # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo - # the base always has the script, so a hostile PR cannot reach this fallback - # by deleting or editing its copy; the trusted checkout above still wins. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: startsWith(github.event_name, 'pull_request') - with: - path: incoming - 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") 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 - # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets - # its own verdict on its own event (a PR/issue body on opened/edited, a - # review on submitted/edited, a comment on created/edited). Folding the - # parent body into every comment scan would let one bad, unedited body - # keep every later comment red regardless of its content — exactly the - # permanent-noise failure mode that gets a gate switched off. - jq -r 'if has("comment") then [.comment.body] - elif has("review") then [.review.body] - elif has("pull_request") then [.pull_request.title, .pull_request.body] - else [.issue.title, .issue.body] - end - | map(select(. != null)) | join("\n")' \ - "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" - echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" - - - name: body policy (PR / issue / comment / review text) - env: - GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - # The existence assertion turns "the sparse checkout resolved to nothing" - # into a named failure instead of bash's opaque "No such file or directory". - run: | - POLICY="trusted/scripts/public-repo-guard/body-policy.sh" - if [ ! -f "$POLICY" ]; then - if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then - echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." - POLICY="incoming/scripts/public-repo-guard/body-policy.sh" - else - echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." - exit 2 - fi - fi - bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 2d04bfc..c835a57 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -118,7 +118,11 @@ check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'O # A quoted marker is also a trivial bypass, and that is an accepted trade. The # threat here is the ACCIDENTAL paste; a deliberate evader has easier routes, and # `guard:allow ` already exists as the honest, visible one. -check BLOCK internal-marker '(?`, or add a path glob to a `.guardignore` at the repo root. -**Install + enforce:** copy the five files the workflow's header lists — -`public-repo-guard.yml` into `.github/workflows/`, plus `.gitleaks.toml`, +**Install + enforce:** copy the six files the workflow's header lists — +`public-repo-guard.yml` AND `public-repo-guard-body.yml` into +`.github/workflows/`, plus `.gitleaks.toml`, `scripts/public-repo-guard/content-policy.sh`, `scripts/public-repo-guard/body-policy.sh`, and `scripts/public-repo-guard/tests/body-policy.test.sh` — then add BOTH check names, `public-repo-guard / Secrets + content policy` and `public-repo-guard / Body content policy`, to the branch's required status -checks. The tree check alone does not gate body edits: on an `edited` event -only the body job runs, so without the second required check a failing body -scan leaves the PR mergeable. +checks. The tree check alone does not gate body edits: those only trigger the +body workflow, so without the second required check a failing body scan leaves +the PR mergeable. + +The two workflow FILES are deliberate, not an accident of packaging. A job +disabled by `if:` still publishes a check run with a non-failing `skipped` +conclusion, and branch protection reads the latest check run per name — so if +body events triggered the tree scan's file, a description edit or review +comment would let a skipped tree job supersede a FAILING +`Secrets + content policy` result on the same commit. Separate files mean a +body event can never emit a check run for the tree context. Install both or +neither; do not merge them back into one file. ## How to add a new template diff --git a/workflow-templates/public-repo-guard-body.properties.json b/workflow-templates/public-repo-guard-body.properties.json new file mode 100644 index 0000000..16c1c98 --- /dev/null +++ b/workflow-templates/public-repo-guard-body.properties.json @@ -0,0 +1,7 @@ +{ + "name": "Public-repo guard (body content policy)", + "description": "Companion to the public-repo guard tree scan: runs the WAVE body-policy script over PR titles/bodies, review bodies, and issue/comment text, which are equally world-readable. Deliberately a separate workflow file so body-only events never emit a check run for the tree context (a skipped run would supersede a failing required check). Install alongside public-repo-guard.yml on every public repo.", + "iconName": "octicon shield-lock", + "categories": ["Continuous integration", "Security", "WAVE"], + "filePatterns": [".*"] +} diff --git a/workflow-templates/public-repo-guard-body.yml b/workflow-templates/public-repo-guard-body.yml new file mode 100644 index 0000000..d57062f --- /dev/null +++ b/workflow-templates/public-repo-guard-body.yml @@ -0,0 +1,148 @@ +# Same display name as public-repo-guard.yml, deliberately: the two files are one +# gate, and sharing the name keeps the check contexts reading exactly as branch +# protection requires them — `public-repo-guard / Secrets + content policy` and +# `public-repo-guard / Body content policy`. +name: public-repo-guard + +# Pre-publication content gate for WAVE public repos — the BODY half. The tree +# workflow (public-repo-guard.yml) scans the published TREE; a PR/review/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. +# +# A SEPARATE FILE from the tree scan, and the split is load-bearing, not +# cosmetic. A job disabled by `if:` still publishes a check run under its context +# name with a non-failing `skipped` conclusion, and branch protection reads the +# LATEST check run per context. When both jobs shared one workflow, every +# body-only event (a description edit, a review comment) re-triggered the whole +# file, and the skipped tree job emitted a fresh `skipped` result for the same +# head SHA — superseding a FAILING `Secrets + content policy` run and making a PR +# the secret scan had rejected mergeable. Two files mean a body event can never +# produce a check run for the tree context, and vice versa. +# +# 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. +# +# To install on a new repo, copy all six files together (list in the tree +# workflow's header). + +on: + # `edited` matters as much as `opened`: a body can be made to leak long after the + # PR is first raised, and until this workflow covered it, nothing ever re-scanned. + # `synchronize` is here because this check is REQUIRED: every new head SHA needs + # a body verdict of its own or the merge box waits forever on an expected check. + pull_request: + types: [opened, edited, reopened, synchronize] + # Review summaries and inline review comments are SEPARATE events (GitHub does + # not deliver either as `issue_comment`) and are just as world-readable, so + # without these two a reviewer could publish the same content unscanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. +permissions: + contents: read + +# No concurrency group, deliberately: a group holds at most one PENDING run, so +# under a burst of edits the queued scans in between get cancelled even with +# cancel-in-progress false, and those body versions never receive a verdict. The +# body job is seconds long; letting every event run to completion is the cheap, +# correct option. History shows why it must not share the tree job's cancelling +# group either: when the group was workflow-level, 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: + body-guard: + name: Body content policy + # No `if:` — every trigger of this file is a body event. That is not just + # tidiness: a job-level skip would emit a `skipped` check run under this + # context, the exact superseding hazard this file exists to remove. + runs-on: ubuntu-latest + steps: + # TRUSTED policy, not the PR's. On pull_request-family events the default + # checkout is the PR merge ref, so the gate would execute whatever + # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 + # (or to read what CI hands it) and this check would still report green. + # Pinning the checkout to the BASE revision means the PR under scan can + # never define the policy that judges it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted + # Only the gate's own scripts are needed; no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + # The PR's own copy, consulted in exactly ONE case: the base revision has no + # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo + # the base always has the script, so a hostile PR cannot reach this fallback + # by deleting or editing its copy; the trusted checkout above still wins. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: startsWith(github.event_name, 'pull_request') + with: + path: incoming + 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") 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 + # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets + # its own verdict on its own event (a PR/issue body on opened/edited, a + # review on submitted/edited, a comment on created/edited). Folding the + # parent body into every comment scan would let one bad, unedited body + # keep every later comment red regardless of its content — exactly the + # permanent-noise failure mode that gets a gate switched off. + jq -r 'if has("comment") then [.comment.body] + elif has("review") then [.review.body] + elif has("pull_request") then [.pull_request.title, .pull_request.body] + else [.issue.title, .issue.body] + end + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment / review text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + # The existence assertion turns "the sparse checkout resolved to nothing" + # into a named failure instead of bash's opaque "No such file or directory". + run: | + POLICY="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$POLICY" ]; then + if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." + POLICY="incoming/scripts/public-repo-guard/body-policy.sh" + else + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." + exit 2 + fi + fi + bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/workflow-templates/public-repo-guard.yml b/workflow-templates/public-repo-guard.yml index 5dcac65..ecc4a69 100644 --- a/workflow-templates/public-repo-guard.yml +++ b/workflow-templates/public-repo-guard.yml @@ -1,11 +1,22 @@ name: public-repo-guard -# Pre-publication content gate for WAVE public repos. Two complementary checks: +# Pre-publication content gate for WAVE public repos — the TREE half. Two +# complementary checks: # 1. gitleaks — formatted secrets (API keys, tokens, private keys) in the tree. # 2. content-policy.sh — WAVE-specific leaks gitleaks misses: live Stripe account # IDs, hardcoded Cloudflare account_ids, developer absolute paths, references # to private WAVE repos, and committed .env files. # +# The BODY half (PR/review/issue/comment text) lives in its own file, +# public-repo-guard-body.yml, and the split is load-bearing, not cosmetic. A job +# disabled by `if:` still publishes a check run under its context name with a +# non-failing `skipped` conclusion, and branch protection reads the LATEST check +# run per context. When body events triggered this file, every body edit or +# review comment made the skipped tree job emit a fresh `skipped` result for the +# same head SHA — superseding a FAILING `Secrets + content policy` run and making +# a PR the secret scan had rejected mergeable. Separate files mean a body event +# can never produce a check run for the tree context, and vice versa. +# # Self-contained by design: the gitleaks config (.gitleaks.toml) and the policy # script (scripts/public-repo-guard/content-policy.sh) are VENDORED into the repo # alongside this workflow — they are NOT fetched at run time. The gate is therefore @@ -13,8 +24,9 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all five files together: +# To install on a new repo, copy all six files together: # .github/workflows/public-repo-guard.yml +# .github/workflows/public-repo-guard-body.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh # scripts/public-repo-guard/body-policy.sh @@ -27,51 +39,28 @@ 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] - # Review summaries and inline review comments are SEPARATE events (GitHub does - # not deliver either as `issue_comment`) and are just as world-readable, so - # without these two a reviewer could publish the same content unscanned. - pull_request_review: - types: [submitted, edited] - pull_request_review_comment: - types: [created, edited] - issues: - types: [opened, edited] - issue_comment: - types: [created, edited] + # No `edited`, deliberately: a title or body edit does not change the tree, + # and per the header it must not emit a tree check run either — a skipped run + # for an unchanged tree would supersede a failing one. Body edits are the + # body workflow's job. + types: [opened, reopened, synchronize] push: branches: [main, master] workflow_dispatch: # `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get -# a write token or repo secrets just because a gate wanted to read its body. +# a write token or repo secrets just because a gate wanted to read its tree. permissions: contents: read -# Concurrency: only the tree job declares a group. The body job deliberately has -# NONE: a concurrency group holds at most one PENDING run, so under a burst of -# edits the queued scans in between get cancelled even with cancel-in-progress -# false, and those body versions never receive a verdict. The body job is seconds -# long; letting every event run to completion is the cheap, correct option. The -# tree job keeps a cancelling group (a push supersedes the tree), and history -# shows why the body side must not share it: when the group was workflow-level, -# 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' + # A cancelling group: a push supersedes the tree, and every trigger of this + # file is a tree event, so nothing body-shaped can cancel a tree run (the + # failure mode that plagued the old workflow-level group — see the body + # workflow's header for that history). concurrency: group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -109,105 +98,7 @@ jobs: # 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. + # and then it protects nothing. Runs here (the tree job) because the script + # under test is tree content and only changes when the tree does. - 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/review/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 - # No concurrency group here, deliberately: see the note above the jobs block. - if: >- - github.event_name == 'pull_request' - || github.event_name == 'pull_request_review' - || github.event_name == 'pull_request_review_comment' - || github.event_name == 'issues' - || github.event_name == 'issue_comment' - runs-on: ubuntu-latest - steps: - # TRUSTED policy, not the PR's. On pull_request-family events the default - # checkout is the PR merge ref, so the gate would execute whatever - # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 - # (or to read what CI hands it) and this check would still report green. - # Pinning the checkout to the BASE revision means the PR under scan can - # never define the policy that judges it. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha || github.sha }} - path: trusted - # Only the gate's own scripts are needed; no reason to pay for the whole - # tree on every comment. - sparse-checkout: scripts/public-repo-guard - sparse-checkout-cone-mode: false - - # The PR's own copy, consulted in exactly ONE case: the base revision has no - # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo - # the base always has the script, so a hostile PR cannot reach this fallback - # by deleting or editing its copy; the trusted checkout above still wins. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: startsWith(github.event_name, 'pull_request') - with: - path: incoming - 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") 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 - # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets - # its own verdict on its own event (a PR/issue body on opened/edited, a - # review on submitted/edited, a comment on created/edited). Folding the - # parent body into every comment scan would let one bad, unedited body - # keep every later comment red regardless of its content — exactly the - # permanent-noise failure mode that gets a gate switched off. - jq -r 'if has("comment") then [.comment.body] - elif has("review") then [.review.body] - elif has("pull_request") then [.pull_request.title, .pull_request.body] - else [.issue.title, .issue.body] - end - | map(select(. != null)) | join("\n")' \ - "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" - echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" - - - name: body policy (PR / issue / comment / review text) - env: - GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - # The existence assertion turns "the sparse checkout resolved to nothing" - # into a named failure instead of bash's opaque "No such file or directory". - run: | - POLICY="trusted/scripts/public-repo-guard/body-policy.sh" - if [ ! -f "$POLICY" ]; then - if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then - echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." - POLICY="incoming/scripts/public-repo-guard/body-policy.sh" - else - echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." - exit 2 - fi - fi - bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" From 5279e32a303ccb30ef00eeb450f01a2ac70c5838 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:13:32 +0000 Subject: [PATCH 07/12] =?UTF-8?q?ci:=20document=20where=20detection=20verd?= =?UTF-8?q?icts=20land=20=E2=80=94=20a=20red=20check=20on=20main=20is=20th?= =?UTF-8?q?e=20alarm,=20not=20a=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codesmith --- .github/workflows/public-repo-guard-body.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml index d57062f..5f72b67 100644 --- a/.github/workflows/public-repo-guard-body.yml +++ b/.github/workflows/public-repo-guard-body.yml @@ -26,6 +26,17 @@ name: public-repo-guard # detection — it tells us to go redact, fast. Only the client-side pre-write hook # can stop that class before publication. # +# WHERE a detection verdict lands is a side effect worth stating so nobody +# rediscovers it as a bug: only pull_request events attach the check run to the +# PR head SHA (the only place branch protection reads it). On `issues` and +# `issue_comment` events GITHUB_SHA is the DEFAULT-BRANCH head, so a failing +# scan pins a red `Body content policy` check run to the tip of main; review +# events pin it to the PR merge commit. Accepted, not accidental: detection has +# no merge to block, and with only a read token a red check on main is the +# loudest alarm this job can raise. Anything that reads main's combined status +# (release gating, merge queues) will see that red until a later body event +# passes under the same context — treat that as the alarm working, and redact. +# # To install on a new repo, copy all six files together (list in the tree # workflow's header). From 351b5286651a81a81fbca84dcabc3d8308c364ad Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:23:59 +0000 Subject: [PATCH 08/12] ci: use fictitious repo names in body-policy fixtures; document tree-job policy provenance The fixture file pinned three real-looking private repo names into the one path neither gate scans (content-policy.sh excludes scripts/public-repo-guard/ and .gitleaks.toml allowlists it), against body-policy.sh's own 'names are NOT hardcoded' rule. The test names are now clearly fictitious and the file says why. The tree job's use of the PR's own content-policy.sh is now documented as deliberate: on pull_request events the workflow definition itself runs from the PR merge ref, so pinning the script to base removes no attacker capability, and the tree gate must scan and self-test the policy version the tree ships so script and fixtures can change together. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 13 ++++++++++ .../tests/body-policy.test.sh | 25 +++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index ecc4a69..beeb008 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -91,6 +91,19 @@ jobs: - name: Install ripgrep run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # Deliberately the PR's OWN copy of the policy, and the asymmetry with the + # body workflow's trusted-base checkout is intentional, not an oversight. + # Two reasons. First, pinning to base removes no attacker capability here: + # on pull_request events the workflow DEFINITION itself executes from the + # PR merge ref, so a PR that could gut content-policy.sh could as easily + # rewrite this file into a no-op job with the same required-check name. + # That vector is covered by review of gate-file diffs and by the push + # trigger, which re-scans main with main's own policy after merge. Second, + # unlike the body gate (which judges TEXT, so its policy version need not + # track the tree), this job judges THE TREE the policy ships in: a PR that + # legitimately updates the policy or its fixtures must be scanned and + # self-tested by the version it ships, or script and fixtures could never + # change together. - name: content policy (WAVE trade-secret / internal-leak gate) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index e32e07d..2bd5ec8 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -14,8 +14,13 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT # The names the real gate is configured with come from an org variable; the tests -# pin their own so they are hermetic and do not depend on CI configuration. -export GUARD_PRIVATE_REPOS="wave-gateway, wave-transports, agent-money" +# pin their own so they are hermetic and do not depend on CI configuration. The +# pinned names are deliberately FICTITIOUS: this file is public, and it sits in +# the one path NEITHER gate scans (content-policy.sh excludes +# scripts/public-repo-guard/ and .gitleaks.toml allowlists it), so a real +# private-repo name written here would be published with nothing able to flag +# it, the exact violation of body-policy.sh's "names are NOT hardcoded" rule. +export GUARD_PRIVATE_REPOS="fixture-repo-alpha, fixture-repo-beta, fixture-repo-gamma" PASS=0; FAIL=0 @@ -39,13 +44,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: WAVE_VIEWPORT_LEASE_SECRET is bound on fixture-repo-alpha now.' expect 1 'private repo + credential name, reverse order' \ - 'The MOQ_JOIN_SECRET was added; wave-transports picks it up on deploy.' + 'The MOQ_JOIN_SECRET was added; fixture-repo-beta picks it up on deploy.' expect 1 'private repo + secret count' \ - 'wave-gateway went from 74 secrets to 75 after this change.' + 'fixture-repo-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 fixture-repo-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' \ @@ -70,21 +75,21 @@ expect 1 'credential leak is NOT exempted by naming the control' \ # --- must PASS (precision — these keep the gate deployable) ------------------- expect 0 'bare private-repo cross-reference' \ - 'This is the companion change to wave-transports#260; merge that one first.' + 'This is the companion change to fixture-repo-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 fixture-repo-alpha and fixture-repo-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: a bare (?i) prefix once made the SCREAMING_CASE credential pattern # case-blind, so lowercase "api_key" near a private repo name blocked the body. expect 0 'lowercase credential-ish word near a private repo' \ - 'Companion to wave-gateway#12: fixes the api_key parsing bug in the client.' + 'Companion to fixture-repo-alpha#12: fixes the api_key parsing bug in 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: fixture-repo-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 5e07da93b66ce671b50d0c11179dd236dd32804e Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:32:15 +0000 Subject: [PATCH 09/12] fix: allow underscores in the proximity rule's credential-name class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With [A-Z0-9]* the class excluded _, so for a multi-segment name like WAVE_VIEWPORT_LEASE_SECRET only the trailing LEASE_SECRET could match, and its start sits right after a _ (a word character) — the \b prepended in the name-then-detail alternative was unsatisfiable, silently passing that order. The detail-then-name order has no leading \b, which is why the existing fixtures stayed green. Adds a regression fixture for the forward order, plus a fixture pinning the deliberate about-exempt behaviour of the proximity rule (a line that names the gate is dropped even when it carries the leak shape), which was previously untested. Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 7 ++++++- scripts/public-repo-guard/tests/body-policy.test.sh | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index c835a57..1634891 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -140,7 +140,12 @@ check BLOCK internal-marker '(?i)(? Date: Thu, 6 Aug 2026 18:45:51 +0000 Subject: [PATCH 10/12] =?UTF-8?q?ci:=20anchor=20the=20home-path=20rule=20a?= =?UTF-8?q?nd=20exempt=20do-not=20prose=20=E2=80=94=20a=20body=20gate=20th?= =?UTF-8?q?at=20reddens=20docs=20paths=20gets=20switched=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 28 ++++++++++++++++--- .../tests/body-policy.test.sh | 18 ++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 1634891..d780e6e 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -17,8 +17,12 @@ # # 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). +# Allowlisting: a line carrying `guard:allow ` is exempt. The threat +# here is the ACCIDENTAL paste, which never carries the marker. A deliberate +# leaker gains nothing from it — they can already evade any regex by reshaping +# the text — and unlike an evasion, the marker is a visible, greppable string +# sitting in the world-readable body itself. It is also the only escape hatch +# that lets a security discussion quote e.g. an internal IP at all. # PROSE rules additionally exempt lines matching the ABOUT-THE-CONTROL allowlist # below; credential-FORMAT rules never do — a real key is a leak on any line. set -uo pipefail @@ -102,8 +106,16 @@ 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' +# The BODY profile diverges from content-policy.sh's verbatim rule here, on +# purpose. Prose is full of strings a tree rarely contains: relative paths +# ("docs/home/guides/setup") and URL routes ("example.com/home/status/") both +# embed a `/home//` substring without leaking anyone's home directory. +# So the match must BEGIN a path (no word/path character before the leading +# slash — start of line, whitespace, quotes, parens all qualify) and must +# CONTINUE past the username (a real leak is `/home/alice/wave/...`; a bare +# route like `/home/status/` followed by prose stays silent). # shellcheck disable=SC2016 # $HOME is literal guidance text -check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' +check BLOCK abs-user-path '(? alternative distinguishes BANNER from ORDINARY PROSE by what +# follows the verb. A banner is an imperative with no object ("DO NOT +# DISTRIBUTE", "do not share externally"); everyday scheduling prose takes an +# object or temporal clause ("do not publish until Friday", "do not share the +# link yet"). The lookahead exempts the latter — a determiner, pronoun, or +# temporal word right after the verb — because a gate that reddens routine +# release chatter is the gate that gets switched off. +check BLOCK internal-marker '(?i)(? alternative once fired on everyday scheduling +# prose; an object or temporal clause after the verb marks it as prose, not a +# banner. +expect 0 'scheduling prose with a do-not verb' \ + 'Please do not publish until Friday; the announcement is still in review.' +expect 0 'do-not verb with an ordinary object' \ + 'For now, do not share the link outside this thread.' +expect 1 'bare do-not banner still blocks' \ + 'DO NOT DISTRIBUTE' expect 0 'talking about the control' \ 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' # Pins the DELIBERATE trade for the proximity rule: it is about-exempt, so a line From 7f4f16b009d9d16cc4d774c4aec19ca5a493471e Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:57:37 +0000 Subject: [PATCH 11/12] =?UTF-8?q?ci:=20paragraph-scope=20the=20proximity?= =?UTF-8?q?=20gap=20=E2=80=94=20a=20hard-wrapped=20body=20defeated=20a=20l?= =?UTF-8?q?ine-scoped=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codesmith --- scripts/public-repo-guard/body-policy.sh | 35 ++++++++++++++++--- .../tests/body-policy.test.sh | 15 ++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index d780e6e..9353039 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -48,16 +48,29 @@ VIOLATIONS=0 # only exemption is the explicit, visible `guard:allow ` marker. 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 [about-exempt] +# check [about-exempt] [multiline] +# multiline: scan with rg -U so the regex may span line breaks (the regex +# itself must still bound what a gap may cross — see private-repo-ops). The +# guard:allow and about-the-control filters stay LINE-scoped: they drop the +# marker-bearing line from a multi-line match, so the match blocks unless +# every reported line carries an exemption. check() { - local sev="$1" name="$2" re="$3" why="$4" scope="${5:-}" + local sev="$1" name="$2" re="$3" why="$4"; shift 4 + local scope='' _ml=() _flag + for _flag in "$@"; do + case "$_flag" in + about-exempt) scope='about-exempt' ;; + multiline) _ml=(-U) ;; + *) echo "::error::body-policy: internal bug — unknown flag '$_flag' for rule '$name'"; exit 2 ;; + esac + done [[ -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. The same # contract applies to every FILTER stage below: exit 1 ("nothing left") is a # normal outcome, exit >=2 must never be read as "no matches". local raw rc - raw="$(rg -nP --no-filename -- "$re" "$FILE" 2>/dev/null)"; rc=$? + raw="$(rg -nP ${_ml[@]+"${_ml[@]}"} --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 @@ -157,6 +170,14 @@ check BLOCK internal-marker '(?i)(? Date: Thu, 6 Aug 2026 19:04:57 +0000 Subject: [PATCH 12/12] fix: let the paragraph break survive CRLF endings; add the changelog AGENTS.md requires Co-authored-by: Codesmith --- CHANGELOG.md | 24 +++++++++++++++++++ scripts/public-repo-guard/body-policy.sh | 6 ++++- .../tests/body-policy.test.sh | 8 +++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b8114f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +User-facing changes to this repository's org-wide workflow templates and vendored +guard scripts. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## Unreleased + +### Added + +- `public-repo-guard` now has a BODY gate: a second workflow file + (`public-repo-guard-body.yml`) plus `scripts/public-repo-guard/body-policy.sh` + scan pull-request titles/bodies, issue titles/bodies, comments, and review + bodies for the same leak classes the tree gate covers (credential formats, + infrastructure identifiers, internal markers, and private-repo names near + operational detail). Previously nothing scanned these world-readable surfaces + server-side. + +### Changed + +- Installing `public-repo-guard` in a repo now means vendoring six files and + requiring BOTH check names (`public-repo-guard / Secrets + content policy` + and `public-repo-guard / Body content policy`) in branch protection; the tree + check alone does not gate body edits. See `workflow-templates/README.md` for + the install and enforce steps. diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 9353039..f68153a 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -199,7 +199,11 @@ if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then # The proximity gap: any character INSIDE a paragraph. A newline is allowed # only when not followed by another (possibly whitespace-padded) blank line, # so hard wraps and bullets stay in scope while a paragraph break resets it. - _GAP='(?:[^\n]|\n(?![ \t]*\n)){0,140}?' + # `\r` is part of the padding class: GitHub delivers PR/issue/comment bodies + # CRLF-terminated, so a real paragraph break arrives as `\r\n\r\n` — without + # `\r` the lookahead never sees the second newline and a clean two-paragraph + # body is misread as one wiring statement. + _GAP='(?:[^\n]|\n(?![ \t\r]*\n)){0,140}?' # Both orders: name-then-detail and detail-then-name. Case-insensitivity is # scoped to the repo-NAME alternation only — a bare `(?i)` prefix would bleed # into OPS_DETAIL and make its SCREAMING_CASE credential pattern match diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 76f848b..2c9b447 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -102,6 +102,14 @@ expect 0 'private repo and credential name in separate paragraphs' \ 'Companion change to fixture-repo-alpha#41; merge that one first. Unrelated: WAVE_VIEWPORT_LEASE_SECRET is now read from the env template.' +# Regression: the paragraph-break lookahead once padded with [ \t] only, so a +# CRLF blank line (\r\n\r\n — the shape GitHub event payloads actually deliver) +# never matched, the newline was consumed as gap, and this clean two-paragraph +# body blocked. The hard-wrap twin proves CRLF wraps still stay in scope. +expect 0 'separate paragraphs with CRLF endings (GitHub payload shape)' \ + $'Companion change to fixture-repo-alpha#41; merge that one first.\r\n\r\nUnrelated: WAVE_VIEWPORT_LEASE_SECRET is now read from the env template.\r' +expect 1 'hard wrap with CRLF endings still blocks' \ + $'Rotation notes:\r\n- fixture-repo-alpha\r\n- WAVE_VIEWPORT_LEASE_SECRET rotated today\r' # Regression: a bare (?i) prefix once made the SCREAMING_CASE credential pattern # case-blind, so lowercase "api_key" near a private repo name blocked the body. expect 0 'lowercase credential-ish word near a private repo' \