diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml new file mode 100644 index 0000000..060e94d --- /dev/null +++ b/.github/workflows/public-repo-guard-body.yml @@ -0,0 +1,122 @@ +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 wrangler.toml 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. +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. +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: + 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 719718a..bd3d9a7 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,36 +14,84 @@ 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 six files together (the guard job runs the +# fixture tests, so a repo missing the tests file fails on every run): # .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 +# 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. # # 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 (this used to be +# one file with two jobs sharing one `on:` block): +# +# 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 +# (public-repo-guard-required). Before this split, that job's shared `on:` block +# had to include pull_request_review / pull_request_review_comment (needed only by +# the sibling body scan), and the job used a job-level `if:` to skip those events +# for the tree scan (a title/comment/review event cannot change the tree). GitHub +# still publishes a check-run named "Secrets + content policy" with conclusion +# `skipped` for every skipped event, on the same head SHA. Branch-protection/ +# ruleset required-status-check evaluation treats `skipped` as passing and reads +# only the NEWEST check-run of a given name — so a review comment (or any other +# skipped event) could flip an already-failed, or never-yet-completed, required +# tree scan to green with nothing re-examining the tree. Observed and confirmed on +# a sibling public repo before this shape was adopted here: wave-av/mcp-server +# PR 87 (merged) — the tree job's only non-skipped run on that head SHA was +# `cancelled`, followed by a dozen `skipped` runs, and the required check's final +# state read `skipped` (passing) despite no completed real verdict ever having +# been produced for that SHA. This file is the same fix, ported. +# +# 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 published tree (pull_request open/reopen/sync, push, workflow_dispatch, +# merge_group). 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. Coverage is unchanged: every event that +# could previously produce a real (non-skipped) tree-scan run still produces that +# same real run after the split. on: pull_request: + types: [opened, reopened, synchronize] push: branches: [main, master] workflow_dispatch: + # Required by the merge queue: a `merge_group` build never runs the `pull_request` + # trigger above, so without this the required "Secrets + content policy" check + # never reports on the queue's temporary ref and every queued PR waits forever. + 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. permissions: contents: read -concurrency: - group: public-repo-guard-${{ github.ref }} - cancel-in-progress: true - jobs: guard: name: Secrets + content policy + # No job-level `if:` needed: 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. + 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 + with: + # Nothing in this job pushes or calls the API, so the token has no + # business lingering in .git/config while repo-checked-out scripts run. + 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 @@ -64,10 +113,36 @@ 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 are rg -P (PCRE2), and Ubuntu's apt ripgrep is built + # WITHOUT it — with that build every rule exits 2 and this required check + # goes permanently red. Install the upstream binary the same way as gitleaks + # above: version-pinned AND SHA-256-verified before it runs. Skipped when the + # runner image already carries a PCRE2-capable rg (probed, not assumed). + - name: Install ripgrep (PCRE2 build, pinned + checksum-verified) + env: + RIPGREP_VERSION: "14.1.1" + RIPGREP_SHA256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" + run: | + set -euo pipefail + 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: 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 diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh new file mode 100755 index 0000000..366b4d6 --- /dev/null +++ b/scripts/public-repo-guard/body-policy.sh @@ -0,0 +1,224 @@ +#!/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). +# Prose-shaped rules (tagged `prose` below) are additionally exempt on lines +# matching the ABOUT-THE-CONTROL allowlist; credential and infrastructure rules +# are NOT — a real key is a leak no matter what else shares its line. +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. +# +# Scope: consulted ONLY by rules tagged `prose` below — the ones that fire on the +# LANGUAGE of a sentence and therefore misfire on sentences about the gate. A +# credential or infrastructure identifier is a leak regardless of what else shares +# its line; naming the gate next to a live key must not launder the key, so those +# rules never see this allowlist and `guard:allow ` is their only +# (visible) escape hatch. +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] +# `prose` opts the rule into the ABOUT_THE_CONTROL allowlist above. Omit it for +# credential/infrastructure rules so a same-line mention of the gate can never +# suppress a real leak. +check() { + local sev="$1" name="$2" re="$3" why="$4" scope="${5:-}" + [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } + # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes + # because its scanner broke is worse than no gate: it reports success. + 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. + # + # The filters fail CLOSED exactly like the main scan: exit 1 only means every + # hit was filtered away (fine), but exit >= 2 is a broken filter, and a broken + # filter that empties the match list would convert detected leaks into a + # silent pass. That is why there is no `|| true` here. + local matches + 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) in the guard:allow filter for rule '$name'. Failing closed." + exit 2 + fi + if [[ "$scope" == "prose" && -n "$matches" ]]; then + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) in the ABOUT_THE_CONTROL filter for rule '$name'. Failing closed." + exit 2 + fi + fi + [[ -z "$matches" ]] && return 0 + local count; count="$(printf '%s\n' "$matches" | grep -c '')" + # Print the LINE NUMBER only — never the matched text. This annotation is itself + # 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' +# The BODY profile diverges from the FILE gate here too. The tree gate excludes +# the guard's own directory from scanning, so its copy of this rule never sees +# the range literal in its own comments; body text has no such exclusion, and +# security discussion names the range's documentation form constantly (including +# quoting this very rule). Two shapes are RANGE-talk, not a fleet address, and +# are exempted: an all-zero host portion (100.64.0.0) and a CIDR-suffixed subnet +# (100.64.0.0/10, 100.71.4.0/24). A concrete host like 100.71.4.19 still blocks. +# Trade accepted: a live address written with a /32 suffix no longer fires. +check BLOCK internal-ip '100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.(?!0\.0(?![0-9]))[0-9]{1,3}\.[0-9]{1,3}(?![0-9]|/[0-9])' 'Internal Tailscale-CGNAT IP (100.64.0.0/10) — internal fleet address' +# shellcheck disable=SC2016 # $HOME is literal guidance text +# The BODY profile diverges from the FILE gate here for the same reason as +# private-repo-ops below: body text is prose, and prose contains app routes. +# "/home//" is an ordinary URL path shape ("See /home/dashboard/settings"), +# so the file gate's bare two-segment form would fire on routine product talk. +# What marks an OPERATOR path is what follows the username: further layout (one +# more path segment) or a file (a dot-bearing final segment, which also catches +# dotdirs like .config). The lookbehind keeps the rule out of absolute URLs, +# where /home/ is preceded by a hostname character. The username class accepts +# capitals: /Users/Someone/ leaks exactly as much as /Users/someone/. Trade +# accepted: a bare "/home/alice/" with nothing after it no longer fires. +check BLOCK abs-user-path '(?` already exists as the honest, visible one. +# +# Case-insensitivity is scoped per-rule with (?i:...), never a leading (?i): a +# sentence-initial "Internal-only" and a shouted "DO NOT SHARE" are the common +# real forms of this marker, while the credential rules above keep their +# deliberate case requirements intact. +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 and INTERNAL +# OPERATIONAL DETAIL on the SAME LINE, within ~140 characters of each other — 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. Trade accepted: the scan is line-scoped (rg matches +# per line and the separator excludes newlines), so a repo name on one line and +# the detail on the next does not fire. Cross-line proximity would need multiline +# scanning with its own false-positive budget; revisit if that shape leaks. +# +# Names are NOT hardcoded (this file is public); CI injects them via the +# GUARD_PRIVATE_REPOS variable. Unset locally → this check is skipped. +_PRIVATE_REPO_OPS_RAN=0 +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='' + # The org variable may be comma- OR newline-separated; `read` stops at the first + # newline, which would silently configure only the FIRST name and then report a + # pass over every unscanned name after it. Normalise newlines to spaces before + # splitting — carriage returns too: a CRLF-stored value would otherwise leave an + # invisible \r glued to each name, so the built regex matches nothing and the + # rule fail-opens with no diagnostic at all. + IFS=', ' read -r -a _PRIV <<< "${GUARD_PRIVATE_REPOS//[$'\n'$'\r']/ }" + 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 + _PRIVATE_REPO_OPS_RAN=1 + # Both orders: name-then-detail and detail-then-name. Case-insensitivity is + # scoped with (?i:...) to the REPO NAME alone: a leading (?i) would bleed into + # OPS_DETAIL and turn its deliberate SCREAMING_CASE requirement into a match + # on everyday lowercase words (docs/setup_key.md, process.env.api_token), + # blocking exactly the bare cross-references this rule promises to leave alone. + # + # No \b in front of OPS_DETAIL: a multi-segment credential name like + # EXAMPLE_LEASE_SECRET can only start its match at the inner segment (LEASE), + # and the underscore before it is a word character, so a boundary there never + # exists — a leading \b silently exempted every credential name with more than + # one underscore when it followed the repo name. Uppercase-shape matching does + # not need the anchor; starting mid-token still evidences a credential name. + check BLOCK private-repo-ops \ + "(?i:\\b(?:${_ALT})\\b)[^\\n]{0,140}?${OPS_DETAIL}|${OPS_DETAIL}[^\\n]{0,140}?(?i:\\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' \ + prose + fi +fi +# Unset locally is fine (the fixtures pin their own names). In CI it is not: an +# empty or names-free variable means the flagship rule scanned NOTHING while the +# job still reports green — the quiet inverse of this script's fail-closed +# posture, and precisely the "green rubber stamp over an unexamined class" that +# every other stage here refuses. A missing or renamed org variable must go RED, +# not emit a warning nobody reads, so this fails CLOSED in CI (GITHUB_ACTIONS +# set) and stays a silent skip only for local runs. +if [[ "$_PRIVATE_REPO_OPS_RAN" == 0 && -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::error title=public-repo-guard (private-repo-ops)::GUARD_PRIVATE_REPOS is empty or contains no names: the private-repo-ops rule scanned nothing this run. Refusing to report a pass over an unscanned leak class — configure the org/repo Actions variable. (Fails closed in CI only; a local run skips the rule.)" + 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..6cc8500 --- /dev/null +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -0,0 +1,231 @@ +#!/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 rules use -P (PCRE2), and not every rg build ships it (Ubuntu's apt package +# does not). On such a build the scanner's fail-closed posture turns EVERY fixture +# into "want exit 1, got 2" — dozens of opaque failures indistinguishable from a +# broken gate. Probe once up front, exactly like the workflow's install step, so +# the suite fails with the actual cause named instead. +command -v rg >/dev/null 2>&1 \ + || { echo "FAIL: ripgrep (rg) is required to run these fixtures" >&2; exit 1; } +echo probe | rg -qP 'p(?=robe)' \ + || { echo "FAIL: this ripgrep build lacks PCRE2 (-P) support, which the policy rules require — install a PCRE2-enabled rg (Ubuntu noble's apt package, brew, or cargo install ripgrep --features pcre2)" >&2; exit 1; } + +# 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 +# pinned names are deliberately SYNTHETIC: this file is public, and hardcoding a +# real private-repo name here would publish the very fact the gate suppresses. +# The rules are shape-based, so synthetic names exercise identical code paths. +export GUARD_PRIVATE_REPOS="example-priv-alpha, example-priv-beta, example-priv-gamma" + +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-priv-alpha now.' +expect 1 'private repo + credential name, reverse order' \ + 'The EXAMPLE_JOIN_SECRET was added; example-priv-beta picks it up on deploy.' +expect 1 'private repo + secret count' \ + 'example-priv-alpha went from 74 secrets to 75 after this change.' +expect 1 'repo name matches case-insensitively' \ + 'Flip is live: EXAMPLE_LEASE_SECRET is bound on Example-Priv-Alpha now.' +expect 1 'private repo + service binding' \ + 'This adds a service binding from the worker to example-priv-gamma for settlement.' +# Regression: a leading \b before the credential-name shape made multi-underscore +# names (only matchable from their inner segment, which follows a word character) +# unmatchable in name-then-detail order, silently exempting exactly these bodies. +expect 1 'private repo then multi-segment credential name' \ + 'example-priv-alpha now reads EXAMPLE_LEASE_SECRET at boot.' +expect 1 'private repo then multi-segment token name' \ + 'example-priv-alpha now reads WAVE_API_TOKEN at boot.' +expect 1 'operator home path' \ + 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) +expect 1 'operator home path, capitalized username' \ + 'Logs land in /Users/Someone/Library/Logs/wave.log on my machine.' # enforce-ignore (fixture) +expect 1 'operator home path, file directly under the home dir' \ + 'The crash referenced /home/someoperator/wrangler.toml directly.' # enforce-ignore (fixture) +expect 1 'internal-only marker' \ + 'Attaching the internal-only rollout plan for context.' +# The marker rules are case-insensitive on purpose: sentence-initial and shouted +# forms are how these phrases are actually written. +expect 1 'capitalized internal-only marker' \ + 'Internal-only rollout plan attached.' +expect 1 'shouted do-not-share marker' \ + 'DO NOT SHARE outside the team.' +expect 1 'for-internal-use marker, sentence-initial' \ + 'For internal use only; see the attached doc.' +# 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.' +# The same-line bypass: mentioning the gate must never launder a credential. +# ABOUT_THE_CONTROL is prose-rules-only; a key next to "public-repo-guard" blocks. +expect 1 'credential on a line that names the control still blocks' \ + "public-repo-guard flagged ${AKID_FIXTURE} in the run linked from SECURITY.md." +expect 1 'internal IP on a line that names the control still blocks' \ + 'body-policy missed 100.71.4.19 on the first pass; fixed now.' + +# --- must PASS (precision — these keep the gate deployable) ------------------- +expect 0 'bare private-repo cross-reference' \ + 'This is the companion change to example-priv-beta#260; merge that one first.' +# Case-insensitivity must stay scoped to the repo NAME: lowercase everyday words +# ending in key/token/secret are not operational detail. +expect 0 'lowercase key-ish word near a private repo is not ops detail' \ + 'Companion to example-priv-beta#260; see docs/setup_key.md for the steps.' +expect 0 'lowercase env accessor near a private repo is not ops detail' \ + 'example-priv-alpha now reads the value from process.env.api_token in dev.' +expect 0 'two private repos, no operational detail' \ + 'Both example-priv-alpha and example-priv-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.' +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) +# Body text is prose, and prose contains app routes: /home// is an ordinary +# URL path shape. Only username-plus-layout (or a dot-bearing file segment) fires. +expect 0 'app route under /home/ is not an operator path' \ + 'See /home/dashboard/settings route for the new page.' +expect 0 'absolute URL with a deep /home/ path is not an operator path' \ + 'Deep link: https://app.wave.online/home/dashboard/settings/profile works now.' +# RANGE-talk is not a fleet address: the documentation form of the CGNAT range +# (all-zero host, or any CIDR-suffixed subnet) appears in ordinary security +# discussion — including quotes of this gate's own comments — and must pass. +expect 0 'CGNAT range in documentation form (CIDR)' \ + 'The internal-ip rule covers the Tailscale CGNAT range 100.64.0.0/10 by design.' +expect 0 'CGNAT range with all-zero host, no CIDR' \ + 'The fleet overlay uses 100.64.0.0 as its network address.' +expect 0 'CIDR-suffixed subnet of the range' \ + 'Traffic from 100.71.4.0/24 is routed through the tunnel.' +expect 0 'talking about the control' \ + 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' +# Prose rules DO consult ABOUT_THE_CONTROL: a sentence describing the gate's +# behaviour with a real repo name stays discussable. +expect 0 'prose rule discussing the gate (repo + credential name)' \ + 'public-repo-guard fires when example-priv-alpha appears near EXAMPLE_SECRET; see the fixtures.' +expect 0 'unquoted marker on a line that names the control' \ + 'public-repo-guard blocks internal-only markers wherever they appear in body text.' +expect 0 'explicit guard:allow with a reason' \ + 'Example for the docs: example-priv-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 +# 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 + +# Regression: the post-scan filter stages must fail CLOSED too. They once ran +# `rg ... || true`, so a filter error (exit >= 2, e.g. a PCRE2-less rg on the -P +# allowlist filter) emptied the match list and reported CLEAN on a body whose +# main scan had already found a leak. Simulate with an rg shim that errors on +# inverted-match (-v*) invocations and delegates everything else to the real rg: +# the main scan still hits, and the gate must exit 2, never 0. +REAL_RG="$(command -v rg)" +mkdir -p "$TMP/fakebin" +cat > "$TMP/fakebin/rg" < "$TMP/body.txt" +PATH="$TMP/fakebin:$PATH" bash "$SCRIPT" "$TMP/body.txt" >/dev/null 2>&1 +rc=$? +if [[ "$rc" == 2 ]]; then + PASS=$((PASS+1)); printf ' ok broken filter stage → exit 2 (fails closed)\n' +else + FAIL=$((FAIL+1)); printf ' FAIL broken filter stage — want exit 2, got %s\n' "$rc" +fi + +# Regression: `read` stops at the first newline, so a newline-separated org +# variable once configured only the first name and reported a pass over every +# unscanned name after it. A CRLF-stored value glued an invisible \r to each name, +# which made the built regex match nothing and fail OPEN with no diagnostic. +GUARD_PRIVATE_REPOS=$'example-priv-alpha\nexample-priv-beta\nexample-priv-gamma' \ +expect 1 'newline-separated GUARD_PRIVATE_REPOS still scans later names' \ + 'The EXAMPLE_JOIN_SECRET was added; example-priv-beta picks it up on deploy.' +GUARD_PRIVATE_REPOS=$'example-priv-alpha\r\nexample-priv-beta\r\nexample-priv-gamma\r' \ +expect 1 'CRLF-separated GUARD_PRIVATE_REPOS still scans every name' \ + 'The EXAMPLE_JOIN_SECRET was added; example-priv-beta picks it up on deploy.' + +# An empty GUARD_PRIVATE_REPOS is a documented local convenience, but in CI it +# means the flagship rule scanned nothing while the job reports green. It must +# fail CLOSED there (exit 2) and stay a silent skip locally (exit 0). +printf '%s\n' 'Ordinary clean body with nothing to find.' > "$TMP/body.txt" +env -u GUARD_PRIVATE_REPOS GITHUB_ACTIONS=true bash "$SCRIPT" "$TMP/body.txt" >/dev/null 2>&1 +rc=$? +if [[ "$rc" == 2 ]]; then + PASS=$((PASS+1)); printf ' ok GUARD_PRIVATE_REPOS unset in CI → exit 2 (fails closed)\n' +else + FAIL=$((FAIL+1)); printf ' FAIL GUARD_PRIVATE_REPOS unset in CI — want exit 2, got %s\n' "$rc" +fi +env -u GUARD_PRIVATE_REPOS -u GITHUB_ACTIONS bash "$SCRIPT" "$TMP/body.txt" >/dev/null 2>&1 +rc=$? +if [[ "$rc" == 0 ]]; then + PASS=$((PASS+1)); printf ' ok GUARD_PRIVATE_REPOS unset locally → exit 0 (rule skipped)\n' +else + FAIL=$((FAIL+1)); printf ' FAIL GUARD_PRIVATE_REPOS unset locally — want exit 0, got %s\n' "$rc" +fi + +echo " ---" +if (( FAIL > 0 )); then + echo " $PASS passed, $FAIL FAILED"; exit 1 +fi +echo " $PASS passed, 0 failed"