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
151 changes: 146 additions & 5 deletions .github/workflows/public-repo-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ name: public-repo-guard
# wave-av/.github must not be able to alter another repo's secret scanner). The
# gitleaks binary is version-pinned AND SHA-256-verified before it runs.
#
# To install on a new repo, copy all three files together:
# To install on a new repo, copy all five files together (the workflow executes
# every one of them; omit the test file and the required check fails on its
# self-test step on every push):
# .github/workflows/public-repo-guard.yml
# .gitleaks.toml
# scripts/public-repo-guard/content-policy.sh
# scripts/public-repo-guard/body-policy.sh
# scripts/public-repo-guard/tests/body-policy.test.sh
#
# Scan scope: the published working TREE (gitleaks --no-git), NOT git history. The
# goal is "what is public right now is clean", so a shallow checkout is sufficient.
Expand All @@ -25,24 +29,53 @@ name: public-repo-guard
# path glob to a repo-root `.guardignore`, or extend the repo-local `.gitleaks.toml`.

on:
# `edited` matters as much as `opened`: a body can be made to leak long after the
# PR is first raised, and until this workflow covered it, nothing ever re-scanned.
pull_request:
types: [opened, edited, reopened, synchronize]
issues:
types: [opened, edited]
issue_comment:

@cubic-dev-ai cubic-dev-ai 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.

P2: Comment scanning is still incomplete because the workflow only subscribes to issue_comment. Public PR review comments (and review bodies) do not trigger this job, so leak-like content in those comment channels is currently outside the body-policy coverage. Expanding triggers to review comment/review events would close that gap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/public-repo-guard.yml, line 35:

<comment>Comment scanning is still incomplete because the workflow only subscribes to `issue_comment`. Public PR review comments (and review bodies) do not trigger this job, so leak-like content in those comment channels is currently outside the body-policy coverage. Expanding triggers to review comment/review events would close that gap.</comment>

<file context>
@@ -25,24 +26,44 @@ name: public-repo-guard
+    types: [opened, edited, reopened, synchronize]
+  issues:
+    types: [opened, edited]
+  issue_comment:
+    types: [created, edited]
   push:
</file context>
Fix with cubic

types: [created, edited]
Comment on lines +36 to +39

@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.

🔍 Review-comment bodies are still unscanned

issue_comment covers issue comments and top-level PR conversation comments, but NOT pull_request_review_comment (inline code-review comments) or pull_request_review (review summary bodies). Those bodies are equally world-readable and are exactly where operational detail tends to get pasted during review, so the stated gap ("an issue or comment BODY is scanned by nothing server-side") is only partly closed.

Open in Devin Review

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

push:
branches: [main, master]
workflow_dispatch:

# `pull_request`, deliberately NOT `pull_request_target`: a fork PR must never get
# a write token or repo secrets just because a gate wanted to read its body.
permissions:
contents: read

concurrency:
group: public-repo-guard-${{ github.ref }}
cancel-in-progress: true
# Concurrency is per JOB, not per workflow: the two jobs want opposite behaviour.
# A workflow-level group would force one policy on both, and it showed: rapid body
# edits cancelled the tree job over and over, and every cancelled check-run stays
# attached to the commit, so the PR reported UNSTABLE while the live runs were green.

jobs:
guard:
name: Secrets + content policy
# Skips issue/comment events (the tree scan has nothing to say about a comment,
# and the org should not pay for a gitleaks run every time anyone posts one).
# On pull_request it runs on EVERY action, `edited` included, and always runs
# the FULL scan. This job publishes the one check name branch protection
# requires, and GitHub evaluates a required check from the LATEST check run
# of that name on the commit: a partial (body-only) run would overwrite a red
# tree verdict with a green one, letting a body edit launder a failing secret
# scan. Re-scanning an unchanged tree on an edit is the price of a verdict
# that cannot be overwritten by a narrower one.
if: >-
github.event_name == 'pull_request'
|| github.event_name == 'push'
|| github.event_name == 'workflow_dispatch'
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
concurrency:
# cancel-in-progress is safe here BECAUSE every run of this job has
# identical full scope: whatever a superseded run would have said, its
# replacement re-checks in full. Nothing is left unscanned by a cancel.
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
Expand Down Expand Up @@ -71,3 +104,111 @@ 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 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
Comment on lines +112 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 The leak gate runs the pull request's own copy of its scripts, so a PR can weaken or disable the check it must pass

