Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions .github/workflows/public-repo-guard-body.yml
Original file line number Diff line number Diff line change
@@ -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]
Comment on lines +57 to +60

@devin-ai-integration devin-ai-integration Bot Aug 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Body verdicts from issue/comment/review events never land on the PR head SHA

Only pull_request events (opened/edited/reopened/synchronize) produce a check run against the PR head commit. For issue_comment and issues, GITHUB_SHA is the default-branch head, and for pull_request_review/pull_request_review_comment it is the PR merge commit — so a FAILING body scan from those events is invisible to branch protection and, for issue/issue_comment, attaches a red public-repo-guard / Body content policy check run to the head of main. The header at .github/workflows/public-repo-guard-body.yml:24-27 frames comment/issue coverage as detection-only, which is consistent, but the side effect of reddening the default-branch commit status (and any tooling that reads it, e.g. release gating or merge queues) is worth confirming is acceptable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


# `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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
bash "$POLICY" "$RUNNER_TEMP/bodyscan/body.txt"
58 changes: 51 additions & 7 deletions .github/workflows/public-repo-guard.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
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
# fully reviewable, deterministic, and cannot be reprogrammed out-of-band (a push to
# 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.
Expand All @@ -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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

# gitleaks' GitHub Action requires a paid license for organizations; the CLI
# itself is MIT-licensed and free. Pin the version AND verify the release
Expand All @@ -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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Loading