diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml new file mode 100644 index 0000000..0ba07e0 --- /dev/null +++ b/.github/workflows/public-repo-guard-body.yml @@ -0,0 +1,134 @@ +name: public-repo-guard-body + +# The other half of a public repo's surface. public-repo-guard.yml 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. +# +# A SEPARATE workflow file on purpose, not a second job next to the tree gate. +# The tree gate's `Secrets + content policy` check is required by the org ruleset, +# and a job-level `if` skip still creates a check run (conclusion `skipped`) on +# the PR head SHA. Required checks treat `skipped` as satisfied and the newest +# check run of a name wins, so if the tree gate's workflow also fired on +# issue/comment/review events (only to skip the tree job), any comment or review +# on a PR would launder an earlier genuine tree-scan FAILURE on the same commit +# into a mergeable PR. Keeping these triggers in their own file means the tree +# gate never emits a skipped verdict. Copy BOTH workflow files together; the full +# six-file install manifest lives in public-repo-guard.yml's header. +# +# Honest about what it can and cannot do. On a PR this check gates the merge +# only once the org ruleset requires the `Body content policy` context; the +# ruleset today requires only `Secrets + content policy` (the tree gate), so +# until that rollout lands a failing body scan is advisory on PRs too. On an +# issue or comment the text is already public the moment it posts, so this is +# detection either way: 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 workflow covered it, nothing ever re-scanned. + # `synchronize` keeps a `Body content policy` verdict on every head SHA, so the + # check is already present on each commit if the ruleset rollout makes it required. + pull_request: + types: [opened, edited, reopened, synchronize] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + # A review's summary and its inline comments are the SAME public surface as an + # issue comment, but GitHub emits them on their own events — without these two, + # text posted in a review would never reach body-guard. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. +permissions: + contents: read + +jobs: + # No job-level `if`: every event in the trigger list above gets a real scan, so + # this job never reports a skipped conclusion over an unscanned body. + 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/review id comes FIRST, before the PR number: review events also + # carry a top-level `pull_request` object, so a PR-number-first key put a + # batch review's N inline comments (N near-simultaneous events, each with + # DIFFERENT text) into one group, and GitHub keeps only ONE pending run per + # group: queueing a new run cancels the previously pending one, permanently + # dropping those bodies unscanned. Per-id keying isolates each comment and + # review; only re-edits of the SAME body (PR/issue title+body) share a group, + # where the surviving run scans the newest version. + # + # 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: + - 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 + # The gate only reads the tree; no later step may find the workflow token + # in .git/config — matches every other workflow in this repo. + persist-credentials: false + + # The policy script scans with `rg -P`, which needs a PCRE2-enabled build. + # Neither the runner image nor Ubuntu's apt package guarantees one, and a + # PCRE2-less rg makes every rule fail closed — so install the pinned upstream + # musl release (built with PCRE2), checksum-verified like gitleaks in the tree + # gate, and verify PCRE2 at install time, where the failure names the problem. + - name: Install ripgrep (pinned + checksum-verified, PCRE2 required) + env: + RIPGREP_VERSION: "14.1.1" + RIPGREP_SHA256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" + run: | + 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 >/dev/null \ + || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; 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") 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, + .review.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 / review 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..d874038 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -13,10 +13,15 @@ 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: # .github/workflows/public-repo-guard.yml +# .github/workflows/public-repo-guard-body.yml (the body gate; a SEPARATE file +# on purpose, see that file's header for why it must not live here) # .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 (run unconditionally as a +# self-test step below — omit it and the gate fails on every push) # # 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 +30,49 @@ name: public-repo-guard # path glob to a repo-root `.guardignore`, or extend the repo-local `.gitleaks.toml`. on: + # This file's trigger list must contain ONLY events on which `guard` performs a + # real scan. The job is the required `Secrets + content policy` check, and a + # job-level `if` skip still creates a check run (conclusion `skipped`) on the + # PR head SHA. Required checks treat `skipped` as satisfied and the newest check + # run of a name wins, so one skipped run would launder an earlier genuine FAILURE + # on the same commit into a mergeable PR. That is why the body gate, with its + # issue/comment/review triggers, lives in public-repo-guard-body.yml instead of + # here, and why `edited` below runs a full scan rather than being filtered out: + # a title edit re-scans an unchanged merge ref (same verdict, small cost), and a + # base-branch retarget (which also arrives as `edited`, with no `synchronize` + # guaranteed to follow) re-scans the NEW merge ref it actually changes. pull_request: + types: [opened, edited, reopened, synchronize] push: branches: [main, master] workflow_dispatch: +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its tree. permissions: contents: read -concurrency: - group: public-repo-guard-${{ github.ref }} - cancel-in-progress: true - jobs: guard: name: Secrets + content policy + # No job-level `if`: every event this workflow triggers on gets a real scan. + # See the trigger comment above: a skipped run of a required check is not + # neutral, it REPLACES a failing verdict on the same head SHA. + concurrency: + group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} + # `edited` fires on every title/body edit (bots edit PR bodies constantly) and + # re-scans an UNCHANGED merge ref — so it must not cancel an in-flight run on + # the same head SHA, or every body edit leaves a cancelled check-run attached + # to the commit. `synchronize` (and push) DO supersede the previous merge ref, + # so those still cancel the now-stale scan. + cancel-in-progress: ${{ github.event.action != 'edited' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The gate only reads the tree; no later step may find the workflow token + # in .git/config — matches every other workflow in this repo. + 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 +94,33 @@ 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 scan with `rg -P`, which needs a PCRE2-enabled build. + # Neither the runner image nor Ubuntu's apt package guarantees one, and a + # PCRE2-less rg makes every rule fail closed — so install the pinned upstream + # musl release (built with PCRE2), checksum-verified like gitleaks above, and + # verify PCRE2 at install time, where the failure names the actual problem. + - name: Install ripgrep (pinned + checksum-verified, PCRE2 required) + env: + RIPGREP_VERSION: "14.1.1" + RIPGREP_SHA256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" + run: | + 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 >/dev/null \ + || { echo "::error title=public-repo-guard::this ripgrep build lacks PCRE2 (required for the -P policy rules) — install a PCRE2-enabled rg."; exit 1; } - 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..cf5cacc --- /dev/null +++ b/scripts/public-repo-guard/body-policy.sh @@ -0,0 +1,191 @@ +#!/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. +# +# Scoped PER RULE, not globally: only the PROSE-shaped rules (internal-marker, +# private-repo-ops) accept it, because only those can fire on a sentence that +# merely discusses the gate. A credential-shaped VALUE is a leak no matter what +# else shares its line — "public-repo-guard caught AKIA…" still contains the key — +# so the credential and infrastructure rules ignore this list and only the +# explicit, visible `guard:allow ` can exempt them. +ABOUT_THE_CONTROL='(public-repo-guard|body-policy|content-policy|public-github-write-gate|\bNDA\s+(gate|guard|policy|denylist|sweep|scan|hook)\b|\bno\s+NDA\b|responsib\w*\s+disclos|SECURITY\.md)' + +# check [about-ok] +# Pass "about-ok" as the 5th arg to additionally exempt lines matching +# ABOUT_THE_CONTROL. Only prose-shaped rules should opt in (see above). +check() { + local sev="$1" name="$2" re="$3" why="$4" about="${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. + local matches + matches="$(printf '%s' "$raw" \ + | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' || true)" + if [[ "$about" == "about-ok" ]]; then + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" + 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' +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. +# +# Case-insensitive via (?i:...) scoped to the marker alternation only: these +# markers most often arrive as sentence-initial capitals or shouty headers +# ("INTERNAL ONLY", "Do not share…"), and a case-sensitive match missed exactly +# those. The scoping keeps OPS_DETAIL's deliberate SCREAMING_CASE requirement +# below untouched. +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. What an EMPTY value means depends on where we +# are. In CI it is a misconfiguration, not a pass: the workflow always injects +# the env line, so empty means the org variable is unset, renamed, or not +# visible to the run; and every other broken precondition in this file exits 2 +# rather than rubber-stamping, so the one rule this gate was built for must not +# be the exception. Locally the check is skipped, with a visible notice, +# because developers have no reason to carry the org's private-repo list. +_no_private_repo_config() { + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::error title=public-repo-guard (private-repo-ops)::GUARD_PRIVATE_REPOS is empty (the org variable is unset, renamed, or not visible to this run). Refusing to report a pass with the private-repo rule silently disabled." + exit 2 + fi + echo "body-policy: GUARD_PRIVATE_REPOS unset; private-repo-ops rule skipped (local run only)" +} +if [[ -z "${GUARD_PRIVATE_REPOS:-}" ]]; then + _no_private_repo_config +else + 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='' + # `-d ''` reads the WHOLE value, not just its first line: without it, `read` + # stops at the first newline, so a newline-separated Actions variable (the + # natural shape for a multi-line list) silently dropped every name after the + # first. Split on commas, spaces, tabs, AND newlines. (read hits EOF looking + # for the NUL delimiter and returns nonzero after filling the array; benign.) + IFS=$', \t\n' read -r -d '' -a _PRIV <<< "$GUARD_PRIVATE_REPOS" || true + 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 [[ -z "$_ALT" ]]; then + # A value of only separators is as disabled as no value at all. + _no_private_repo_config + else + # Both orders: name-then-detail and detail-then-name. Case-insensitivity is + # scoped to the REPO NAME alternation only — (?i:...) not a global (?i) — so + # OPS_DETAIL keeps its deliberate SCREAMING_CASE requirement. A global flag + # made lowercase prose like "adds the api_key plumbing" count as operational + # detail, and a gate that blocks harmless text gets switched off. + # + # No \b in front of OPS_DETAIL in either order: underscore is a word + # character, so [A-Z][A-Z0-9]*_ anchored at a word boundary can never reach + # the final segment of a multi-part name like WAVE_VIEWPORT_LEASE_SECRET + # (the only \b is before WAVE, and [A-Z0-9]* cannot cross underscores). A + # leading \b silently limited the name-then-detail order to two-segment + # credential names while the reverse order matched all of them. + 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' \ + about-ok + 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/content-policy.sh b/scripts/public-repo-guard/content-policy.sh index 4f67fb8..40ac2f0 100755 --- a/scripts/public-repo-guard/content-policy.sh +++ b/scripts/public-repo-guard/content-policy.sh @@ -97,16 +97,41 @@ check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' # Private WAVE repo/product names that must never appear in a public tree. The # names are NOT hardcoded here (this file is itself public) — they are supplied # at run time via GUARD_PRIVATE_REPOS (CI injects it from an org-level Actions -# variable), comma- or space-separated. Unset locally → this check is skipped. -if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then - IFS=', ' read -r -a _PRIV <<< "$GUARD_PRIVATE_REPOS" +# variable), comma-, space-, or newline-separated. What an EMPTY value means +# depends on where we are — same fail-closed semantics as body-policy.sh. In CI +# the workflow always injects the env line, so empty means the org variable is +# unset, renamed, or not visible to the run: that is a misconfiguration, and +# reporting "content policy OK" with this rule silently disabled would turn the +# required check into a rubber stamp. Locally the check is skipped, with a +# visible notice, because developers have no reason to carry the org's list. +_no_private_repo_config() { + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::error title=public-repo-guard (private-repo)::GUARD_PRIVATE_REPOS is empty (the org variable is unset, renamed, or not visible to this run). Refusing to report a pass with the private-repo rule silently disabled." + exit 2 + fi + echo "content-policy: GUARD_PRIVATE_REPOS unset; private-repo rule skipped (local run only)" +} +if [[ -z "${GUARD_PRIVATE_REPOS:-}" ]]; then + _no_private_repo_config +else + # `-d ''` reads the WHOLE value, not just its first line: a plain `read` stops + # at the first newline, so a newline-separated Actions variable (the natural + # shape for a multi-line list) silently dropped every name after the first. + # Same parse as body-policy.sh, so both gates agree on the same org variable. + # (read hits EOF looking for the NUL delimiter and returns nonzero after + # filling the array; benign.) + IFS=$', \t\n' read -r -d '' -a _PRIV <<< "$GUARD_PRIVATE_REPOS" || true + _SCANNED=0 for _name in "${_PRIV[@]}"; do [[ -z "$_name" ]] && continue # Regex-escape the name so metacharacters in a repo name (., -, etc.) match # literally rather than changing the pattern's meaning. _esc="$(printf '%s' "$_name" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g')" check BLOCK private-repo "\\b${_esc}\\b" 'Reference to a private WAVE repo/product (configured via GUARD_PRIVATE_REPOS) — keep out of public' + _SCANNED=1 done + # A value of only separators is as disabled as no value at all. + (( _SCANNED == 0 )) && _no_private_repo_config fi # --- Credential formats gitleaks may miss in-context ------------------------- 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..0701a93 --- /dev/null +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -0,0 +1,188 @@ +#!/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 +# pinned names are deliberately SYNTHETIC: this file is world-readable and the +# tree scanners exclude scripts/public-repo-guard/**, so a real private-repo name +# written here would be published unscanned — the exact leak the gate exists to +# block. Never swap these for real names. +export GUARD_PRIVATE_REPOS="acme-private-gateway, acme-private-transports, acme-private-billing" + +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 --------------------------------------------------------------- +# No secondary trigger in this body: the multi-segment credential NAME is the +# only OPS_DETAIL present, so this fixture proves the credential-name branch +# itself fires in the name-then-detail order. An earlier wording also said +# "is bound on", which independently satisfied OPS_DETAIL and kept this test +# green while a leading \b silently broke the branch it exists to prove. +expect 1 'private repo + credential name' \ + 'Flip is live: acme-private-gateway now reads WAVE_VIEWPORT_LEASE_SECRET at boot.' +expect 1 'private repo + credential name, reverse order' \ + 'The MOQ_JOIN_SECRET was added; acme-private-transports picks it up on deploy.' +expect 1 'private repo + secret-binding phrase' \ + 'Flip is live: the lease secret is bound on acme-private-gateway now.' +expect 1 'private repo + secret count' \ + 'acme-private-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 acme-private-billing for settlement.' +# Case-insensitivity is scoped to the repo NAME, not the whole pattern: a cased +# variant of the name still blocks, while OPS_DETAIL stays SCREAMING_CASE-only. +expect 1 'mixed-case private repo name + credential name still blocks' \ + 'ACME-Private-Gateway reads the ROLLOUT_SECRET at boot 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' \ + 'Attaching the internal-only rollout plan for context.' +# The markers most often arrive capitalised — sentence-initial or as a shouty +# header — and a case-sensitive match missed exactly those shapes. +expect 1 'INTERNAL ONLY header blocks despite capitals' \ + 'INTERNAL ONLY: rollout plan attached below.' +expect 1 'sentence-initial Do not share blocks' \ + 'Do not share this outside the team.' +# 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 ABOUT-THE-CONTROL allowlist is scoped to prose-shaped rules only. A real +# credential is a leak even on a line that talks about the gate — "the guard +# caught " still contains the key — so naming the control must not exempt it. +expect 1 'credential still blocks on a line naming the control' \ + "public-repo-guard flagged ${AKID_FIXTURE} in the logs — see SECURITY.md." + +# --- must PASS (precision — these keep the gate deployable) ------------------- +expect 0 'bare private-repo cross-reference' \ + 'This is the companion change to acme-private-transports#260; merge that one first.' +expect 0 'two private repos, no operational detail' \ + 'Both acme-private-gateway and acme-private-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 for a global (?i) that made lowercase prose count as OPS_DETAIL: +# "api_key" is ordinary description, not a SCREAMING_CASE credential name. +expect 0 'lowercase credential-ish word near a private repo is prose' \ + 'Companion to acme-private-transports#260 — adds the api_key plumbing 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: acme-private-gateway holds EXAMPLE_SECRET — guard:allow documented-example' +expect 0 'credential on a control line passes only via explicit guard:allow' \ + "public-repo-guard docs cite ${AKID_FIXTURE} as the test fixture — guard:allow documented-fixture" +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.' +# Use-vs-mention must survive the case-insensitive marker match: a QUOTED cased +# marker is still a description, not a leak. +expect 0 'cased marker MENTIONED in quotes is a description' \ + 'The gate now also catches "INTERNAL ONLY" headers in body text.' +expect 1 'marker USED unquoted still blocks' \ + 'Attaching the internal-only rollout plan; do not share outside the team.' + +# --- configuration handling ---------------------------------------------------- +# expect_with_env +# Same contract as expect(), but runs the script under an explicit environment +# instead of the file-level export, so these cases stay hermetic no matter where +# the suite itself runs (CI exports GITHUB_ACTIONS=true; a laptop does not). +expect_with_env() { + local want="$1" name="$2" body="$3"; shift 3 + printf '%s\n' "$body" > "$TMP/body.txt" + local rc + env "$@" bash "$SCRIPT" "$TMP/body.txt" >/dev/null 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' "$name" "$want" "$rc" + fi +} + +# A newline-separated variable is the natural way to enter a multi-line Actions +# variable, and `read` without -d '' stopped at the first line, silently leaving +# every later name unguarded. The fixture leaks the THIRD name: it only blocks +# if the whole value was parsed. +expect_with_env 1 'newline-separated repo list still guards names after line one' \ + 'Flip is live: acme-private-billing now reads WAVE_VIEWPORT_LEASE_SECRET at boot.' \ + GUARD_PRIVATE_REPOS=$'acme-private-gateway\nacme-private-transports\nacme-private-billing' + +# In CI an empty GUARD_PRIVATE_REPOS is a misconfiguration, not a pass: the +# workflow always injects the env line, so empty means the org variable is gone. +# The gate must go red rather than run with its headline rule silently disabled. +expect_with_env 2 'empty GUARD_PRIVATE_REPOS in CI fails closed' \ + 'An ordinary clean body with nothing to hide.' \ + GUARD_PRIVATE_REPOS= GITHUB_ACTIONS=true +expect_with_env 2 'separator-only GUARD_PRIVATE_REPOS in CI fails closed' \ + 'An ordinary clean body with nothing to hide.' \ + GUARD_PRIVATE_REPOS=' , , ' GITHUB_ACTIONS=true + +# Locally the documented skip still applies: developers do not carry the org's +# private-repo list, and the other rules still run. +expect_with_env 0 'empty GUARD_PRIVATE_REPOS locally skips the rule, still passes' \ + 'An ordinary clean body with nothing to hide.' \ + GUARD_PRIVATE_REPOS= GITHUB_ACTIONS= +expect_with_env 1 'empty GUARD_PRIVATE_REPOS locally still enforces other rules' \ + 'Attaching the internal-only rollout plan for context.' \ + GUARD_PRIVATE_REPOS= GITHUB_ACTIONS= + +# --- 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"