diff --git a/.github/workflows/public-repo-guard-body.yml b/.github/workflows/public-repo-guard-body.yml new file mode 100644 index 0000000..5f72b67 --- /dev/null +++ b/.github/workflows/public-repo-guard-body.yml @@ -0,0 +1,159 @@ +# Same display name as public-repo-guard.yml, deliberately: the two files are one +# gate, and sharing the name keeps the check contexts reading exactly as branch +# protection requires them — `public-repo-guard / Secrets + content policy` and +# `public-repo-guard / Body content policy`. +name: public-repo-guard + +# Pre-publication content gate for WAVE public repos — the BODY half. The tree +# workflow (public-repo-guard.yml) scans the published TREE; a PR/review/issue/ +# comment BODY is just as world-readable and, until this job, was scanned by +# nothing server-side. That gap was real, not theoretical: a PR was blocked for +# naming a private repo in wrangler.toml while the very same name, with more +# operational detail attached, sat unchallenged in its body. +# +# A SEPARATE FILE from the tree scan, and the split is load-bearing, not +# cosmetic. A job disabled by `if:` still publishes a check run under its context +# name with a non-failing `skipped` conclusion, and branch protection reads the +# LATEST check run per context. When both jobs shared one workflow, every +# body-only event (a description edit, a review comment) re-triggered the whole +# file, and the skipped tree job emitted a fresh `skipped` result for the same +# head SHA — superseding a FAILING `Secrets + content policy` run and making a PR +# the secret scan had rejected mergeable. Two files mean a body event can never +# produce a check run for the tree context, and vice versa. +# +# Honest about what it can and cannot do. On a PR this PREVENTS the merge. On an +# issue or comment the text is already public the moment it posts, so this is +# detection — it tells us to go redact, fast. Only the client-side pre-write hook +# can stop that class before publication. +# +# WHERE a detection verdict lands is a side effect worth stating so nobody +# rediscovers it as a bug: only pull_request events attach the check run to the +# PR head SHA (the only place branch protection reads it). On `issues` and +# `issue_comment` events GITHUB_SHA is the DEFAULT-BRANCH head, so a failing +# scan pins a red `Body content policy` check run to the tip of main; review +# events pin it to the PR merge commit. Accepted, not accidental: detection has +# no merge to block, and with only a read token a red check on main is the +# loudest alarm this job can raise. Anything that reads main's combined status +# (release gating, merge queues) will see that red until a later body event +# passes under the same context — treat that as the alarm working, and redact. +# +# To install on a new repo, copy all six files together (list in the tree +# workflow's header). + +on: + # `edited` matters as much as `opened`: a body can be made to leak long after the + # PR is first raised, and until this workflow covered it, nothing ever re-scanned. + # `synchronize` is here because this check is REQUIRED: every new head SHA needs + # a body verdict of its own or the merge box waits forever on an expected check. + pull_request: + types: [opened, edited, reopened, synchronize] + # Review summaries and inline review comments are SEPARATE events (GitHub does + # not deliver either as `issue_comment`) and are just as world-readable, so + # without these two a reviewer could publish the same content unscanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. +permissions: + contents: read + +# No concurrency group, deliberately: a group holds at most one PENDING run, so +# under a burst of edits the queued scans in between get cancelled even with +# cancel-in-progress false, and those body versions never receive a verdict. The +# body job is seconds long; letting every event run to completion is the cheap, +# correct option. History shows why it must not share the tree job's cancelling +# group either: when the group was workflow-level, rapid body edits cancelled the +# tree job over and over, and every cancelled check-run stays attached to the +# commit, so the PR reported UNSTABLE while the live runs were green. + +jobs: + body-guard: + name: Body content policy + # No `if:` — every trigger of this file is a body event. That is not just + # tidiness: a job-level skip would emit a `skipped` check run under this + # context, the exact superseding hazard this file exists to remove. + runs-on: ubuntu-latest + steps: + # TRUSTED policy, not the PR's. On pull_request-family events the default + # checkout is the PR merge ref, so the gate would execute whatever + # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 + # (or to read what CI hands it) and this check would still report green. + # Pinning the checkout to the BASE revision means the PR under scan can + # never define the policy that judges it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted + # Only the gate's own scripts are needed; no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + # The PR's own copy, consulted in exactly ONE case: the base revision has no + # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo + # the base always has the script, so a hostile PR cannot reach this fallback + # by deleting or editing its copy; the trusted checkout above still wins. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: startsWith(github.event_name, 'pull_request') + with: + path: incoming + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + - name: Install ripgrep + run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + + # The body is read straight out of the event payload FILE and written to + # another file. It is never interpolated into a run: block and never placed + # in an environment variable, so shell metacharacters in a hostile PR body + # have nothing to act on. jq is preinstalled on the GitHub-hosted images. + - name: Materialize the untrusted title/body to a file + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bodyscan" + # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and + # report a pass. If the event schema ever moves, this job must go red + # rather than become a green rubber stamp over an unscanned body. + if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment") or has("review")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment/review object — refusing to report a pass on an unscanned body." + exit 1 + fi + # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets + # its own verdict on its own event (a PR/issue body on opened/edited, a + # review on submitted/edited, a comment on created/edited). Folding the + # parent body into every comment scan would let one bad, unedited body + # keep every later comment red regardless of its content — exactly the + # permanent-noise failure mode that gets a gate switched off. + jq -r 'if has("comment") then [.comment.body] + elif has("review") then [.review.body] + elif has("pull_request") then [.pull_request.title, .pull_request.body] + else [.issue.title, .issue.body] + end + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment / review text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + # The existence assertion turns "the sparse checkout resolved to nothing" + # into a named failure instead of bash's opaque "No such file or directory". + run: | + POLICY="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$POLICY" ]; then + if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." + POLICY="incoming/scripts/public-repo-guard/body-policy.sh" + else + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." + exit 2 + fi + fi + bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/.github/workflows/public-repo-guard.yml b/.github/workflows/public-repo-guard.yml index 719718a..beeb008 100644 --- a/.github/workflows/public-repo-guard.yml +++ b/.github/workflows/public-repo-guard.yml @@ -1,11 +1,22 @@ name: public-repo-guard -# Pre-publication content gate for WAVE public repos. Two complementary checks: +# Pre-publication content gate for WAVE public repos — the TREE half. Two +# complementary checks: # 1. gitleaks — formatted secrets (API keys, tokens, private keys) in the tree. # 2. content-policy.sh — WAVE-specific leaks gitleaks misses: live Stripe account # IDs, hardcoded Cloudflare account_ids, developer absolute paths, references # to private WAVE repos, and committed .env files. # +# The BODY half (PR/review/issue/comment text) lives in its own file, +# public-repo-guard-body.yml, and the split is load-bearing, not cosmetic. A job +# disabled by `if:` still publishes a check run under its context name with a +# non-failing `skipped` conclusion, and branch protection reads the LATEST check +# run per context. When body events triggered this file, every body edit or +# review comment made the skipped tree job emit a fresh `skipped` result for the +# same head SHA — superseding a FAILING `Secrets + content policy` run and making +# a PR the secret scan had rejected mergeable. Separate files mean a body event +# can never produce a check run for the tree context, and vice versa. +# # Self-contained by design: the gitleaks config (.gitleaks.toml) and the policy # script (scripts/public-repo-guard/content-policy.sh) are VENDORED into the repo # alongside this workflow — they are NOT fetched at run time. The gate is therefore @@ -13,10 +24,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 six files together: # .github/workflows/public-repo-guard.yml +# .github/workflows/public-repo-guard-body.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh +# scripts/public-repo-guard/body-policy.sh +# 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. @@ -26,23 +40,33 @@ name: public-repo-guard on: pull_request: + # No `edited`, deliberately: a title or body edit does not change the tree, + # and per the header it must not emit a tree check run either — a skipped run + # for an unchanged tree would supersede a failing one. Body edits are the + # body workflow's job. + types: [opened, reopened, synchronize] push: branches: [main, master] workflow_dispatch: +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its tree. permissions: contents: read -concurrency: - group: public-repo-guard-${{ github.ref }} - cancel-in-progress: true - jobs: guard: name: Secrets + content policy + # A cancelling group: a push supersedes the tree, and every trigger of this + # file is a tree event, so nothing body-shaped can cancel a tree run (the + # failure mode that plagued the old workflow-level group — see the body + # workflow's header for that history). + concurrency: + group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release @@ -67,7 +91,27 @@ jobs: - name: Install ripgrep run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + # Deliberately the PR's OWN copy of the policy, and the asymmetry with the + # body workflow's trusted-base checkout is intentional, not an oversight. + # Two reasons. First, pinning to base removes no attacker capability here: + # on pull_request events the workflow DEFINITION itself executes from the + # PR merge ref, so a PR that could gut content-policy.sh could as easily + # rewrite this file into a no-op job with the same required-check name. + # That vector is covered by review of gate-file diffs and by the push + # trigger, which re-scans main with main's own policy after merge. Second, + # unlike the body gate (which judges TEXT, so its policy version need not + # track the tree), this job judges THE TREE the policy ships in: a PR that + # legitimately updates the policy or its fixtures must be scanned and + # self-tested by the version it ships, or script and fixtures could never + # change together. - name: content policy (WAVE trade-secret / internal-leak gate) env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} 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 (the tree job) because the script + # under test is tree content and only changes when the tree does. + - name: body policy self-test (fixtures) + run: bash scripts/public-repo-guard/tests/body-policy.test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b8114f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +User-facing changes to this repository's org-wide workflow templates and vendored +guard scripts. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## Unreleased + +### Added + +- `public-repo-guard` now has a BODY gate: a second workflow file + (`public-repo-guard-body.yml`) plus `scripts/public-repo-guard/body-policy.sh` + scan pull-request titles/bodies, issue titles/bodies, comments, and review + bodies for the same leak classes the tree gate covers (credential formats, + infrastructure identifiers, internal markers, and private-repo names near + operational detail). Previously nothing scanned these world-readable surfaces + server-side. + +### Changed + +- Installing `public-repo-guard` in a repo now means vendoring six files and + requiring BOTH check names (`public-repo-guard / Secrets + content policy` + and `public-repo-guard / Body content policy`) in branch protection; the tree + check alone does not gate body edits. See `workflow-templates/README.md` for + the install and enforce steps. diff --git a/scripts/public-repo-guard/body-policy.sh b/scripts/public-repo-guard/body-policy.sh new file mode 100755 index 0000000..f68153a --- /dev/null +++ b/scripts/public-repo-guard/body-policy.sh @@ -0,0 +1,222 @@ +#!/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. The threat +# here is the ACCIDENTAL paste, which never carries the marker. A deliberate +# leaker gains nothing from it — they can already evade any regex by reshaping +# the text — and unlike an evasion, the marker is a visible, greppable string +# sitting in the world-readable body itself. It is also the only escape hatch +# that lets a security discussion quote e.g. an internal IP at all. +# PROSE rules additionally exempt lines matching the ABOUT-THE-CONTROL allowlist +# below; credential-FORMAT rules never do — a real key is a leak on any line. +set -uo pipefail + +FILE="${1:-}" +[[ -n "$FILE" && -f "$FILE" ]] || { echo "::error::body-policy: usage: body-policy.sh "; exit 2; } +command -v rg >/dev/null 2>&1 || { echo "::error::body-policy: ripgrep (rg) required"; exit 2; } +# Every rule below uses -P (PCRE2), and not every distro ships rg with it built +# in. Probe once up front so a PCRE2-less build fails with ONE clear message +# instead of an opaque "ripgrep failed (exit 2)" on every rule. +rg --pcre2-version >/dev/null 2>&1 || { echo "::error::body-policy: this ripgrep build lacks PCRE2 (-P) support — failing closed"; exit 2; } + +VIOLATIONS=0 + +# 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: PROSE rules only (the ones tagged `about-exempt` below). A credential- +# FORMAT hit is a leak no matter what else the line says — a real key next to +# the words "public-repo-guard" is still a real key — so for those rules the +# only exemption is the explicit, visible `guard:allow ` marker. +ABOUT_THE_CONTROL='(public-repo-guard|body-policy|content-policy|public-github-write-gate|\bNDA\s+(gate|guard|policy|denylist|sweep|scan|hook)\b|\bno\s+NDA\b|responsib\w*\s+disclos|SECURITY\.md)' + +# check [about-exempt] [multiline] +# multiline: scan with rg -U so the regex may span line breaks (the regex +# itself must still bound what a gap may cross — see private-repo-ops). The +# guard:allow and about-the-control filters stay LINE-scoped: they drop the +# marker-bearing line from a multi-line match, so the match blocks unless +# every reported line carries an exemption. +check() { + local sev="$1" name="$2" re="$3" why="$4"; shift 4 + local scope='' _ml=() _flag + for _flag in "$@"; do + case "$_flag" in + about-exempt) scope='about-exempt' ;; + multiline) _ml=(-U) ;; + *) echo "::error::body-policy: internal bug — unknown flag '$_flag' for rule '$name'"; exit 2 ;; + esac + done + [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } + # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes + # because its scanner broke is worse than no gate: it reports success. The same + # contract applies to every FILTER stage below: exit 1 ("nothing left") is a + # normal outcome, exit >=2 must never be read as "no matches". + local raw rc + raw="$(rg -nP ${_ml[@]+"${_ml[@]}"} --no-filename -- "$re" "$FILE" 2>/dev/null)"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) scanning rule '$name' — failing closed." + exit 2 + 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:]]')"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) applying the guard:allow filter for rule '$name' — failing closed." + exit 2 + fi + if [[ "$scope" == "about-exempt" ]]; then + matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL")"; rc=$? + if (( rc >= 2 )); then + echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) applying the about-the-control filter for rule '$name' — failing closed." + exit 2 + fi + fi + [[ -z "$matches" ]] && return 0 + local count; count="$(printf '%s\n' "$matches" | grep -c '')" + # Print the LINE NUMBER only — never the matched text. This annotation is itself + # 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' +# The BODY profile diverges from content-policy.sh's verbatim rule here, on +# purpose. Prose is full of strings a tree rarely contains: relative paths +# ("docs/home/guides/setup") and URL routes ("example.com/home/status/") both +# embed a `/home//` substring without leaking anyone's home directory. +# So the match must BEGIN a path (no word/path character before the leading +# slash — start of line, whitespace, quotes, parens all qualify) and must +# CONTINUE past the username (a real leak is `/home/alice/wave/...`; a bare +# route like `/home/status/` followed by prose stays silent). +# shellcheck disable=SC2016 # $HOME is literal guidance text +check BLOCK abs-user-path '(?` already exists as the honest, visible one. +# +# (?i), scoped to this rule only: these markers are PROSE, and real banners are +# usually capitalised ("INTERNAL ONLY", "Do Not Share"). Credential-format rules +# above stay case-exact on purpose — a key's case is part of its format. +# +# The do-not- alternative distinguishes BANNER from ORDINARY PROSE by what +# follows the verb. A banner is an imperative with no object ("DO NOT +# DISTRIBUTE", "do not share externally"); everyday scheduling prose takes an +# object or temporal clause ("do not publish until Friday", "do not share the +# link yet"). The lookahead exempts the latter — a determiner, pronoun, or +# temporal word right after the verb — because a gate that reddens routine +# release chatter is the gate that gets switched off. +check BLOCK internal-marker '(?i)(?#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. +# +# The ~140-character window is PARAGRAPH-scoped, not line-scoped. Markdown +# bodies are routinely hard-wrapped or bulleted ("- repo X\n- WAVE_..._SECRET +# rotated" is exactly the shape this rule exists to catch), so the gap may +# cross single newlines. A blank line — a paragraph break — ends proximity: +# two facts in separate paragraphs are two topics, not one wiring statement. +# This is the one rule scanned with rg -U (see check); every newline crossed +# still spends the same 140-character budget. +# +# Names are NOT hardcoded (this file is public); CI injects them via the +# GUARD_PRIVATE_REPOS variable. Unset locally → this check is skipped. +if [[ -n "${GUARD_PRIVATE_REPOS:-}" ]]; then + # The credential-name class allows `_` so the whole multi-segment identifier + # matches as ONE token. With `[A-Z0-9]*` only the final `LEASE_SECRET` of + # `WAVE_VIEWPORT_LEASE_SECRET` could match, and its start sits right after a + # `_` (a word character) — so the `\b` prepended in the name-then-detail + # alternative below could NEVER be satisfied, silently passing that order. + OPS_DETAIL='(?:[A-Z][A-Z0-9_]*_(?:SECRET|TOKEN|KEY|PASSWORD)|wrangler\s+secret|secret\s+(?:is\s+)?(?:bound|binding|list)|(?:is\s+)?bound\s+on|service\s+binding|\d{2,}\s+secrets)' + _ALT='' + IFS=', ' read -r -a _PRIV <<< "$GUARD_PRIVATE_REPOS" + for _name in "${_PRIV[@]}"; do + [[ -z "$_name" ]] && continue + # Regex-escape so metacharacters in a name match literally. + _esc="$(printf '%s' "$_name" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g')" + _ALT="${_ALT:+$_ALT|}${_esc}" + done + if [[ -n "$_ALT" ]]; then + # The proximity gap: any character INSIDE a paragraph. A newline is allowed + # only when not followed by another (possibly whitespace-padded) blank line, + # so hard wraps and bullets stay in scope while a paragraph break resets it. + # `\r` is part of the padding class: GitHub delivers PR/issue/comment bodies + # CRLF-terminated, so a real paragraph break arrives as `\r\n\r\n` — without + # `\r` the lookahead never sees the second newline and a clean two-paragraph + # body is misread as one wiring statement. + _GAP='(?:[^\n]|\n(?![ \t\r]*\n)){0,140}?' + # Both orders: name-then-detail and detail-then-name. Case-insensitivity is + # scoped to the repo-NAME alternation only — a bare `(?i)` prefix would bleed + # into OPS_DETAIL and make its SCREAMING_CASE credential pattern match + # everyday lowercase prose like "api_key", blocking legitimate bodies. + check BLOCK private-repo-ops \ + "\\b(?i:${_ALT})\\b${_GAP}\\b${OPS_DETAIL}|${OPS_DETAIL}${_GAP}\\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-exempt multiline + fi +fi + +if (( VIOLATIONS > 0 )); then + echo "::error::public-repo-guard: $VIOLATIONS blocking body-policy violation(s) — see annotations above." + exit 1 +fi +echo "public-repo-guard: body policy OK" diff --git a/scripts/public-repo-guard/tests/body-policy.test.sh b/scripts/public-repo-guard/tests/body-policy.test.sh new file mode 100755 index 0000000..2c9b447 --- /dev/null +++ b/scripts/public-repo-guard/tests/body-policy.test.sh @@ -0,0 +1,181 @@ +#!/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 FICTITIOUS: this file is public, and it sits in +# the one path NEITHER gate scans (content-policy.sh excludes +# scripts/public-repo-guard/ and .gitleaks.toml allowlists it), so a real +# private-repo name written here would be published with nothing able to flag +# it, the exact violation of body-policy.sh's "names are NOT hardcoded" rule. +export GUARD_PRIVATE_REPOS="fixture-repo-alpha, fixture-repo-beta, fixture-repo-gamma" + +PASS=0; FAIL=0 + +# expect +expect() { + local want="$1" name="$2" body="$3" out rc + printf '%s\n' "$body" > "$TMP/body.txt" + out="$(bash "$SCRIPT" "$TMP/body.txt" 2>&1)"; rc=$? + if [[ "$rc" == "$want" ]]; then + PASS=$((PASS+1)); printf ' ok %s\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit %s, got %s\n%s\n' "$name" "$want" "$rc" "$out" + fi + # The annotation is world-readable; a hit must never echo the matched text. + if [[ "$rc" == 1 ]] && printf '%s' "$out" | grep -qF "$body"; then + FAIL=$((FAIL+1)); printf ' FAIL %s — LEAKED the matched text into the annotation\n' "$name" + fi +} + +echo "body-policy fixtures" + +# --- must BLOCK --------------------------------------------------------------- +expect 1 'private repo + credential name' \ + 'Flip is live: WAVE_VIEWPORT_LEASE_SECRET is bound on fixture-repo-alpha now.' +expect 1 'private repo + credential name, reverse order' \ + 'The MOQ_JOIN_SECRET was added; fixture-repo-beta picks it up on deploy.' +# Regression: the credential-name class once excluded `_`, so `\b` could only +# anchor mid-identifier (before `LEASE_SECRET`, right after a `_` — never a word +# boundary) and the name-then-detail order sailed through unflagged. +expect 1 'private repo THEN multi-segment credential name' \ + 'fixture-repo-alpha now stores WAVE_VIEWPORT_LEASE_SECRET.' +expect 1 'private repo + secret count' \ + 'fixture-repo-alpha went from 74 secrets to 75 after this change.' +expect 1 'private repo + service binding' \ + 'This adds a service binding from the worker to fixture-repo-gamma for settlement.' +# Regression: the proximity gap was once [^\n]-only while rg matched per line, +# so a hard-wrapped or bulleted body — the most common Markdown shape — put the +# repo name and the credential name on different lines and NEVER tripped the rule. +expect 1 'private repo + credential name across a hard wrap' \ + 'Rotation notes: +- fixture-repo-alpha +- WAVE_VIEWPORT_LEASE_SECRET rotated today' +expect 1 'operator home path' \ + 'Repro: run it from /Users/someoperator/Documents/notes and it fails.' # enforce-ignore (fixture) +expect 1 'operator linux home path' \ + 'The crash log sits at /home/someoperator/wave/edge.log on my machine.' # enforce-ignore (fixture) +expect 1 'internal-only marker' \ + 'Attaching the internal-only rollout plan for context.' +# Regression: the marker rule was once case-sensitive, so a capitalised banner — +# the way these warnings are actually written — sailed through unflagged. +expect 1 'internal-only marker, capitalised banner' \ + 'INTERNAL ONLY: Do Not Share 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.' +# Regression: the about-the-control allowlist once applied to EVERY rule, so a +# credential-shaped hit vanished if the same line happened to name the gate. +expect 1 'credential leak is NOT exempted by naming the control' \ + "public-repo-guard missed this: ${AKID_FIXTURE} showed up 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 fixture-repo-beta#260; merge that one first.' +expect 0 'two private repos, no operational detail' \ + 'Both fixture-repo-alpha and fixture-repo-beta will need a follow-up for this.' +expect 0 'credential NAME with no private repo nearby' \ + 'The handler now reads SOME_API_TOKEN from the environment instead of a literal.' +# The other half of the paragraph-scoped gap: a blank line is a topic boundary. +# Two facts in separate paragraphs are not one wiring statement, even within +# 140 characters — without this, any body that mentions a private repo and, +# paragraphs later, any credential NAME would block. +expect 0 'private repo and credential name in separate paragraphs' \ + 'Companion change to fixture-repo-alpha#41; merge that one first. + +Unrelated: WAVE_VIEWPORT_LEASE_SECRET is now read from the env template.' +# Regression: the paragraph-break lookahead once padded with [ \t] only, so a +# CRLF blank line (\r\n\r\n — the shape GitHub event payloads actually deliver) +# never matched, the newline was consumed as gap, and this clean two-paragraph +# body blocked. The hard-wrap twin proves CRLF wraps still stay in scope. +expect 0 'separate paragraphs with CRLF endings (GitHub payload shape)' \ + $'Companion change to fixture-repo-alpha#41; merge that one first.\r\n\r\nUnrelated: WAVE_VIEWPORT_LEASE_SECRET is now read from the env template.\r' +expect 1 'hard wrap with CRLF endings still blocks' \ + $'Rotation notes:\r\n- fixture-repo-alpha\r\n- WAVE_VIEWPORT_LEASE_SECRET rotated today\r' +# Regression: a bare (?i) prefix once made the SCREAMING_CASE credential pattern +# case-blind, so lowercase "api_key" near a private repo name blocked the body. +expect 0 'lowercase credential-ish word near a private repo' \ + 'Companion to fixture-repo-alpha#12: fixes the api_key parsing bug in the client.' +expect 0 'public runner path is not an operator path' \ + 'CI checks out to /home/runner/work/repo/repo before the scan runs.' # enforce-ignore (fixture) +# Regression: the home-path rule was once unanchored, so the `/home/guides/` +# SUBSTRING of a relative docs path — common in prose, rare in a tree — blocked +# the body, and the redacted annotation gave the author nothing to fix. +expect 0 'relative docs path is not a home dir' \ + 'See docs/home/guides/setup for details on the rollout.' +expect 0 'URL route starting with /home/ is not a home dir' \ + 'The endpoint /home/status/ returns 200 once the worker is warm.' +# Regression: the do-not- alternative once fired on everyday scheduling +# prose; an object or temporal clause after the verb marks it as prose, not a +# banner. +expect 0 'scheduling prose with a do-not verb' \ + 'Please do not publish until Friday; the announcement is still in review.' +expect 0 'do-not verb with an ordinary object' \ + 'For now, do not share the link outside this thread.' +expect 1 'bare do-not banner still blocks' \ + 'DO NOT DISTRIBUTE' +expect 0 'talking about the control' \ + 'body-policy blocks a private repo named next to a SECRET_TOKEN; that is intended.' +# Pins the DELIBERATE trade for the proximity rule: it is about-exempt, so a line +# that names the gate is dropped even when it also carries a private repo name +# next to a credential name. Without this the gate blocks its own PRs — every +# body describing the rule has to write exactly this shape. The exemption is +# line-scoped; the same leak on a line that does NOT name a control still blocks +# (the fixtures above prove that half). +expect 0 'proximity hit on a line naming the control is exempt' \ + 'body-policy should flag fixture-repo-alpha next to WAVE_VIEWPORT_LEASE_SECRET.' +expect 0 'explicit guard:allow with a reason' \ + 'Example for the docs: fixture-repo-alpha holds EXAMPLE_SECRET — guard:allow documented-example' +expect 0 'ordinary clean body' \ + 'Bumps the draft revision and regenerates the fixtures. No behaviour change.' +# Regression: the first CI run of this job failed on its own PR, because a review +# 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 0 'capitalised marker MENTIONED in quotes is still a description' \ + 'The rule now also catches an "INTERNAL ONLY" banner regardless of case.' +expect 1 'marker USED unquoted still blocks' \ + 'Attaching the internal-only rollout plan; do not share outside the team.' + +# --- fail closed -------------------------------------------------------------- +# Invoked directly, not through expect(): expect() always materializes a file, so +# it cannot reach these paths. A gate that returns "OK" when it was handed nothing +# to scan is the failure mode this whole file exists to prevent. +for case in "no argument at all::" "nonexistent path::$TMP/does-not-exist.txt"; do + name="${case%%::*}"; arg="${case##*::}" + if [[ -n "$arg" ]]; then bash "$SCRIPT" "$arg" >/dev/null 2>&1; else bash "$SCRIPT" >/dev/null 2>&1; fi + rc=$? + if [[ "$rc" == 2 ]]; then + PASS=$((PASS+1)); printf ' ok %s → exit 2 (fails closed)\n' "$name" + else + FAIL=$((FAIL+1)); printf ' FAIL %s — want exit 2, got %s\n' "$name" "$rc" + fi +done + +echo " ---" +if (( FAIL > 0 )); then + echo " $PASS passed, $FAIL FAILED"; exit 1 +fi +echo " $PASS passed, 0 failed" diff --git a/workflow-templates/README.md b/workflow-templates/README.md index b961805..a136b6c 100644 --- a/workflow-templates/README.md +++ b/workflow-templates/README.md @@ -20,7 +20,7 @@ or use GitHub's "New workflow" UI and pick the WAVE template. **For:** **every** WAVE public repo. This is the standing pre-publication gate. -**What it does:** two complementary checks on each PR / push — +**What it does:** three complementary checks — 1. **gitleaks** — formatted secrets (API keys, tokens, private keys), using the canonical `.gitleaks.toml` at the root of this repo (placeholders / fixtures allowlisted). @@ -28,10 +28,15 @@ or use GitHub's "New workflow" UI and pick the WAVE template. live Stripe account IDs (`acct_…`), hardcoded Cloudflare `account_id`s, developer absolute paths (`/Users/…`), references to private WAVE repos, and committed `.env` files. +3. **body-policy.sh** — the same leak classes in PR / review / issue / comment + TEXT, which is equally world-readable and was previously scanned by nothing + server-side. On a PR it blocks the merge; on an issue, comment, or review it + detects so the text can be redacted fast. -Both the config and the script are fetched from `wave-av/.github` at run time, so -the rules live in exactly one place across the org. A repo may ship its own -`.gitleaks.toml` to extend the rules locally. +The config and the scripts are VENDORED into each repo alongside the workflow — +they are NOT fetched at run time, so the gate is fully reviewable and cannot be +reprogrammed out-of-band. A repo may extend its local `.gitleaks.toml` to add +rules. **Private-repo name denylist:** the list of private repo/product names to block is *not* baked into the (public) script. Set an org-level Actions **variable** @@ -41,9 +46,26 @@ check is skipped when the variable is empty. **Allowlisting:** annotate a verified-safe line with `# guard:allow `, or add a path glob to a `.guardignore` at the repo root. -**Install + enforce:** copy `public-repo-guard.yml` into `.github/workflows/` -(or use the "New workflow" UI), then add `public-repo-guard / Secrets + content -policy` to the branch's required status checks so it blocks merges. +**Install + enforce:** copy the six files the workflow's header lists — +`public-repo-guard.yml` AND `public-repo-guard-body.yml` into +`.github/workflows/`, plus `.gitleaks.toml`, +`scripts/public-repo-guard/content-policy.sh`, +`scripts/public-repo-guard/body-policy.sh`, and +`scripts/public-repo-guard/tests/body-policy.test.sh` — then add BOTH check +names, `public-repo-guard / Secrets + content policy` and +`public-repo-guard / Body content policy`, to the branch's required status +checks. The tree check alone does not gate body edits: those only trigger the +body workflow, so without the second required check a failing body scan leaves +the PR mergeable. + +The two workflow FILES are deliberate, not an accident of packaging. A job +disabled by `if:` still publishes a check run with a non-failing `skipped` +conclusion, and branch protection reads the latest check run per name — so if +body events triggered the tree scan's file, a description edit or review +comment would let a skipped tree job supersede a FAILING +`Secrets + content policy` result on the same commit. Separate files mean a +body event can never emit a check run for the tree context. Install both or +neither; do not merge them back into one file. ## How to add a new template diff --git a/workflow-templates/public-repo-guard-body.properties.json b/workflow-templates/public-repo-guard-body.properties.json new file mode 100644 index 0000000..16c1c98 --- /dev/null +++ b/workflow-templates/public-repo-guard-body.properties.json @@ -0,0 +1,7 @@ +{ + "name": "Public-repo guard (body content policy)", + "description": "Companion to the public-repo guard tree scan: runs the WAVE body-policy script over PR titles/bodies, review bodies, and issue/comment text, which are equally world-readable. Deliberately a separate workflow file so body-only events never emit a check run for the tree context (a skipped run would supersede a failing required check). Install alongside public-repo-guard.yml on every public repo.", + "iconName": "octicon shield-lock", + "categories": ["Continuous integration", "Security", "WAVE"], + "filePatterns": [".*"] +} diff --git a/workflow-templates/public-repo-guard-body.yml b/workflow-templates/public-repo-guard-body.yml new file mode 100644 index 0000000..d57062f --- /dev/null +++ b/workflow-templates/public-repo-guard-body.yml @@ -0,0 +1,148 @@ +# Same display name as public-repo-guard.yml, deliberately: the two files are one +# gate, and sharing the name keeps the check contexts reading exactly as branch +# protection requires them — `public-repo-guard / Secrets + content policy` and +# `public-repo-guard / Body content policy`. +name: public-repo-guard + +# Pre-publication content gate for WAVE public repos — the BODY half. The tree +# workflow (public-repo-guard.yml) scans the published TREE; a PR/review/issue/ +# comment BODY is just as world-readable and, until this job, was scanned by +# nothing server-side. That gap was real, not theoretical: a PR was blocked for +# naming a private repo in wrangler.toml while the very same name, with more +# operational detail attached, sat unchallenged in its body. +# +# A SEPARATE FILE from the tree scan, and the split is load-bearing, not +# cosmetic. A job disabled by `if:` still publishes a check run under its context +# name with a non-failing `skipped` conclusion, and branch protection reads the +# LATEST check run per context. When both jobs shared one workflow, every +# body-only event (a description edit, a review comment) re-triggered the whole +# file, and the skipped tree job emitted a fresh `skipped` result for the same +# head SHA — superseding a FAILING `Secrets + content policy` run and making a PR +# the secret scan had rejected mergeable. Two files mean a body event can never +# produce a check run for the tree context, and vice versa. +# +# Honest about what it can and cannot do. On a PR this PREVENTS the merge. On an +# issue or comment the text is already public the moment it posts, so this is +# detection — it tells us to go redact, fast. Only the client-side pre-write hook +# can stop that class before publication. +# +# To install on a new repo, copy all six files together (list in the tree +# workflow's header). + +on: + # `edited` matters as much as `opened`: a body can be made to leak long after the + # PR is first raised, and until this workflow covered it, nothing ever re-scanned. + # `synchronize` is here because this check is REQUIRED: every new head SHA needs + # a body verdict of its own or the merge box waits forever on an expected check. + pull_request: + types: [opened, edited, reopened, synchronize] + # Review summaries and inline review comments are SEPARATE events (GitHub does + # not deliver either as `issue_comment`) and are just as world-readable, so + # without these two a reviewer could publish the same content unscanned. + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its body. +permissions: + contents: read + +# No concurrency group, deliberately: a group holds at most one PENDING run, so +# under a burst of edits the queued scans in between get cancelled even with +# cancel-in-progress false, and those body versions never receive a verdict. The +# body job is seconds long; letting every event run to completion is the cheap, +# correct option. History shows why it must not share the tree job's cancelling +# group either: when the group was workflow-level, rapid body edits cancelled the +# tree job over and over, and every cancelled check-run stays attached to the +# commit, so the PR reported UNSTABLE while the live runs were green. + +jobs: + body-guard: + name: Body content policy + # No `if:` — every trigger of this file is a body event. That is not just + # tidiness: a job-level skip would emit a `skipped` check run under this + # context, the exact superseding hazard this file exists to remove. + runs-on: ubuntu-latest + steps: + # TRUSTED policy, not the PR's. On pull_request-family events the default + # checkout is the PR merge ref, so the gate would execute whatever + # body-policy.sh the PR itself ships: a contributor could edit it to exit 0 + # (or to read what CI hands it) and this check would still report green. + # Pinning the checkout to the BASE revision means the PR under scan can + # never define the policy that judges it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted + # Only the gate's own scripts are needed; no reason to pay for the whole + # tree on every comment. + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + # The PR's own copy, consulted in exactly ONE case: the base revision has no + # body policy yet, i.e. the PR that INSTALLS the gate. In an installed repo + # the base always has the script, so a hostile PR cannot reach this fallback + # by deleting or editing its copy; the trusted checkout above still wins. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: startsWith(github.event_name, 'pull_request') + with: + path: incoming + sparse-checkout: scripts/public-repo-guard + sparse-checkout-cone-mode: false + + - name: Install ripgrep + run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + + # The body is read straight out of the event payload FILE and written to + # another file. It is never interpolated into a run: block and never placed + # in an environment variable, so shell metacharacters in a hostile PR body + # have nothing to act on. jq is preinstalled on the GitHub-hosted images. + - name: Materialize the untrusted title/body to a file + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bodyscan" + # An UNRECOGNIZED payload shape must fail, never quietly scan nothing and + # report a pass. If the event schema ever moves, this job must go red + # rather than become a green rubber stamp over an unscanned body. + if [ "$(jq -r 'has("pull_request") or has("issue") or has("comment") or has("review")' "$GITHUB_EVENT_PATH")" != "true" ]; then + echo "::error title=public-repo-guard (body-guard)::Event payload contains no pull_request/issue/comment/review object — refusing to report a pass on an unscanned body." + exit 1 + fi + # Scan WHAT THIS EVENT CHANGED, not the whole thread. Each surface gets + # its own verdict on its own event (a PR/issue body on opened/edited, a + # review on submitted/edited, a comment on created/edited). Folding the + # parent body into every comment scan would let one bad, unedited body + # keep every later comment red regardless of its content — exactly the + # permanent-noise failure mode that gets a gate switched off. + jq -r 'if has("comment") then [.comment.body] + elif has("review") then [.review.body] + elif has("pull_request") then [.pull_request.title, .pull_request.body] + else [.issue.title, .issue.body] + end + | map(select(. != null)) | join("\n")' \ + "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/bodyscan/body.txt" + echo "scanning $(wc -l < "$RUNNER_TEMP/bodyscan/body.txt") line(s) of body text" + + - name: body policy (PR / issue / comment / review text) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + # The existence assertion turns "the sparse checkout resolved to nothing" + # into a named failure instead of bash's opaque "No such file or directory". + run: | + POLICY="trusted/scripts/public-repo-guard/body-policy.sh" + if [ ! -f "$POLICY" ]; then + if [ -f "incoming/scripts/public-repo-guard/body-policy.sh" ]; then + echo "::warning title=public-repo-guard (body-guard)::base revision has no body policy; running the copy this PR installs. Tamper protection begins once it merges." + POLICY="incoming/scripts/public-repo-guard/body-policy.sh" + else + echo "::error title=public-repo-guard (body-guard)::body-policy.sh missing from the trusted checkout; failing closed." + exit 2 + fi + fi + bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt" diff --git a/workflow-templates/public-repo-guard.yml b/workflow-templates/public-repo-guard.yml index 719718a..ecc4a69 100644 --- a/workflow-templates/public-repo-guard.yml +++ b/workflow-templates/public-repo-guard.yml @@ -1,11 +1,22 @@ name: public-repo-guard -# Pre-publication content gate for WAVE public repos. Two complementary checks: +# Pre-publication content gate for WAVE public repos — the TREE half. Two +# complementary checks: # 1. gitleaks — formatted secrets (API keys, tokens, private keys) in the tree. # 2. content-policy.sh — WAVE-specific leaks gitleaks misses: live Stripe account # IDs, hardcoded Cloudflare account_ids, developer absolute paths, references # to private WAVE repos, and committed .env files. # +# The BODY half (PR/review/issue/comment text) lives in its own file, +# public-repo-guard-body.yml, and the split is load-bearing, not cosmetic. A job +# disabled by `if:` still publishes a check run under its context name with a +# non-failing `skipped` conclusion, and branch protection reads the LATEST check +# run per context. When body events triggered this file, every body edit or +# review comment made the skipped tree job emit a fresh `skipped` result for the +# same head SHA — superseding a FAILING `Secrets + content policy` run and making +# a PR the secret scan had rejected mergeable. Separate files mean a body event +# can never produce a check run for the tree context, and vice versa. +# # Self-contained by design: the gitleaks config (.gitleaks.toml) and the policy # script (scripts/public-repo-guard/content-policy.sh) are VENDORED into the repo # alongside this workflow — they are NOT fetched at run time. The gate is therefore @@ -13,10 +24,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 six files together: # .github/workflows/public-repo-guard.yml +# .github/workflows/public-repo-guard-body.yml # .gitleaks.toml # scripts/public-repo-guard/content-policy.sh +# scripts/public-repo-guard/body-policy.sh +# 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. @@ -26,23 +40,33 @@ name: public-repo-guard on: pull_request: + # No `edited`, deliberately: a title or body edit does not change the tree, + # and per the header it must not emit a tree check run either — a skipped run + # for an unchanged tree would supersede a failing one. Body edits are the + # body workflow's job. + types: [opened, reopened, synchronize] push: branches: [main, master] workflow_dispatch: +# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get +# a write token or repo secrets just because a gate wanted to read its tree. permissions: contents: read -concurrency: - group: public-repo-guard-${{ github.ref }} - cancel-in-progress: true - jobs: guard: name: Secrets + content policy + # A cancelling group: a push supersedes the tree, and every trigger of this + # file is a tree event, so nothing body-shaped can cancel a tree run (the + # failure mode that plagued the old workflow-level group — see the body + # workflow's header for that history). + concurrency: + group: public-repo-guard-tree-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # gitleaks' GitHub Action requires a paid license for organizations; the CLI # itself is MIT-licensed and free. Pin the version AND verify the release @@ -71,3 +95,10 @@ jobs: env: GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} run: bash scripts/public-repo-guard/content-policy.sh . + + # The body gate's own fixtures. Its negatives are the load-bearing half — a + # leak gate that blocks legitimate cross-repo references gets switched off, + # and then it protects nothing. Runs here (the tree job) because the script + # under test is tree content and only changes when the tree does. + - name: body policy self-test (fixtures) + run: bash scripts/public-repo-guard/tests/body-policy.test.sh