From 7960247e0b5c7a48a74f2bbd520091fa42471bf5 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Tue, 25 Aug 2026 23:48:56 -0700 Subject: [PATCH] feat(public-repo-hygiene): scan the PR title and description too (BE-9652) PR text is a leak surface with no guard at all. It is published on a public repo the moment it is typed, and no file scan can ever see it -- the checker walks tracked files, and a PR title and body are neither. Both motivating leaks are real and happened on a public PR of THIS repo: an internal collaboration-tool permalink pasted into a description (exposing a workspace and a channel id), and a non-public org repo named in a body. Neither is caught anywhere today. This adds PR title and description as a third surface, alongside a file's contents and (since #231) its tracked path. All three go through the one `_line_findings` matcher rather than a fork of it, for the reason #231 already gives: a second matcher is a second place to forget an allowlist entry. CATEGORIES 2 AND 3 ONLY -- NEVER CATEGORY 1 The ticket category is deliberately excluded from this surface, and that is a policy decision, not an oversight. This org's commit convention REQUIRES a `(BE-####)` Linear suffix on the PR title, so a ticket id in a public PR title is deliberate org-wide practice rather than a leak: half of this repo's own recent merged PR titles carry one. Scanning for it here would fail roughly every second PR on every enrolled repo, and a required check that fires on CORRECT behaviour does not get fixed -- it gets switched off, taking the two categories that catch real leaks with it. A test pins this, in both directions: the same string is still flagged in a tracked file. TWO PROPERTIES THAT ARE SECURITY, NOT STYLE Read from the EVENT, never from a workflow input. A caller that could supply the text being judged could supply different text -- the same reasoning that loads the checker from `workflows_ref` rather than the caller's checkout. Passed to the checker as FILE PATHS, never as argv values, and interpolated only into an `env:` value, never into a `run:` body. A PR title and body are attacker-controlled strings; `${{ ... }}` inside a shell script is textual substitution before the shell sees it, so `$(...)` in a PR title would execute on the runner. The workflow writes them from `env:` to RUNNER_TEMP -- outside the scanned tree -- and passes `--scan-text 'LABEL=PATH'`. Surface findings are reported BEFORE file findings so `MAX_FINDINGS_TOTAL` cannot truncate away the highest-signal, cheapest-to-fix ones. A surface named but unreadable FAILS (exit 2) rather than scanning an empty string: a green run over text nobody read is the silent pass this checker exists to deny. TWO LIMITATIONS, DOCUMENTED RATHER THAN IMPLIED A repo named WITHOUT the org prefix is not caught on any surface -- a bare name in prose is indistinguishable from an ordinary word without a list of non-public names, and that list is exactly what cannot live in a public repo. Of the three real leaks above, this catches two. PR text is only re-scanned when the caller's `on:` includes `edited`; GitHub's default `pull_request` types do not fire on a title or body edit, so without it a PR can go green and then have a link pasted in. The reusable cannot enforce this -- `on:` belongs to the caller and a workflow cannot read the trigger types it was configured with -- so the caller guide now carries it, with the `types:` line marked load-bearing in the copy-pasteable example. Verified: 196 tests pass (184 upstream + 12 new), workflow-pins lint clean, actionlint reports only the two pre-existing findings on this file, and the checker was run end to end against the real leaked PR text. Co-Authored-By: Claude Opus 5 --- .github/public-repo-hygiene/README.md | 31 ++++ .../check_public_repo_hygiene.py | 169 +++++++++++++++++- .../tests/test_check_public_repo_hygiene.py | 126 +++++++++++++ .github/workflows/public-repo-hygiene.yml | 57 ++++++ docs/callers/public-repo-hygiene.md | 16 ++ 5 files changed, 393 insertions(+), 6 deletions(-) diff --git a/.github/public-repo-hygiene/README.md b/.github/public-repo-hygiene/README.md index b7a27b7..9ef696e 100644 --- a/.github/public-repo-hygiene/README.md +++ b/.github/public-repo-hygiene/README.md @@ -29,6 +29,35 @@ string is scanned in place of the file body. A regular file is read up to `MAX_F and no further, and what is *derived* from those bytes is capped too — `MAX_FINDINGS_PER_FILE` (200), `MAX_FINDINGS_TOTAL` (2000) and a `MAX_EXCERPT_CHARS` (200) bound on the echoed line, since a category-2 finding copies the matched line and the scanned repo controls how long that is. + +## Surfaces scanned + +Three surfaces, all sharing one matcher (`_line_findings`) so they can never drift apart: a +tracked file's **contents**, its tracked **path** string (BE-9399), and the **PR title and +description** (BE-9652). + +PR text is scanned when the triggering event carries a pull request. It is published the moment it +is typed and no file scan can ever see it — both leaks that motivated this were on a public PR of +`github-workflows` itself: an internal collaboration-tool permalink in a description, and a +non-public org repo named in a body. + +Three properties are deliberate: + +- **Categories 2 and 3 only — never category 1.** This org's commit convention *requires* a + `(BE-####)` Linear suffix on PR titles, so ticket ids there are org-wide practice, not a leak; + half of this repo's own recent merged PR titles carry one. Flagging them would fail roughly every + second PR, and a required check that fires on correct behaviour does not get fixed — it gets + switched off, taking the two categories that catch real leaks with it. A test pins this. +- **Read from the event, never from a workflow input.** A caller that could supply the text being + judged could supply *different* text. Same reasoning as loading this checker from `workflows_ref` + rather than the caller's checkout. +- **Passed to the checker as file paths, never as argv values.** PR text is unbounded, + author-controlled and full of shell metacharacters; the workflow writes it from `env:` to + `RUNNER_TEMP` and passes `--scan-text '