diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 719718a..665c0b7 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,10 +13,13 @@ name: public-repo-guard # wave-av/.github must not be able to alter another repo's secret scanner). The # gitleaks binary is version-pinned AND SHA-256-verified before it runs. # -# To install on a new repo, copy all three files together: +# To install on a new repo, copy all five files together (the last is executed +# by the guard job's self-test step — omitting it fails the workflow at run time): # .github/workflows/public-repo-guard.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh +# scripts/public-repo-guard/body-policy.sh +# scripts/public-repo-guard/tests/body-policy.test.sh # # Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The # goal is "what is public right now is clean", so a shallow checkout is sufficient. @@ -25,24 +28,117 @@ 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] + # `issue_comment` only covers the top-level conversation. Inline diff comments + # and review summary bodies are separate surfaces, equally world-readable, and + # were the last body text nothing scanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] push: branches: [main, master] workflow_dispatch: +# `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 ONLY issues/issue_comment events (the tree scan has nothing to say + # about a comment, and their check-runs attach to the default branch, never a + # PR head, so that skip cannot mask a PR verdict). Every OTHER event runs the + # tree scan even when it cannot have changed the tree (`edited`, review + # events): those runs attach check-runs to the PR head SHA, a job-level skip + # still posts a fresh check-run with conclusion `skipped` there, and branch + # protection reads the LATEST check-run of a given name and treats `skipped` + # as passing — so skipping would let a body edit or review comment silently + # replace a failing tree verdict with a mergeable one. Re-scanning an + # unchanged tree is the cheap side of that trade. Written as a denylist so a + # future trigger fails toward scanning, not toward a green rubber stamp. + if: >- + github.event_name != 'issues' + && github.event_name != 'issue_comment' + # Keyed on the SHA being scanned, NOT the PR number: review events carry the + # PR's number too, so a number-keyed group let a review submitted mid-scan + # cancel the PR's in-flight tree scan. Two runs share a group only when they + # would scan the SAME tree (rapid body edits, review chatter on one head); + # a new push gets a new SHA, a new group, and a run nothing can cancel. + concurrency: + group: public-repo-guard-tree-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Review events are the one privileged path through this job: for a fork PR, + # `pull_request` runs with a fork-scoped read-only token, but + # `pull_request_review` / `pull_request_review_comment` run in the BASE + # repository's context (org/repo variables resolvable, base-repo token), + # while the checkout above still resolves the PR merge ref, i.e. the fork's + # code. The tree is only ever scanned as DATA, so that stays. But the gate's + # own executables (.gitleaks.toml, content-policy.sh, the self-test) must + # not be taken from the untrusted tree in that context, or a fork PR that + # edits them gains code execution in a base-repo run the moment a maintainer + # reviews it. On review events this step pins the bundle to the base repo's + # default branch, materialized OUTSIDE the workspace so the trusted copies + # are never themselves scanned as tree content. Every other event keeps the + # tree's own copies: `pull_request` is unprivileged for forks, `push` / + # `workflow_dispatch` only ever run base-repo code, and a PR that edits the + # gate must be tested against its own edits. + - name: Pin gate executables to the default branch (review events) + id: gate + env: + IS_REVIEW_EVENT: ${{ github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment' }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + if [ "$IS_REVIEW_EVENT" != "true" ]; then + echo "dir=." >> "$GITHUB_OUTPUT" + echo "selftest=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + trusted="$RUNNER_TEMP/trusted-gate" + mkdir -p "$trusted/scripts/public-repo-guard/tests" + git fetch --depth 1 origin "refs/heads/$DEFAULT_BRANCH" + # .gitleaks.toml and content-policy.sh are install PREREQUISITES of this + # workflow. If the default branch lacks either, refuse to run rather + # than fall back to executing the PR tree's copy in a privileged run. + for f in .gitleaks.toml scripts/public-repo-guard/content-policy.sh; do + if ! git show "FETCH_HEAD:$f" > "$trusted/$f" 2>/dev/null; then + echo "::error title=public-repo-guard (guard)::$f is missing from '$DEFAULT_BRANCH'; refusing to execute the PR tree's copy in a privileged review-event run." + exit 1 + fi + done + # Bootstrap only: on the PR that installs the gate, the default branch + # does not carry body-policy.sh or its fixtures yet. Skip the self-test + # here rather than run the PR's copy (that would reopen the exact hole + # this step closes); every pull_request event still runs the tree's own + # fixtures in an unprivileged context, so coverage is not lost. + selftest=true + for f in scripts/public-repo-guard/body-policy.sh scripts/public-repo-guard/tests/body-policy.test.sh; do + if ! git show "FETCH_HEAD:$f" > "$trusted/$f" 2>/dev/null; then + echo "::notice title=public-repo-guard (guard)::$f is not on '$DEFAULT_BRANCH' yet; skipping the body-policy self-test for this review-event run." + selftest=false + fi + done + echo "dir=$trusted" >> "$GITHUB_OUTPUT" + echo "selftest=$selftest" >> "$GITHUB_OUTPUT" # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release @@ -62,7 +158,9 @@ jobs: gitleaks version - name: gitleaks (secret scan — published tree) - run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 + env: + GATE_DIR: ${{ steps.gate.outputs.dir }} + run: gitleaks detect --no-git --source . --config "$GATE_DIR/.gitleaks.toml" --redact --no-banner --exit-code 1 - name: Install ripgrep run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) @@ -70,4 +168,139 @@ jobs: - name: content policy (WAVE trade-secret / internal-leak gate) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} - run: bash scripts/public-repo-guard/content-policy.sh . + GATE_DIR: ${{ steps.gate.outputs.dir }} + run: bash "$GATE_DIR/scripts/public-repo-guard/content-policy.sh" . + + # The body gate's own fixtures. Its negatives are the load-bearing half — a + # leak gate that blocks legitimate cross-repo references gets switched off, + # and then it protects nothing. Runs here so a regression is caught by CI + # rather than by a leak. + - name: body policy self-test (fixtures) + if: ${{ steps.gate.outputs.selftest == 'true' }} + env: + GATE_DIR: ${{ steps.gate.outputs.dir }} + run: bash "$GATE_DIR/scripts/public-repo-guard/tests/body-policy.test.sh" + + # The other half of a public repo's surface. `guard` above scans the published + # TREE; a PR/issue/comment/review BODY is just as world-readable and, until this + # 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, comment, or review the text is already public the moment it posts, so + # this is detection — it tells us to go redact, fast. Only the client-side + # pre-write hook can stop that class before publication. + body-guard: + name: Body content policy + if: >- + github.event_name == 'pull_request' + || github.event_name == 'issues' + || github.event_name == 'issue_comment' + || github.event_name == 'pull_request_review' + || github.event_name == 'pull_request_review_comment' + concurrency: + # Keyed on the most specific identifier of the BODY under scan, not on the + # PR/issue or github.ref. Ordering matters: review-event payloads carry the + # PR object too, so `pull_request.number` first would fold every review + # comment and review summary on one PR into a single group — and GitHub + # keeps at most ONE pending run per group (even with cancel-in-progress: + # false), so a burst of review comments would silently drop some bodies + # unscanned. `comment.id` / `review.id` give each distinct body its own + # group; PR and issue events (which carry neither) fall through to their + # number, where sharing a group is CORRECT: successive body edits supersede + # each other, and the latest pending run always scans the current body. + # + # cancel-in-progress is deliberately FALSE. Every version of a body deserves a + # verdict, the job is seconds long, and a cancelled check-run lingers on the + # commit and makes an otherwise-green PR look broken. + group: public-repo-guard-body-${{ github.event.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: + # The gate's script comes from the base repo's DEFAULT BRANCH, never from + # the PR merge ref: body-guard exists to grade untrusted text, and a fork + # PR that edits body-policy.sh (say, to `exit 0`) must not get to grade its + # own body with its own scanner. Nothing else is needed from the tree: the + # text under scan comes from the event payload, not the checkout. Only the + # gate's own scripts are fetched: no reason to pay for the whole tree on + # every comment. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + path: trusted + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + # Bootstrap only: on the PR that installs the gate, the default branch does + # not have body-policy.sh yet, so fall back to the PR's own copy, but ONLY + # for a same-repo pull request. That covers review events on one too: + # reviews and inline comments on the install PR carry the same pull_request + # object, and the copy executed is still the same-repo author's, whose + # write access already lets them ship it. A fork PR replacing + # body-policy.sh (say, with `exit 0`) must never get to grade its own body + # with its own scanner, even during the bootstrap window, so fork PRs (and + # every non-PR event) fail closed below instead of falling back. The PR + # head SHA is named explicitly because review events would otherwise + # checkout the default branch, which is exactly the tree that lacks the + # script; for a same-repo PR that SHA always exists in this repository. + # Once the bundle is merged the trusted copy always exists and this step + # never runs. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: >- + hashFiles('trusted/scripts/public-repo-guard/body-policy.sh') == '' + && (github.event_name == 'pull_request' + || github.event_name == 'pull_request_review' + || github.event_name == 'pull_request_review_comment') + && github.event.pull_request.head.repo.full_name == github.repository + with: + ref: ${{ github.event.pull_request.head.sha }} + path: bootstrap + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + - name: Install ripgrep + run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + + # The body is read straight out of the event payload FILE and written to + # another file. It is never interpolated into a run: block and never placed + # in an environment variable, so shell metacharacters in a hostile PR body + # have nothing to act on. jq is preinstalled on the GitHub-hosted images. + - name: Materialize the untrusted title/body to a file + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bodyscan" + # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and + # report a pass. If the event schema ever moves, this job must go red + # rather than become a green rubber stamp over an unscanned body. + if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment") or has("review")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment/review object — refusing to report a pass on an unscanned body." + exit 1 + fi + 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 / review text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + run: | + set -euo pipefail + script="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$script" ]; then + if [ -f "bootstrap/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::notice title=public-repo-guard (body-guard)::body-policy.sh is not on the default branch yet; bootstrap run using this same-repo PR's own copy." + script="bootstrap/scripts/public-repo-guard/body-policy.sh" + else + # No trusted copy and no same-repo bootstrap copy: this is a fork + # PR (or a non-PR event) during the bootstrap window. Refuse to + # grade a body with a scanner the untrusted tree supplies. + echo "::error title=public-repo-guard (body-guard)::body-policy.sh is not on the default branch and this event's tree is untrusted; refusing to execute the PR's own scanner. Merge the guard bundle to the default branch first." + exit 1 + fi + fi + bash "$script" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh new file mode 100755 index 0000000..a1918d4 --- /dev/null +++ b/scripts/public-repo-guard/body-policy.sh @@ -0,0 +1,183 @@ +#!/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. EXCEPTION: rules +# declared `--no-exempt` (the credential formats) ignore both allowlists — a real +# secret is never legitimate in prose, so no marker or discussion context can +# make publishing one acceptable. +set -uo pipefail + +FILE="${1:-}" +[[ -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 [--no-exempt] +# --no-exempt: skip the guard:allow / ABOUT_THE_CONTROL line exemptions. For +# credential formats: a live key is a leak even on a line that names this gate +# or carries an allow marker, so no line-level context may suppress the hit. +check() { + local exempt=1 + [[ "$1" == "--no-exempt" ]] && { exempt=0; shift; } + local sev="$1" name="$2" re="$3" why="$4" + [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } + # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes + # 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. + # Each filter's exit code is checked the same way as the primary scan: 1 (all + # lines filtered) is a clean result, but >=2 is a scanner error and FAILS + # CLOSED — a swallowed filter error would empty `matches` and pass the rule. + local matches + if (( exempt )); then + matches="$(printf '%s' "$raw" | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]')"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) applying the guard:allow exemption for rule '$name' — failing closed." + exit 2 + fi + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) applying the about-the-control exemption for rule '$name' — failing closed." + exit 2 + fi + else + matches="$raw" + fi + [[ -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 -------------------------- +# --no-exempt: these formats match REAL secrets, not discussion of secrets, so +# neither `guard:allow` nor talking about the control may suppress a hit. If a +# doc genuinely needs a key-shaped example, truncate it below the rule's floor. +check --no-exempt BLOCK stripe-live-key '(sk|rk)_live_[A-Za-z0-9]{16,}' 'Live Stripe secret/restricted key' +check --no-exempt BLOCK stripe-account 'acct_[A-Za-z0-9]{16,}' 'Live Stripe account ID — financial infra, never publish' +check --no-exempt BLOCK anthropic-key 'sk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{20,}' 'Real Anthropic API/admin key' +check --no-exempt BLOCK github-pat 'github_pat_[A-Za-z0-9_]{30,}' 'GitHub fine-grained PAT' +check --no-exempt BLOCK supabase-pat 'sbp_[a-f0-9]{40}' 'Supabase personal access token' +check --no-exempt BLOCK aws-akid 'AKIA[0-9A-Z]{16}' 'AWS access key ID' +check --no-exempt BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Embedded private key material' + +# --- Infrastructure identifiers ---------------------------------------------- +# shellcheck disable=SC2016 # $CLOUDFLARE_ACCOUNT_ID is literal guidance text +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. This is the one rule whose existence depends on +# configuration, so its absence is graded by WHERE the script runs: unset locally +# → skipped (developers cannot know the org's private-repo list, and a hook that +# demands org secrets to run at all gets bypassed); unset or empty IN CI → exit 2. +# Everything else in this file fails closed, and the headline rule must not be +# the exception: a mis-typed or unset vars.GUARD_PRIVATE_REPOS would otherwise +# turn the gate into a green rubber stamp that enforces nothing. +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='' +if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then + 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 +fi +if [[ -n "$_ALT" ]]; then + # Both orders: name-then-detail and detail-then-name. Case-insensitivity is + # scoped to the repo-name alternation with (?i:...) — a leading (?i) would + # spill across the whole pattern and make the deliberately SCREAMING_CASE-only + # OPS_DETAIL match everyday prose like `api_key`. + # + # No \b brackets OPS_DETAIL in either direction. Its credential alternative + # cannot span an underscore, so inside a multi-part name like WAVE_API_TOKEN + # the only sub-match (API_TOKEN) sits after `_`, a word character, and a + # leading \b silently killed the name-first direction for exactly those + # names. The alternation's own anchors ([A-Z] start, keyword tail) already + # bound what it can touch, and the detail-then-name direction never had one. + check BLOCK private-repo-ops \ + "\\b(?i:${_ALT})\\b[^\\n]{0,140}?${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?\\b(?i:${_ALT})\\b" \ + 'A private WAVE repo named alongside internal operational detail (credential name, secret binding, or secret count) — the wiring topology is not public' +elif [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::error title=public-repo-guard (private-repo-ops)::GUARD_PRIVATE_REPOS resolved empty in CI — the private-repo proximity rule would silently not run. Set the org/repo Actions variable vars.GUARD_PRIVATE_REPOS; failing closed rather than reporting a pass that enforces nothing." + exit 2 +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..f11b2fa --- /dev/null +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -0,0 +1,150 @@ +#!/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. The +# names (and every credential name / count in the fixtures below) are DELIBERATELY +# synthetic: this file is public, and body-policy.sh's own rule is that real +# private-repo names are never hardcoded. The tests only need self-consistency +# between this variable and the fixture text. +export GUARD_PRIVATE_REPOS="example-private-a, example-private-b, example-private-c" + +PASS=0; FAIL=0 + +# 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: EXAMPLE_LEASE_SECRET is bound on example-private-a now.' +expect 1 'private repo + credential name, reverse order' \ + 'The EXAMPLE_JOIN_SECRET was added; example-private-b picks it up on deploy.' +# Regression: a \b before the credential name once killed the name-first +# direction for multi-part names — inside EXAMPLE_API_TOKEN the pattern can only +# match API_TOKEN, which sits after `_`, a word character. Note the line carries +# no other operational phrase, so only the credential-name alternative can fire. +expect 1 'private repo + multi-part credential name, name first' \ + 'example-private-a reads EXAMPLE_API_TOKEN at startup.' +expect 1 'private repo + secret count' \ + 'example-private-a went from 12 secrets to 13 after this change.' +expect 1 'private repo + service binding' \ + 'This adds a service binding from the worker to example-private-c.' +expect 1 'operator home path' \ + 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) +expect 1 'internal-only marker' \ + '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." +# Credential rules are --no-exempt: no line-level context makes a live key OK. +expect 1 'guard:allow does NOT exempt a credential' \ + "Example key: ${AKID_FIXTURE} — guard:allow documented-example" +expect 1 'talking about the control does NOT exempt a credential' \ + "body-policy caught ${AKID_FIXTURE} in a comment last week." +expect 1 'internal tailscale IP' \ + 'It resolves to 100.71.4.19 from inside the fleet.' + +# --- must PASS (precision — these keep the gate deployable) ------------------- +expect 0 'bare private-repo cross-reference' \ + 'This is the companion change to example-private-b#260; merge that one first.' +expect 0 'two private repos, no operational detail' \ + 'Both example-private-a and example-private-b will need a follow-up for this.' +expect 0 'credential NAME with no private repo nearby' \ + 'The handler now reads SOME_API_TOKEN from the environment instead of a literal.' +# Regression: a leading (?i) once spilled case-insensitivity across the whole +# private-repo-ops pattern, so a lowercase everyday word like `api_key` counted +# as operational detail and blocked any body that also named a private repo. +expect 0 'lowercase identifier near a private repo is not operational detail' \ + 'Fix example-private-a: the api_key header is now lowercase.' +expect 0 'lowercase token word near a private repo is not operational detail' \ + 'Docs for example-private-b: pass your access_token to the client.' +expect 0 'public runner path is not an operator path' \ + 'CI checks out to /home/runner/work/repo/repo before the scan runs.' # enforce-ignore (fixture) +expect 0 'talking about the control' \ + 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' +expect 0 'explicit guard:allow with a reason' \ + 'Example for the docs: example-private-a holds EXAMPLE_SECRET — guard:allow documented-example' +expect 0 'ordinary clean body' \ + 'Bumps the draft revision and regenerates the fixtures. No behaviour change.' +# Regression: the first CI run of this job failed on its own PR, because a review +# 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 -------------------------------------------------------------- +# GUARD_PRIVATE_REPOS is the one rule fed by configuration, so a configuration +# mistake must go red in CI, never green: an unset/mis-typed org variable would +# otherwise disable the headline rule while the check still reports a pass. The +# silent skip stays local-only, where the org's private-repo list is unknowable. +printf '%s\n' 'Bumps the draft revision. No behaviour change.' > "$TMP/clean.txt" +# guardcfg +guardcfg() { + local want="$1" name="$2"; shift 2 + env -u GUARD_PRIVATE_REPOS -u GITHUB_ACTIONS "$@" bash "$SCRIPT" "$TMP/clean.txt" >/dev/null 2>&1 + local rc=$? + if [[ "$rc" == "$want" ]]; then + PASS=$((PASS+1)); printf ' ok %s → exit %s\n' "$name" "$want" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit %s, got %s\n' "$name" "$want" "$rc" + fi +} +guardcfg 2 'unset GUARD_PRIVATE_REPOS in CI fails closed' GITHUB_ACTIONS=true +guardcfg 2 'whitespace-only GUARD_PRIVATE_REPOS in CI fails closed' GITHUB_ACTIONS=true GUARD_PRIVATE_REPOS=' , ' +guardcfg 0 'unset GUARD_PRIVATE_REPOS locally skips the rule' + +# Invoked directly, not through expect(): expect() always materializes a file, so +# it cannot reach these paths. A gate that returns "OK" when it was handed nothing +# to scan is the failure mode this whole file exists to prevent. +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"