The guard job checks out the PR merge ref and then executes scripts/public-repo-guard/tests/body-policy.test.sh (and, in the pre-existing step, content-policy.sh) from that checkout. A pull request — including one from a fork — can modify those vendored scripts in the same PR, so the run that is supposed to gate it executes attacker-controlled code. The self-test step in particular exists to prove the gate works, but a PR that edits the fixtures makes it prove nothing. The same job also passes the org variable GUARD_PRIVATE_REPOS (the list of private WAVE repo names the gate exists to keep out of public view) into a script the PR author controls, so a modified script can print those names into the public Actions log.

Open in Devin Review

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


# A PR body is merge-blocking, so it is scanned HERE, inside the one check
# name branch protection requires. Scanning it only in a differently named
# job would let a body edit hide behind this check's stale green result.
# Same handling discipline as body-guard below: the untrusted text goes
# payload file to scratch file, never through a run: block or an env var.
- name: Materialize the untrusted PR title/body to a file
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/bodyscan"
# An UNRECOGNIZED payload shape must fail, never quietly scan nothing
# and report a pass.
if [ "$(jq -r 'has("pull_request")' "$GITHUB_EVENT_PATH")" != "true" ]; then
echo "::error title=public-repo-guard (guard)::Event payload contains no pull_request object; refusing to report a pass on an unscanned body."
exit 1
fi
jq -r '[.pull_request.title, .pull_request.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 title/body, merge-blocking)
if: github.event_name == 'pull_request'
env:
GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }}
run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt"

# The other half of a public repo's surface. `guard` above scans the published
# TREE and (on PRs) the merge-blocking title/body; an issue or 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.
#
# Honest about what it can and cannot do. Issue and comment 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. PR bodies are NOT scanned here: they are merge-blocking, so
# they belong to `guard`, the check name branch protection actually requires.
body-guard:
name: Body content policy
if: github.event_name == 'issues' || github.event_name == 'issue_comment'
concurrency:
# Keyed on the specific comment / 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.
#
# 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.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
Comment on lines +169 to +174

@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.

🔍 Fork PRs run the body gate from their own branch, so a fork can neutralize it

body-guard triggers on pull_request and checks out the PR's merge ref, then executes scripts/public-repo-guard/body-policy.sh from that checkout. A fork PR can therefore modify the gate script in the same PR and have the modified version judge its own body. The header of .github/workflows/public-repo-guard.yml:9-14 claims the gate "cannot be reprogrammed out-of-band", which is true for wave-av/.github but not for the PR branch itself. The pre-existing guard job has the same property, so this is not new, but the new job is explicitly described as PREVENTING a merge (.github/workflows/public-repo-guard.yml:109-112), which makes the assumption worth verifying. Pinning the script fetch to the base ref (e.g. checking out github.event.pull_request.base.sha for the scripts directory) would close it.

Open in Devin Review

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

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

- 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 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("issue") or has("comment")' "$GITHUB_EVENT_PATH")" != "true" ]; then
echo "::error title=public-repo-guard (body-guard)::Event payload contains no issue/comment object; refusing to report a pass on an unscanned body."
exit 1
fi
# Scan only the text THIS event delivered. On issue_comment the
# payload's .issue is the surrounding issue (or PULL REQUEST, whose
# .issue.title/.issue.body are the PR's own description), so scanning
# .issue.* on comment events would re-scan a PR body on every comment
# (duplicating the merge-blocking scan in `guard`) and, because
# issue/comment events run against the DEFAULT branch head, pin any
# failure to main rather than the PR. Nothing is left unscanned by
# the narrowing: every comment gets its own created/edited event, and
# issue bodies get theirs via issues opened/edited.
jq -r 'if has("comment")
then [.comment.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 (issue / comment text)
env:
GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }}
run: bash scripts/public-repo-guard/body-policy.sh "$RUNNER_TEMP/bodyscan/body.txt"
Loading
Loading