From c80ed0fde4189fdee0c2b6c41ff806d3779414d7 Mon Sep 17 00:00:00 2001 From: yakimoto <66892052+yakimoto@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:19:33 -0400 Subject: [PATCH 1/9] 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 178fd8428bb207fd3c2a61df79b81b1807bda3f5 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:29:17 +0000 Subject: [PATCH 2/9] ci: probe for PCRE2 up front; a non-PCRE2 ripgrep failed with an opaque exit 2 Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 22 ++++++++++++++++++---- scripts/public-repo-guard/body-policy.sh | 10 ++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index bba2f67..aa7fa7c 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -85,8 +85,16 @@ jobs: - name: gitleaks (secret scan — published tree) run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 - - name: Install ripgrep - run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # Both policy scripts depend on `rg -P` (PCRE2), and not every ripgrep build + # ships it (Ubuntu 22.04's apt package did not). Verify the capability at + # install time so a runner-image regression fails HERE, with a message that + # names the problem, not later inside a rule with an opaque exit 2. + - name: Install ripgrep (must have PCRE2) + run: | + rg --pcre2-version >/dev/null 2>&1 \ + || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + rg --pcre2-version >/dev/null 2>&1 \ + || { echo "::error title=public-repo-guard::ripgrep on this runner lacks PCRE2; the guard's rules require rg -P."; exit 1; } - name: content policy (WAVE trade-secret / internal-leak gate) env: @@ -132,8 +140,14 @@ jobs: sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false - - name: Install ripgrep - run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # Same PCRE2 requirement as the tree job: body-policy.sh probes for it and + # fails closed, so surface a missing capability at install time instead. + - name: Install ripgrep (must have PCRE2) + run: | + rg --pcre2-version >/dev/null 2>&1 \ + || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + rg --pcre2-version >/dev/null 2>&1 \ + || { echo "::error title=public-repo-guard::ripgrep on this runner lacks PCRE2; the guard's rules require rg -P."; exit 1; } # The body is read straight out of the event payload FILE and written to # another file. It is never interpolated into a run: block and never placed diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index a0b421f..5471674 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -26,6 +26,16 @@ 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 runs through `rg -P` (PCRE2), because the lookarounds demand it, but +# not every ripgrep BUILD ships PCRE2 (Ubuntu 22.04's apt package does not). On +# such a build the first rule would die with an opaque "ripgrep failed (exit 2)". +# Probe once, up front, with an error that names the actual problem. The probe +# exercises a lookbehind so it fails on exactly the builds the rules fail on. +printf 'pcre2-probe' | rg -qP '(?/dev/null 2>&1 || { + echo "::error::body-policy: this ripgrep build lacks PCRE2 support (rg -P); every rule here requires it. Install a PCRE2-enabled ripgrep (rg --pcre2-version must succeed)." + exit 2 +} + VIOLATIONS=0 # Lines that TALK ABOUT the control rather than leaking through it. Without this, From f0413fac142d7d4549b8e7b73592c22d4646f9f5 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:32:57 +0000 Subject: [PATCH 3/9] ci: scope (?i) to repo names and run the body scanner from the trusted base ref Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 27 ++++++++++++++++++- scripts/public-repo-guard/body-policy.sh | 8 ++++-- .../tests/body-policy.test.sh | 7 +++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index aa7fa7c..4152dde 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -140,6 +140,20 @@ jobs: sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false + # On pull_request events the checkout above contains PR-CONTROLLED code, and + # a gate must never execute the code of the thing it is judging: a PR could + # edit body-policy.sh in the same push that leaks in its body and go green. + # So the scanner that RUNS is checked out separately, from the trusted base + # ref. Only the scanned TEXT comes from the untrusted event. For issue and + # comment events github.sha is already the default branch, so the two + # checkouts are identical there. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted-guard + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + # Same PCRE2 requirement as the tree job: body-policy.sh probes for it and # fails closed, so surface a missing capability at install time instead. - name: Install ripgrep (must have PCRE2) @@ -174,4 +188,15 @@ jobs: - name: body policy (PR / issue / comment text) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" + run: | + set -euo pipefail + SCANNER="trusted-guard/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$SCANNER" ]; then + # Bootstrap only: the base ref predates the guard's installation, so no + # trusted copy exists yet. A PR cannot steer execution here, because it + # cannot change what the base SHA contains; this branch only ever runs + # on the PR that first installs the bundle. + echo "::warning title=public-repo-guard (body-guard)::No body-policy.sh at the trusted ref (first install?); using this checkout's copy for this run only." + SCANNER="scripts/public-repo-guard/body-policy.sh" + fi + bash "$SCANNER" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 5471674..6d4509f 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -135,9 +135,13 @@ if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then _ALT="${_ALT:+$_ALT|}${_esc}" done if [[ -n "$_ALT" ]]; then - # Both orders: name-then-detail and detail-then-name. + # Both orders: name-then-detail and detail-then-name. Case-insensitivity is + # scoped to the repo NAMES with (?i:...): a top-level (?i) would leak into + # OPS_DETAIL, whose SCREAMING_CASE credential rule relies on case to tell an + # env-var NAME from prose: under (?i) an innocent "cache_key" near a repo + # name would block, and false positives are how a gate gets switched off. check BLOCK private-repo-ops \ - "(?i)\\b(?:${_ALT})\\b[^\\n]{0,140}?\\b${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?:${_ALT})\\b" \ + "\\b(?i:${_ALT})\\b[^\\n]{0,140}?\\b${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?i:${_ALT})\\b" \ 'A private WAVE repo named alongside internal operational detail (credential name, secret binding, or secret count) — the wiring topology is not public' 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..37e7f8d 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -46,6 +46,9 @@ 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.' +# Case-insensitivity is scoped to the repo NAME alternation, not the whole rule. +expect 1 'repo name still matches case-insensitively' \ + 'Wave-Gateway went from 74 secrets to 75 after this change.' expect 1 'operator home path' \ 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) expect 1 'internal-only marker' \ @@ -67,6 +70,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 top-level (?i) once leaked into OPS_DETAIL and made the +# SCREAMING_CASE credential rule match ordinary lowercase identifiers. +expect 0 'lowercase identifier near a private repo is prose, not a credential' \ + 'In wave-gateway we renamed the cache_key format for the edge.' expect 0 'public runner path is not an operator path' \ 'CI checks out to /home/runner/work/repo/repo before the scan runs.' # enforce-ignore (fixture) expect 0 'talking about the control' \ From 0641794b3fe75f0eb1542aa5d70ec141e8bd71c3 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:34:32 +0000 Subject: [PATCH 4/9] ci: re-run the tree scan when a PR is retargeted; edited is the only event a base change fires Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 4152dde..7a58a64 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -53,9 +53,14 @@ jobs: name: Secrets + content policy # Skips issue/comment events (the tree scan has nothing to say about a comment, # and the org should not pay for a gitleaks run every time anyone posts one) and - # skips `edited` (a title or body edit does not change the tree). + # skips `edited` (a title or body edit does not change the tree), with ONE + # exception: retargeting. A base-branch change alters the merge tree this job + # scans, and `edited` with `changes.base` in the payload is the ONLY event + # GitHub fires for it (there is no `synchronize`), so a retarget must re-run + # the scan or the check stays green against a tree it never saw. if: >- - (github.event_name == 'pull_request' && github.event.action != 'edited') + (github.event_name == 'pull_request' + && (github.event.action != 'edited' || github.event.changes.base.ref.from != '')) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' concurrency: From 734426cdb2f7b52abaf696758caab47b5b188e91 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:45:49 +0000 Subject: [PATCH 5/9] ci: fail allowlist filters closed, synthesize fixture names, drop checkout credentials Three review-driven fixes: - check()'s allowlist filters were '|| true'd: a filter dying with exit >= 2 emptied the match list and reported a DETECTED hit as clean. Each filter now captures its status and exits 2, same as the main scan. - The test fixtures hardcoded real private repo names and credential names; the file is gate-exempt by path, so it published exactly what the gate blocks. All fixture names are now obviously synthetic (acme-*). - All three checkout steps now set persist-credentials: false, matching every other workflow here: these jobs execute scripts from the PR's own tree, and that code must not run beside a token in .git/config. Also documents why a body-guard failure on an issue/comment lands on main's head commit and why the job must stay non-required. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 14 +++++++++++ scripts/public-repo-guard/body-policy.sh | 19 +++++++++++--- .../tests/body-policy.test.sh | 25 +++++++++++-------- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 7a58a64..5a846d6 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -68,7 +68,12 @@ jobs: cancel-in-progress: true runs-on: ubuntu-latest steps: + # persist-credentials: false on every checkout in this workflow: the jobs + # execute scripts taken from the PR's own tree, and attacker-controlled + # code must never run in a workspace with a token sitting in .git/config. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release @@ -123,6 +128,13 @@ jobs: # 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. + # + # Accepted side effect of that detection: on issue/comment events github.sha is + # the default-branch head, so a leak in an issue or comment surfaces as a red + # check on main. That is intentional (a leak SHOULD be loud somewhere a human + # looks) and safe for merges because the branch-protection-required check is + # `Secrets + content policy`, not this job. Keep it that way: making this name + # required would let any commenter turn main's checks red at will. body-guard: name: Body content policy if: github.event_name == 'pull_request' || github.event_name == 'issues' || github.event_name == 'issue_comment' @@ -144,6 +156,7 @@ jobs: # tree on every comment. sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false + persist-credentials: false # On pull_request events the checkout above contains PR-CONTROLLED code, and # a gate must never execute the code of the thing it is judging: a PR could @@ -158,6 +171,7 @@ jobs: path: trusted-guard sparse-checkout: scripts/public-repo-guard sparse-checkout-cone-mode: false + persist-credentials: false # Same PCRE2 requirement as the tree job: body-policy.sh probes for it and # fails closed, so surface a missing capability at install time instead. diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 6d4509f..ea5e58b 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -59,10 +59,21 @@ check() { # Filter with rg, not grep: BSD/macOS grep has no -P, so a `grep -P` allowlist # silently errors out locally while working on GNU/CI — the gate would then # disagree with itself depending on where it ran. rg is already required above. - local matches - matches="$(printf '%s' "$raw" \ - | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \ - | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + # + # The filters fail closed too, same as the scan above. An `|| true` here would + # turn a broken filter (exit >= 2) into an empty match list, converting a + # DETECTED hit into a pass; exit 1 just means the filter removed every line. + local matches frc + matches="$(printf '%s' "$raw" | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]')"; frc=$? + if (( frc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the guard:allow filter for rule '$name'; failing closed." + exit 2 + fi + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? + if (( frc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control allowlist for rule '$name'; failing closed." + exit 2 + fi [[ -z "$matches" ]] && return 0 local count; count="$(printf '%s\n' "$matches" | grep -c '')" # Print the LINE NUMBER only — never the matched text. This annotation is itself diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 37e7f8d..396715f 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -14,8 +14,11 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT # The names the real gate is configured with come from an org variable; the tests -# pin their own so they are hermetic and do not depend on CI configuration. -export GUARD_PRIVATE_REPOS="wave-gateway, wave-transports, agent-money" +# pin their own so they are hermetic and do not depend on CI configuration. The +# names (and every credential name and count below) are deliberately SYNTHETIC: +# this file is public and exempt from the gates by path, so real private-repo +# names or real credential names here would BE the leak the gate exists to block. +export GUARD_PRIVATE_REPOS="acme-alpha, acme-beta, acme-gamma" PASS=0; FAIL=0 @@ -39,16 +42,16 @@ 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: ALPHA_VIEWPORT_LEASE_SECRET is bound on acme-alpha now.' expect 1 'private repo + credential name, reverse order' \ - 'The MOQ_JOIN_SECRET was added; wave-transports picks it up on deploy.' + 'The BETA_JOIN_SECRET was added; acme-beta picks it up on deploy.' expect 1 'private repo + secret count' \ - 'wave-gateway went from 74 secrets to 75 after this change.' + 'acme-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 acme-gamma for settlement.' # Case-insensitivity is scoped to the repo NAME alternation, not the whole rule. expect 1 'repo name still matches case-insensitively' \ - 'Wave-Gateway went from 74 secrets to 75 after this change.' + 'Acme-Alpha went from 74 secrets to 75 after this change.' expect 1 'operator home path' \ 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) expect 1 'internal-only marker' \ @@ -65,21 +68,21 @@ expect 1 'internal tailscale IP' \ # --- must PASS (precision — these keep the gate deployable) ------------------- expect 0 'bare private-repo cross-reference' \ - 'This is the companion change to wave-transports#260; merge that one first.' + 'This is the companion change to acme-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 acme-alpha and acme-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 top-level (?i) once leaked into OPS_DETAIL and made the # SCREAMING_CASE credential rule match ordinary lowercase identifiers. expect 0 'lowercase identifier near a private repo is prose, not a credential' \ - 'In wave-gateway we renamed the cache_key format for the edge.' + 'In acme-alpha we renamed the cache_key format for the edge.' expect 0 'public runner path is not an operator path' \ 'CI checks out to /home/runner/work/repo/repo before the scan runs.' # enforce-ignore (fixture) expect 0 'talking about the control' \ 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' expect 0 'explicit guard:allow with a reason' \ - 'Example for the docs: wave-gateway holds EXAMPLE_SECRET — guard:allow documented-example' + 'Example for the docs: acme-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 7f1b67ae113d879b12b63e475f5a7ee1f4486fa9 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 17:49:36 +0000 Subject: [PATCH 6/9] ci: the install manifest names four files but the guard job executes five The self-test step runs tests/body-policy.test.sh, so an install that copies only the four listed files fails on a step nobody read. List the fixture as part of the install unit. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 5a846d6..7913c4e 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,11 +13,14 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all four files together: +# To install on a new repo, copy all five files together (the guard job RUNS the +# body-policy fixtures as a step, so the test file is part of the install unit, +# not an optional extra): # .github/workflows/public-repo-guard.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh # scripts/public-repo-guard/body-policy.sh +# scripts/public-repo-guard/tests/body-policy.test.sh # # Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The # goal is "what is public right now is clean", so a shallow checkout is sufficient. From ad7bdeaeb4312adced00153f6d1a926142d7c030 Mon Sep 17 00:00:00 2001 From: yakimoto Date: Thu, 6 Aug 2026 18:00:41 +0000 Subject: [PATCH 7/9] fix(ci): never execute PR-controlled scanner; scope about-the-control allowlist to prose rules Two review findings on the body gate: - The bootstrap fallback ran the PR's own body-policy.sh whenever the base ref lacked a copy, which is not only the install PR: any PR opened against (or retargeted onto) a branch predating the guard would have its own code judge its own body. The fallback is now the default branch's copy (which a PR cannot control), and if no trusted copy exists anywhere the job fails closed instead of executing untrusted code. - The about-the-control allowlist was applied to every rule, so a line mentioning the gate by name was exempt even when it also carried a live credential. It now applies only to prose rules (internal-marker, private-repo-ops); credential- and infrastructure-format rules stay strict. Fixtures pin both halves. Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 35 +++++++++++++++---- scripts/public-repo-guard/body-policy.sh | 26 +++++++++----- .../tests/body-policy.test.sh | 9 +++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 7913c4e..b632b24 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -176,6 +176,21 @@ jobs: sparse-checkout-cone-mode: false persist-credentials: false + # Fallback scanner source for PRs whose BASE predates the guard (a long-lived + # branch, or a PR retargeted onto one): the DEFAULT branch's copy, which a PR + # can never control. Without this, the only fallback would be the PR's own + # checkout — and a gate must never execute the code of the thing it is + # judging. Skipped for issue/comment events, where the trusted checkout + # above is already the default-branch head. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.event_name == 'pull_request' + with: + ref: ${{ github.event.repository.default_branch }} + path: default-guard + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + persist-credentials: false + # Same PCRE2 requirement as the tree job: body-policy.sh probes for it and # fails closed, so surface a missing capability at install time instead. - name: Install ripgrep (must have PCRE2) @@ -212,13 +227,21 @@ jobs: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: | set -euo pipefail + # Scanner resolution, most-trusted first — and NEVER the PR's own + # checkout. "Base has no copy" is not only the install PR: it is also + # any PR opened against (or retargeted onto) a branch that predates the + # guard, and there a PR-supplied body-policy.sh would judge its own body. SCANNER="trusted-guard/scripts/public-repo-guard/body-policy.sh" if [ ! -f "$SCANNER" ]; then - # Bootstrap only: the base ref predates the guard's installation, so no - # trusted copy exists yet. A PR cannot steer execution here, because it - # cannot change what the base SHA contains; this branch only ever runs - # on the PR that first installs the bundle. - echo "::warning title=public-repo-guard (body-guard)::No body-policy.sh at the trusted ref (first install?); using this checkout's copy for this run only." - SCANNER="scripts/public-repo-guard/body-policy.sh" + echo "::warning title=public-repo-guard (body-guard)::No body-policy.sh at the trusted base ref (base predates the guard); falling back to the default branch's copy." + SCANNER="default-guard/scripts/public-repo-guard/body-policy.sh" + fi + if [ ! -f "$SCANNER" ]; then + # True bootstrap: no trusted copy exists anywhere yet. Fail rather + # than execute untrusted code — a red, non-required check on the + # install PR is a one-time cost; a gate that can be made to run the + # code it is judging is a standing bypass. + echo "::error title=public-repo-guard (body-guard)::No trusted copy of body-policy.sh on the base ref or the default branch; refusing to execute the PR's own copy. Merge the guard bundle to the default branch to activate this check." + exit 1 fi bash "$SCANNER" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index ea5e58b..d6db503 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -42,11 +42,18 @@ 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 a discussion of the gate can trip). The +# credential- and infrastructure-FORMAT rules never apply it: an AWS key or +# private-key block is a leak even on a line that also says "public-repo-guard", +# and gate discussions are exactly where such an accidental paste is most likely. 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 [prose] +# Pass `prose` as the 5th arg to apply the ABOUT_THE_CONTROL allowlist. Format +# rules (credentials, infrastructure identifiers) omit it and stay strict. check() { - local sev="$1" name="$2" re="$3" why="$4" + local sev="$1" name="$2" re="$3" why="$4" scope="${5:-strict}" [[ -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. @@ -69,10 +76,12 @@ check() { echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the guard:allow filter for rule '$name'; failing closed." exit 2 fi - matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? - if (( frc >= 2 )); then - echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control allowlist for rule '$name'; failing closed." - exit 2 + if [[ "$scope" == "prose" ]]; then + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? + if (( frc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control allowlist for rule '$name'; failing closed." + exit 2 + fi fi [[ -z "$matches" ]] && return 0 local count; count="$(printf '%s\n' "$matches" | grep -c '')" @@ -118,7 +127,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 18:12:37 +0000 Subject: [PATCH 8/9] fix(guard): fail closed on empty GUARD_PRIVATE_REPOS in CI; keep topology rule strict on gate-naming lines Co-authored-by: Codesmith --- .github/workflows/public-repo-guard.yml | 4 ++ scripts/public-repo-guard/body-policy.sh | 44 ++++++++++++++----- .../tests/body-policy.test.sh | 30 ++++++++++++- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index b632b24..9625658 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -224,6 +224,10 @@ jobs: - name: body policy (PR / issue / comment text) env: + # body-policy.sh FAILS CLOSED (exit 2) if this is empty in CI: an + # unset, misspelled, or unexposed variable must surface as a red + # check, never as a silent skip of the private-repo rule. A repo with + # deliberately nothing to guard sets the variable to the literal 'none'. GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: | set -euo pipefail diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index d6db503..6473552 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). +# The internal-marker rule additionally exempts lines matching the +# ABOUT-THE-CONTROL allowlist below; every other rule ignores it. set -uo pipefail FILE="${1:-}" @@ -43,10 +44,15 @@ VIOLATIONS=0 # self-referential trap that gets a gate switched off. Ported verbatim in intent # from the client-side gate's allowlist, which was built for exactly this. # -# Scope: PROSE rules only (the ones a discussion of the gate can trip). The -# credential- and infrastructure-FORMAT rules never apply it: an AWS key or -# private-key block is a leak even on a line that also says "public-repo-guard", -# and gate discussions are exactly where such an accidental paste is most likely. +# Scope: the internal-marker rule ONLY (the one a discussion of the gate can +# trip with pure prose). The credential- and infrastructure-FORMAT rules never +# apply it: an AWS key or private-key block is a leak even on a line that also +# says "public-repo-guard", and gate discussions are exactly where such an +# accidental paste is most likely. The private-repo-ops TOPOLOGY rule does not +# apply it either: "public-repo-guard now blocks FOO_SECRET bound on " is precisely the leak that rule exists for, and naming the gate must +# not sanitize it. A deliberate, safe example on such a line uses the visible +# `guard:allow ` marker instead. 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 [prose] @@ -143,8 +149,22 @@ check BLOCK internal-marker '(?`. check BLOCK private-repo-ops \ "\\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' \ - prose + '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 5457436..1c0cd4a 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -70,6 +70,12 @@ expect 1 'credential format blocks even on a line naming the control' \ "public-repo-guard flagged ${AKID_FIXTURE} in the job logs." expect 1 'internal tailscale IP' \ 'It resolves to 100.71.4.19 from inside the fleet.' +# Regression: naming the gate does not sanitize topology. The about-the-control +# allowlist is scoped to the internal-marker rule only — a line that says +# "public-repo-guard" AND wires a private repo to a credential name is exactly +# the leak private-repo-ops exists for. +expect 1 'private repo + credential name blocks even on a line naming the control' \ + 'public-repo-guard fires when acme-alpha sits near a BAR_SECRET name; expected.' # --- must PASS (precision — these keep the gate deployable) ------------------- expect 0 'bare private-repo cross-reference' \ @@ -86,8 +92,6 @@ 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 'prose rule stays exempt on a line naming the control' \ - 'public-repo-guard fires when acme-alpha sits near a BAR_SECRET name; expected.' expect 0 'internal-marker stays exempt on a line naming the control' \ 'public-repo-guard exists so an internal-only doc never lands in a public body.' expect 0 'explicit guard:allow with a reason' \ @@ -105,6 +109,28 @@ expect 0 'marker MENTIONED in smart quotes' \ expect 1 'marker USED unquoted still blocks' \ 'Attaching the internal-only rollout plan; do not share outside the team.' +# --- GUARD_PRIVATE_REPOS availability ----------------------------------------- +# In CI, an empty GUARD_PRIVATE_REPOS is a configuration error (exit 2): the +# private-repo rule is the one this gate exists for, and an unset, misspelled, +# or unexposed org variable must be a red check, never a silent skip that still +# reports "body policy OK". Locally (no GITHUB_ACTIONS) the skip stays quiet, +# and the literal 'none' is the explicit CI opt-out for a repo with nothing to +# guard. Invoked via env(1), not expect(): these must NOT inherit the export above. +printf '%s\n' 'Ordinary clean body text.' > "$TMP/envcase.txt" +envcase() { + local want="$1" name="$2"; shift 2 + env -u GUARD_PRIVATE_REPOS -u GITHUB_ACTIONS "$@" bash "$SCRIPT" "$TMP/envcase.txt" >/dev/null 2>&1 + local rc=$? + if [[ "$rc" == "$want" ]]; then + PASS=$((PASS+1)); printf ' ok %s\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit %s, got %s\n' "$name" "$want" "$rc" + fi +} +envcase 2 'empty GUARD_PRIVATE_REPOS in CI fails closed' GITHUB_ACTIONS=true +envcase 0 'empty GUARD_PRIVATE_REPOS locally skips quietly' +envcase 0 "explicit 'none' opt-out passes in CI" GITHUB_ACTIONS=true GUARD_PRIVATE_REPOS=none + # --- fail closed -------------------------------------------------------------- # Invoked directly, not through expect(): expect() always materializes a file, so # it cannot reach these paths. A gate that returns "OK" when it was handed nothing From 8b1bae8afc81cac0cfc2e0b407e222a84ed4626f Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Tue, 8 Sep 2026 14:24:46 -0400 Subject: [PATCH 9/9] ci: split the body scan into its own workflow; drop the bootstrap deadlock The body scan could never go green on the PR that introduces it. It refused to execute the PR's own copy of body-policy.sh and looked for a trusted copy on the base ref, then on the default branch; neither has one until this lands, so the job hard-failed with "No trusted copy of body-policy.sh ... Merge the guard bundle to the default branch to activate this check." That is a bootstrap deadlock, not a policy hit, and it is not fixable by weakening the gate. The trusted-copy dance is unnecessary here. This job triggers on `pull_request`, never `pull_request_target`, so a fork PR gets no write token and no repo secrets. With nothing to steal, running the repo's own copy directly means the worst a PR can do by editing the scanner is fail its own check. The three checkouts collapse to one, and the deadlock disappears. Second defect, visible on this PR right now: the required check "Secrets + content policy" reported `skipping`. Both jobs shared one `on:` block, so the tree job needed a job-level `if:` to sit out comment and body-edit events -- and GitHub still publishes a check-run under the required name with conclusion `skipped`, which branch protection reads as passing and which supersedes an earlier real failure. The mirror-image failure is a comment burst under `cancel-in-progress: true` piling `cancelled` runs onto the same required name, leaving the rollup FAILURE while the newest run is green. Both are the same structural problem: a required check-run name reachable from a trigger set that only the body scan needs. So this is a file-level split, not a job-level one. public-repo-guard.yml now lists only tree-changing events (pull_request opened/reopened/synchronize, push, workflow_dispatch, and the existing merge_group trigger the queue depends on), needs no `if:` at all, and can never publish a skipped or cancelled run for a comment. Coverage is unchanged. public-repo-guard-body.yml carries the non-required "Body content policy" check and gains two triggers the previous draft never had: pull_request_review_comment and pull_request_review, whose bodies are world-readable text nothing scanned. Also here, ported from the generation that already landed in a sibling repo: - ripgrep is installed from a pinned, checksum-verified upstream release with PCRE2 compiled in. `apt-get install ripgrep` has no PCRE2, so every `rg -P` rule exited 2 and the scanner fail-closed red on every run. - Both allowlists (`guard:allow ` and the about-the-control list) are now scoped to the one prose rule that can self-trip, internal-marker. A body has no reviewable diff, so an author could previously neutralise any rule -- including the live-credential rules -- by appending the marker to the same line that leaked. Two regression fixtures cover it. - GUARD_PRIVATE_REPOS is normalised for newlines and CRs before splitting; a multi-line value used to configure only the first name and report a pass over the unscanned rest. - The internal-ip rule exempts the range's own all-zero designation (with or without a CIDR suffix) so a body quoting the gate's own documentation is not blocked with no remedy. A host one address past it still blocks. Kept from this branch, deliberately: the up-front PCRE2 probe; the CI fail-closed when GUARD_PRIVATE_REPOS is empty, with `none` as the explicit opt-out; synthetic fixture names throughout the test file, because it is public; and a STRICT private-repo-ops rule that takes no allowlist at all. Naming the gate must not sanitise wiring topology, and a documented safe example belongs in a file, where the marker lands in a reviewable diff. Verified locally: 36/36 fixtures pass; actionlint and shellcheck clean on all four files; content-policy.sh reports OK against a clean export of the tree; and this PR's own title and body scan clean under the real configuration. --- .github/workflows/public-repo-guard-body.yml | 136 ++++++++++ .github/workflows/public-repo-guard.yml | 248 ++++++------------ scripts/public-repo-guard/body-policy.sh | 127 +++++---- .../tests/body-policy.test.sh | 47 +++- 4 files changed, 332 insertions(+), 226 deletions(-) create mode 100644 .github/workflows/public-repo-guard-body.yml diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml new file mode 100644 index 0000000..5bcd959 --- /dev/null +++ b/.github/workflows/public-repo-guard-body.yml @@ -0,0 +1,136 @@ +name: public-repo-guard-body + +# The other half of public-repo-guard.yml's coverage, deliberately in its OWN +# workflow file — see the long comment block at the top of public-repo-guard.yml +# for the incident (wave-av/cli PR 68) that caused the split and why it is a +# file-level split, not just a job-level one. +# +# `guard` (in public-repo-guard.yml) scans the published TREE and produces the +# REQUIRED check "Secrets + content policy". This job scans a PR/issue/comment +# BODY, which is just as world-readable and, until this job existed, was scanned +# by nothing server-side. That gap was real, not theoretical: a PR was blocked +# for naming a private repo in a config file while the very same name, with more +# operational detail attached, sat unchallenged in its body. +# +# This job's check-run name ("Body content policy") is NOT a required status +# context in this repo's ruleset, so it can safely trigger on every comment/review +# event without any risk of masking or wedging the required tree-scan context — +# that is the entire reason it lives in a separate file from the tree scan. +# +# 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. +# +# Accepted side effect of that detection: on issue/comment events github.sha is +# the default-branch head, so a leak in an issue or comment surfaces as a red +# check on main. That is intentional (a leak SHOULD be loud somewhere a human +# looks) and safe for merges because the branch-protection-required check is +# `Secrets + content policy`, not this one. Keep it that way: making this name +# required would let any commenter turn main's checks red at will. +on: + # `edited` matters as much as `opened`: a body can be made to leak long after + # the PR is first raised, and until this job covered it, nothing re-scanned it. + pull_request: + types: [opened, edited, reopened, synchronize] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + # Inline review comments on a diff are a SEPARATE event from issue_comment — + # without this trigger they are world-readable text that no job ever scans. + pull_request_review_comment: + types: [created, edited] + # A submitted review's top-level body (the free-text field above any inline + # comments) is yet another world-readable payload, separate from BOTH comment + # events — without this trigger nothing ever scans it. + pull_request_review: + types: [submitted, 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. That +# is also why this job can run the repo's own copy of the scanner directly — with +# no token and no secrets in the job, PR-supplied code has nothing to steal, and +# the only thing a PR can do by editing the scanner is fail its own check. +permissions: + contents: read + +jobs: + body-guard: + name: Body content policy + concurrency: + # Keyed on the specific comment / review / PR / issue rather than github.ref, + # because issue events all report the default branch and a ref-keyed group + # would let two comments cancel each other, leaving one unscanned. The comment + # and review ids come FIRST: those payloads also carry the PR number, and + # keying them on the PR would collapse two rapid comments into one group, + # dropping a verdict. + # + # 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. Since this check-run name is not required, a lingering cancelled + # run here cannot wedge a merge the way the tree scan's could — but a dropped + # verdict on a body would still be a real coverage gap, so the same "let it + # finish" policy applies. + group: public-repo-guard-body-${{ github.event.comment.id || github.event.review.id || github.event.pull_request.number || github.event.issue.number || github.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Only the gate's own scripts are needed — no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + # This job only reads the scripts — never leave the token sitting in + # .git/config while repo-supplied scripts execute in the workspace. + persist-credentials: false + + # Same rationale as the tree job: body-policy.sh needs a PCRE2-enabled rg, + # and Ubuntu's apt package has none. + - name: Install ripgrep (pinned + checksum-verified, PCRE2 build) + env: + RIPGREP_VERSION: "14.1.1" + RIPGREP_SHA256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" + run: | + if command -v rg >/dev/null && rg --pcre2-version >/dev/null 2>&1; then + echo "using preinstalled $(rg --version | head -n1) with PCRE2"; exit 0 + fi + curl -fsSL --proto '=https' --tlsv1.2 -o ripgrep.tar.gz \ + "https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl.tar.gz" + echo "${RIPGREP_SHA256} ripgrep.tar.gz" | sha256sum -c - + tar -xzf ripgrep.tar.gz --strip-components=1 "ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl/rg" + sudo install -m 0755 rg /usr/local/bin/rg + rm -f rg ripgrep.tar.gz + rg --pcre2-version + + # 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::Event payload contains no pull_request/issue/comment/review object — refusing to report a pass on an unscanned body." + exit 1 + fi + jq -r '[.pull_request.title, .pull_request.body, + .issue.title, .issue.body, + .comment.body, .review.body] + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment text) + env: + # body-policy.sh FAILS CLOSED (exit 2) if this is empty in CI: an unset, + # misspelled, or unexposed variable must surface as a red check, never as + # a silent skip of the private-repo rule. A repo with deliberately nothing + # to guard sets the variable to the literal 'none'. + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index c2dd5a2..0a1fc94 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -1,6 +1,7 @@ name: public-repo-guard -# Pre-publication content gate for WAVE public repos. Two complementary checks: +# Pre-publication content gate for WAVE public repos. Two complementary checks, +# split across TWO workflow files (this one, plus public-repo-guard-body.yml): # 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 @@ -13,10 +14,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 five files together (the guard job RUNS the +# To install on a new repo, copy all six files together (the guard job RUNS the # body-policy fixtures as a step, so the test file is part of the install unit, # not an optional extra): # .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,16 +29,50 @@ name: public-repo-guard # # Allowlisting: annotate a verified-safe line with `# guard:allow `, add a # path glob to a repo-root `.guardignore`, or extend the repo-local `.gitleaks.toml`. - +# +# WHY THIS IS A SEPARATE WORKFLOW FROM public-repo-guard-body.yml (an earlier draft +# of this change put both jobs in this one file — see the incident below): +# +# The `guard` job below produces the check-run named "Secrets + content policy", +# which is the REQUIRED status context in this repo's branch-protection ruleset. +# Two failure modes are possible for any job that produces a required check-run +# from a shared, comment/review-triggered `on:` block, and BOTH were hit in +# production before this split: +# +# (a) MASKING: if this job's `on:` trigger set includes the comment/review events +# the BODY scan needs (the tree scan does not) and the job then uses a +# job-level `if:` to skip those events (because the tree hasn't changed), +# GitHub still publishes a check-run named "Secrets + content policy" with +# conclusion `skipped` on that event. Branch protection treats `skipped` as +# passing, and the newest check-run for a name wins — so a bare comment could +# flip an already-FAILED required tree scan green with nothing re-examining +# the tree. +# +# (b) CHURN / FALSE BLOCK: making the job run for real on every comment event, +# combined with `cancel-in-progress: true` (needed so a genuine new commit +# supersedes a stale scan promptly), means a burst of review-bot comments — +# which do not change the tree at all — repeatedly re-fires and cancels the +# SAME job. Every cancelled run leaves a `cancelled` check-run attached to +# the commit under the required name. Observed live in wave-av/cli PR 68: +# four review-bot comments within 68s produced seven check-runs named +# "Secrets + content policy" (five cancelled, two success); the checks tab +# showed the latest as green, but the commit's status-check rollup reported +# FAILURE and the PR was permanently MERGEABLE/BLOCKED even though the gate +# had genuinely passed. +# +# (a) and (b) are the SAME structural problem: this job's required check-run name +# was reachable from an `on:` trigger set that also had to serve comment/review +# events for the (unrelated) body scan. Splitting into two workflow FILES — not +# just two jobs — removes the shared trigger set entirely: this file's `on:` block +# now lists ONLY events that can change the tree. A review comment or a title/body +# edit never matches this workflow's trigger at all, so GitHub never runs it and +# never publishes ANY check-run — skipped, cancelled, or otherwise — under the +# required name for that event. There is nothing left to mask and nothing left to +# cancel. Coverage is unchanged: every event that can actually alter the published +# tree still gets a real, non-skippable gitleaks + content-policy run. 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] + types: [opened, reopened, synchronize] push: branches: [main, master] workflow_dispatch: @@ -46,40 +82,33 @@ on: merge_group: # `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 the tree. permissions: contents: read -# Concurrency is per JOB, not per workflow: the two jobs want opposite behaviour. -# A workflow-level group would force one policy on both, and it showed: rapid body -# edits cancelled the tree job over and over, and every cancelled check-run stays -# attached to the commit, so the PR reported UNSTABLE while the live runs were green. - jobs: guard: name: Secrets + content policy - # Skips issue/comment events (the tree scan has nothing to say about a comment, - # and the org should not pay for a gitleaks run every time anyone posts one) and - # skips `edited` (a title or body edit does not change the tree), with ONE - # exception: retargeting. A base-branch change alters the merge tree this job - # scans, and `edited` with `changes.base` in the payload is the ONLY event - # GitHub fires for it (there is no `synchronize`), so a retarget must re-run - # the scan or the check stays green against a tree it never saw. - if: >- - (github.event_name == 'pull_request' - && (github.event.action != 'edited' || github.event.changes.base.ref.from != '')) - || github.event_name == 'push' - || github.event_name == 'workflow_dispatch' + # No job-level `if:` — deliberately. The `on:` block above already scopes this + # job to exactly the tree-changing events, so every triggering event is a real + # run: never skipped, never a candidate for the masking bug described above, + # and never skipped out from under the merge queue. concurrency: + # cancel-in-progress is TRUE here and it is now safe: the only rapid + # retrigger this workflow can see is `synchronize` (a new commit landing + # while a prior scan of the OLD tree is still running), and superseding a + # stale in-flight scan with a fresh one for the new tree is exactly the + # right behaviour. Comment/review bursts cannot reach this workflow at all + # (see the block comment above), so this can no longer produce the + # cancelled-run pileup that wedged wave-av/cli PR 68. group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true runs-on: ubuntu-latest steps: - # persist-credentials: false on every checkout in this workflow: the jobs - # execute scripts taken from the PR's own tree, and attacker-controlled - # code must never run in a workspace with a token sitting in .git/config. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # This job only reads the tree — never leave the token sitting in + # .git/config while repo-supplied scripts execute in the workspace. persist-credentials: false # gitleaks' GitHub Action requires a paid license for organizations; the CLI @@ -102,16 +131,26 @@ jobs: - name: gitleaks (secret scan — published tree) run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 - # Both policy scripts depend on `rg -P` (PCRE2), and not every ripgrep build - # ships it (Ubuntu 22.04's apt package did not). Verify the capability at - # install time so a runner-image regression fails HERE, with a message that - # names the problem, not later inside a rule with an opaque exit 2. - - name: Install ripgrep (must have PCRE2) + # Both policy scripts use `rg -P` (PCRE2). Ubuntu's apt ripgrep is built + # WITHOUT PCRE2, so `rg -P` exits 2 there and the scripts fail closed — + # red on every run, which gets a gate switched off. Accept a preinstalled + # rg only if it actually has PCRE2; otherwise install the official release + # binary (PCRE2 compiled in), pinned and checksum-verified like gitleaks. + - name: Install ripgrep (pinned + checksum-verified, PCRE2 build) + env: + RIPGREP_VERSION: "14.1.1" + RIPGREP_SHA256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" run: | - rg --pcre2-version >/dev/null 2>&1 \ - || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) - rg --pcre2-version >/dev/null 2>&1 \ - || { echo "::error title=public-repo-guard::ripgrep on this runner lacks PCRE2; the guard's rules require rg -P."; exit 1; } + if command -v rg >/dev/null && rg --pcre2-version >/dev/null 2>&1; then + echo "using preinstalled $(rg --version | head -n1) with PCRE2"; exit 0 + fi + curl -fsSL --proto '=https' --tlsv1.2 -o ripgrep.tar.gz \ + "https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl.tar.gz" + echo "${RIPGREP_SHA256} ripgrep.tar.gz" | sha256sum -c - + tar -xzf ripgrep.tar.gz --strip-components=1 "ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl/rg" + sudo install -m 0755 rg /usr/local/bin/rg + rm -f rg ripgrep.tar.gz + rg --pcre2-version - name: content policy (WAVE trade-secret / internal-leak gate) env: @@ -124,132 +163,3 @@ jobs: # rather than by a leak. - name: body policy self-test (fixtures) run: bash scripts/public-repo-guard/tests/body-policy.test.sh - - # The other half of a public repo's surface. `guard` above scans the published - # TREE; a PR/issue/comment 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. - # - # Accepted side effect of that detection: on issue/comment events github.sha is - # the default-branch head, so a leak in an issue or comment surfaces as a red - # check on main. That is intentional (a leak SHOULD be loud somewhere a human - # looks) and safe for merges because the branch-protection-required check is - # `Secrets + content policy`, not this job. Keep it that way: making this name - # required would let any commenter turn main's checks red at will. - 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 - persist-credentials: false - - # On pull_request events the checkout above contains PR-CONTROLLED code, and - # a gate must never execute the code of the thing it is judging: a PR could - # edit body-policy.sh in the same push that leaks in its body and go green. - # So the scanner that RUNS is checked out separately, from the trusted base - # ref. Only the scanned TEXT comes from the untrusted event. For issue and - # comment events github.sha is already the default branch, so the two - # checkouts are identical there. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha || github.sha }} - path: trusted-guard - sparse-checkout: scripts/public-repo-guard - sparse-checkout-cone-mode: false - persist-credentials: false - - # Fallback scanner source for PRs whose BASE predates the guard (a long-lived - # branch, or a PR retargeted onto one): the DEFAULT branch's copy, which a PR - # can never control. Without this, the only fallback would be the PR's own - # checkout — and a gate must never execute the code of the thing it is - # judging. Skipped for issue/comment events, where the trusted checkout - # above is already the default-branch head. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: github.event_name == 'pull_request' - with: - ref: ${{ github.event.repository.default_branch }} - path: default-guard - sparse-checkout: scripts/public-repo-guard - sparse-checkout-cone-mode: false - persist-credentials: false - - # Same PCRE2 requirement as the tree job: body-policy.sh probes for it and - # fails closed, so surface a missing capability at install time instead. - - name: Install ripgrep (must have PCRE2) - run: | - rg --pcre2-version >/dev/null 2>&1 \ - || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) - rg --pcre2-version >/dev/null 2>&1 \ - || { echo "::error title=public-repo-guard::ripgrep on this runner lacks PCRE2; the guard's rules require rg -P."; exit 1; } - - # The body is read straight out of the event payload FILE and written to - # another file. It is never interpolated into a run: block and never placed - # in an environment variable, so shell metacharacters in a hostile PR body - # have nothing to act on. jq is preinstalled on the GitHub-hosted images. - - name: Materialize the untrusted title/body to a file - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/bodyscan" - # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and - # report a pass. If the event schema ever moves, this job must go red - # rather than become a green rubber stamp over an unscanned body. - if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment")' "$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: - # body-policy.sh FAILS CLOSED (exit 2) if this is empty in CI: an - # unset, misspelled, or unexposed variable must surface as a red - # check, never as a silent skip of the private-repo rule. A repo with - # deliberately nothing to guard sets the variable to the literal 'none'. - GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - run: | - set -euo pipefail - # Scanner resolution, most-trusted first — and NEVER the PR's own - # checkout. "Base has no copy" is not only the install PR: it is also - # any PR opened against (or retargeted onto) a branch that predates the - # guard, and there a PR-supplied body-policy.sh would judge its own body. - SCANNER="trusted-guard/scripts/public-repo-guard/body-policy.sh" - if [ ! -f "$SCANNER" ]; then - echo "::warning title=public-repo-guard (body-guard)::No body-policy.sh at the trusted base ref (base predates the guard); falling back to the default branch's copy." - SCANNER="default-guard/scripts/public-repo-guard/body-policy.sh" - fi - if [ ! -f "$SCANNER" ]; then - # True bootstrap: no trusted copy exists anywhere yet. Fail rather - # than execute untrusted code — a red, non-required check on the - # install PR is a one-time cost; a gate that can be made to run the - # code it is judging is a standing bypass. - echo "::error title=public-repo-guard (body-guard)::No trusted copy of body-policy.sh on the base ref or the default branch; refusing to execute the PR's own copy. Merge the guard bundle to the default branch to activate this check." - exit 1 - fi - bash "$SCANNER" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh index 6473552..b65dd64 100755 --- a/scripts/public-repo-guard/body-policy.sh +++ b/scripts/public-repo-guard/body-policy.sh @@ -17,21 +17,27 @@ # # 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). -# The internal-marker rule additionally exempts lines matching the -# ABOUT-THE-CONTROL allowlist below; every other rule ignores it. +# Allowlisting: unlike the tree scanner, where a `guard:allow` marker lands in a +# reviewable diff, a body is free-form text the untrusted author controls and can +# edit at any time — an allowlist marker there is one edit away, not one review +# away. So BOTH allowlists (the `guard:allow ` marker and the +# ABOUT-THE-CONTROL list below) apply ONLY to the self-referential PROSE rule +# (internal-marker) — never to the credential-format, infrastructure, or +# private-repo-topology rules, which have NO author-controlled escape in a body. The +# residual prose-rule escape is an accepted trade: the threat model is the +# accidental paste, and a gate that blocks its own security discussions gets +# switched off. 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 runs through `rg -P` (PCRE2), because the lookarounds demand it, but -# not every ripgrep BUILD ships PCRE2 (Ubuntu 22.04's apt package does not). On -# such a build the first rule would die with an opaque "ripgrep failed (exit 2)". -# Probe once, up front, with an error that names the actual problem. The probe -# exercises a lookbehind so it fails on exactly the builds the rules fail on. +# Every rule below runs through `rg -P` (PCRE2), because the lookarounds demand it, +# but not every ripgrep BUILD ships PCRE2 (Ubuntu's apt package does not). On such +# a build the first rule would die with an opaque "ripgrep failed (exit 2)". Probe +# once, up front, with an error that names the actual problem. The probe exercises +# a lookbehind so it fails on exactly the builds the rules fail on. printf 'pcre2-probe' | rg -qP '(?/dev/null 2>&1 || { echo "::error::body-policy: this ripgrep build lacks PCRE2 support (rg -P); every rule here requires it. Install a PCRE2-enabled ripgrep (rg --pcre2-version must succeed)." exit 2 @@ -44,22 +50,22 @@ VIOLATIONS=0 # self-referential trap that gets a gate switched off. Ported verbatim in intent # from the client-side gate's allowlist, which was built for exactly this. # -# Scope: the internal-marker rule ONLY (the one a discussion of the gate can -# trip with pure prose). The credential- and infrastructure-FORMAT rules never -# apply it: an AWS key or private-key block is a leak even on a line that also -# says "public-repo-guard", and gate discussions are exactly where such an -# accidental paste is most likely. The private-repo-ops TOPOLOGY rule does not -# apply it either: "public-repo-guard now blocks FOO_SECRET bound on " is precisely the leak that rule exists for, and naming the gate must -# not sanitize it. A deliberate, safe example on such a line uses the visible -# `guard:allow ` marker instead. +# SCOPE: this allowlist applies ONLY to the rule that can self-trip on pure prose +# when a body DESCRIBES the control (internal-marker, below). It must NEVER apply +# to the credential-format, infrastructure-identifier, or private-repo-topology +# rules: a live key is a live key even when the sentence around it names the gate, +# and PRs about this gate are exactly the ones whose bodies contain these words. +# Those rules take no allowlist at all in a body — see private-repo-ops below. 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 [prose] -# Pass `prose` as the 5th arg to apply the ABOUT_THE_CONTROL allowlist. Format -# rules (credentials, infrastructure identifiers) omit it and stay strict. +# check [about-the-control-exempt] +# Pass the literal string `about-the-control-exempt` as the 5th argument to let +# lines matching ABOUT_THE_CONTROL or carrying `guard:allow ` through. +# Only self-referential prose rules may opt in; hard-format rules must not — +# in a body both escapes are author-controlled, so a live key stays a hit no +# matter what else its line says. check() { - local sev="$1" name="$2" re="$3" why="$4" scope="${5:-strict}" + local sev="$1" name="$2" re="$3" why="$4" about_exempt="${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. @@ -73,19 +79,27 @@ 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. # - # The filters fail closed too, same as the scan above. An `|| true` here would - # turn a broken filter (exit >= 2) into an empty match list, converting a - # DETECTED hit into a pass; exit 1 just means the filter removed every line. - local matches frc - matches="$(printf '%s' "$raw" | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]')"; frc=$? - if (( frc >= 2 )); then - echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the guard:allow filter for rule '$name'; failing closed." - exit 2 - fi - if [[ "$scope" == "prose" ]]; then - matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; frc=$? - if (( frc >= 2 )); then - echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $frc) applying the about-the-control allowlist for rule '$name'; failing closed." + # The filters fail closed too: exit 1 just means every line was filtered (fine), + # but >=2 is a scanner error, and treating it as "no matches" would turn a broken + # allowlist into a green check over real hits. + # + # BOTH allowlists live inside the opt-in branch: a body has no reviewable diff, + # so the untrusted author could otherwise neutralize any rule — including the + # live-credential rules — by appending `guard:allow ` to the same line. + # Only the self-referential prose rules may be exempted, and only because their + # alternative (blocking every discussion of the gate itself) gets the gate + # switched off. + local matches="$raw" + if [[ "$about_exempt" == "about-the-control-exempt" ]]; then + matches="$(printf '%s' "$matches" \ + | 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 + 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 allowlist for rule '$name' — failing closed." exit 2 fi fi @@ -116,7 +130,14 @@ check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Em # --- 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' +# The leading lookahead exempts the range's own DESIGNATION — the all-zero +# network address 100.64.0.0, with or without a CIDR suffix. That string is the +# public NAME of the CGNAT range (it appears in this rule's own message), and +# infrastructure rules deliberately accept no allowlist marker, so matching it +# would block every body that so much as quotes the gate's documentation, with +# no remedy short of deleting the text. Every real fleet address — any host +# with a non-zero octet — is still a hit. +check BLOCK internal-ip '(?!100\.64\.0\.0(?:/[0-9]{1,2})?(?![0-9]))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' @@ -133,7 +154,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 '(?`. + # Deliberately STRICT — no 5th argument, so NEITHER allowlist reaches this + # rule. It already tolerates a bare mention, so the only way to trip it is a + # private repo name sitting NEXT TO operational detail, and that is a leak + # even on a line that also names the gate ("public-repo-guard now blocks + # FOO_SECRET bound on " is precisely the topology this rule + # exists for). Both escapes are author-controlled in a body — one edit, not + # one review — so a documented safe example belongs in a FILE, where + # `guard:allow ` lands in a reviewable diff. check BLOCK private-repo-ops \ "\\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' diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh index 1c0cd4a..0b14340 100755 --- a/scripts/public-repo-guard/tests/body-policy.test.sh +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -68,14 +68,36 @@ expect 1 'AWS access key id' \ # discussions are exactly where an accidental paste is most likely to land. expect 1 'credential format blocks even on a line naming the control' \ "public-repo-guard flagged ${AKID_FIXTURE} in the job logs." -expect 1 'internal tailscale IP' \ - 'It resolves to 100.71.4.19 from inside the fleet.' +# Regression: `guard:allow` once exempted EVERY rule, but a body has no reviewable +# diff — the untrusted author can append the marker in the same edit that leaks. +# The marker may only exempt the self-referential prose rules, never a credential. +expect 1 'guard:allow does NOT exempt a credential in a body' \ + "Key for the repro: ${AKID_FIXTURE} — guard:allow repro-example" # Regression: naming the gate does not sanitize topology. The about-the-control -# allowlist is scoped to the internal-marker rule only — a line that says -# "public-repo-guard" AND wires a private repo to a credential name is exactly -# the leak private-repo-ops exists for. +# allowlist does not reach private-repo-ops — a line that says "public-repo-guard" +# AND wires a private repo to a credential name is exactly the leak that rule +# exists for. A deliberate safe example belongs in a file, where the marker lands +# in a reviewable diff, not in a body the author can edit at will. expect 1 'private repo + credential name blocks even on a line naming the control' \ 'public-repo-guard fires when acme-alpha sits near a BAR_SECRET name; expected.' +expect 1 'guard:allow does NOT exempt private-repo topology in a body' \ + 'Example for the docs: acme-alpha holds EXAMPLE_SECRET — guard:allow documented-example' +expect 1 'internal tailscale IP' \ + 'It resolves to 100.71.4.19 from inside the fleet.' +# The range-designation exemption must stay razor-thin: a host one address past +# the all-zero network form is a real fleet machine and still blocks. +expect 1 'host adjacent to the network address still blocks' \ + 'The subnet router answers on 100.64.0.1 inside the fleet.' +# Regression: `read` stops at the first newline, so a newline-separated org variable +# once configured only the first name and passed over the unscanned rest. +GUARD_PRIVATE_REPOS=$'acme-alpha\nacme-beta\nacme-gamma' \ +expect 1 'newline-separated GUARD_PRIVATE_REPOS still scans later names' \ + 'The BETA_JOIN_SECRET was added; acme-beta picks it up on deploy.' +# Regression: a CRLF-stored variable once glued an invisible \r to every name, so +# the built regex matched nothing and the gate fail-opened with no diagnostic. +GUARD_PRIVATE_REPOS=$'acme-alpha\r\nacme-beta\r\nacme-gamma\r' \ +expect 1 'CRLF-separated GUARD_PRIVATE_REPOS still scans every name' \ + 'The BETA_JOIN_SECRET was added; acme-beta picks it up on deploy.' # --- must PASS (precision — these keep the gate deployable) ------------------- expect 0 'bare private-repo cross-reference' \ @@ -84,8 +106,8 @@ expect 0 'two private repos, no operational detail' \ 'Both acme-alpha and acme-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 top-level (?i) once leaked into OPS_DETAIL and made the -# SCREAMING_CASE credential rule match ordinary lowercase identifiers. +# Regression: a global (?i) once leaked into the SCREAMING_CASE alternative, so +# ordinary lowercase prose like "cache_key" counted as a credential NAME. expect 0 'lowercase identifier near a private repo is prose, not a credential' \ 'In acme-alpha we renamed the cache_key format for the edge.' expect 0 'public runner path is not an operator path' \ @@ -94,10 +116,17 @@ expect 0 'talking about the control' \ 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' expect 0 'internal-marker stays exempt on a line naming the control' \ 'public-repo-guard exists so an internal-only doc never lands in a public body.' -expect 0 'explicit guard:allow with a reason' \ - 'Example for the docs: acme-alpha holds EXAMPLE_SECRET — guard:allow documented-example' +expect 0 'explicit guard:allow with a reason exempts a prose rule' \ + 'Docs example of the marker text: internal-only — guard:allow documented-example' expect 0 'ordinary clean body' \ 'Bumps the draft revision and regenerates the fixtures. No behaviour change.' +# Regression risk called out in review: the gate's own docs (and any body quoting +# them, which review bots do) name the range as 100.64.0.0/10. That is the NAME +# of the range, not a host on it, and infra rules have no allowlist escape. +expect 0 'the CGNAT range designation is the name of the range, not a host' \ + 'The internal-ip rule covers the 100.64.0.0/10 space by design.' +expect 0 'the bare all-zero network address is a designation too' \ + 'Traffic in 100.64.0.0 space never leaves the tailnet.' # 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' \