fix(security): make the server GitHub writer guard fail closed and refuse redacted identity fields (PEN-3391) - #1993
Conversation
…fuse redacted identity fields (PEN-3391) Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
… what it assumes (PEN-3391) Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (applied directly; nested CLI not launched under opencode_k8s).
Reviewed head: f74f6c6
The refusal-over-redaction change is well-argued and the fail-closed inversion is the right direction. One hole in the new predicate, verified by execution rather than by reading.
Critical Issues (0)
Important Issues (1)
-
[gstack/review + native-codex]
server/src/__tests__/helpers/github-writer-derivation.ts:89—METHOD_WORDis case-sensitive (/\bmethod\b/g) whileSAFE_READ_METHODis case-insensitive (/…/gi). BecausedeclaresOnlySafeReadMethodsdecides by counting (safe === mentions), a capitalisedMethod: "GET"anywhere in a file — a comment, a JSDoc line, a log string — contributes +1 safe and +0 mentions. That surplus credit cancels one genuine unpinned write elsewhere in the same file, and the file is classified read-only, so the scrub guard never checks it. This defeats the fail-closed property the helper is built around.Executed against the PR's own functions at this head:
import { ghFetch } from "./github-fetch.js"; /** Method: "GET" is the default. */ await ghFetch(url, { method: verb, headers, body });→
isSuspectedGitHubWriter= false. Deleting only the comment line flips it to true, which isolates the decoy as the cause.This branch is also entirely unexercised by the real tree: running the derivation over
server/srcat this head gives 9 candidates, and all 7 reads havementions=0, soSAFE_READ_METHODcurrently matches zero files. The counting path carrying the hole is reachable only from the synthetic suite, so nothing in-tree would surface it.- Make both regexes agree on case:
const METHOD_WORD = /\bmethod\b/gi;. Worth a case for it ingithub-writer-derivation.test.tsalongside the existingMISSED_BY_OLDshapes — the suite's "safe verb does not excuse an unpinned one" test uses matched case and so passes either way.
- Make both regexes agree on case:
Suggestions (3)
- [pr-review-toolkit/comments]
server/src/__tests__/helpers/github-writer-derivation.ts:56— the comment says a file that "names a variablemethodName" is classified as a writer. It is not:\bmethod\brequires a word boundary, somethodNamenever matches and such a file stays read-only. The"method" in a commenthalf of the same sentence is correct. Since this sentence is the stated justification for accepting the false-positive cost, it is worth being exact. - [gstack/review]
server/src/__tests__/helpers/github-writer-derivation.ts:148—/from\s*"[^"]*__tests__\//matches double-quoted static imports only. That is the same narrowness the PR rejects three files away ("Prettier is a formatter, not a security control"), applied to the check that keeps the__tests__/exclusion sound. It is correct today — the one production file mentioning__tests__/(services/plugin-host-services.ts:1402) does so in a comment, not an import — so this is consistency, not a live gap. Widening to any quote style and toimport(would cost one character class. - [pr-review-toolkit/code]
server/src/services/github-app-auth.ts:1233— two consecutive blank lines aftergitHubIdentityFieldRedaction. I could not confirm from the sparse checkout whether a format check enforces this, so treat it as cosmetic unless CI says otherwise.
Strengths
- Refusing identity fields instead of redacting them is the correct call, and the reasoning is the strong part: a redacted
contextsubstitutes a different identity rather than degrading the same one, so branch protection waits forever on a name that will never arrive. Non-retryable is right too — the scrub is deterministic, so a retry cannot converge. OLD_PREDICATEkept verbatim as a fixture, with every widened shape asserted to fail under it first, makes "the widening is real" a measurement rather than a claim in a comment. That is the part most such PRs omit.- The residual gap (a
RequestInitassembled in another file) is recorded as an executable assertion rather than a caveat in prose, so it cannot rot into an unstated assumption. - The "ambiguous mention is classified as a writer" test deliberately pins the accepted false-positive direction, so anyone removing the noise has to delete a test that explains why it exists.
- Extracting the duplicated derivation is the right fix for the actual PEN-3157 failure mode — the two copies were widened by hand in lockstep once already.
- Verified independently: the exhaustiveness claim holds exactly. 9 files reference
ghFetchunderserver/src;methodappears in precisely the 2 that write; both carryscrubOutboundGitHubText.postPendingStatus's new early return also matches its declared{ ok: true } | { ok: false; reason: string }.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
CI note, not a finding: verify and General tests (workspaces-b) are red at this head, but both are runner-pool evictions rather than test failures — the verify lane says so verbatim ("KILLED MID-JOB by the CI runner pool, not failed … see BLO-28999"), and the workspaces-b log ends at The runner has received a shutdown signal during action download, before any test ran. Re-run the jobs rather than pushing; a push would move the head. Note this also means the new suites have not yet produced a passing verdict anywhere.
Ally found that METHOD_WORD was case-sensitive while SAFE_READ_METHOD is case-insensitive. declaresOnlySafeReadMethods decides by counting safe literals against mentions, so a capitalised `Method: "GET"` in a comment added one safe hit and no mention, cancelling a genuine unpinned write in the same file and classifying it read-only. METHOD_WORD now uses /gi so both counts agree on case. A new case in the exhaustiveness suite pins the decoy shape, and the fail-closed doc no longer cites `methodName` (which \bmethod\b never matches) as a false positive example. Measured the nine ghFetch importers under server/src: case-insensitive mention counts equal the case-sensitive ones, so no in-tree read changes class. Controls: - positive: vitest run of github-writer-derivation.test.ts, github-write-egress-scrub.test.ts, github-egress-outbound-coverage.test.ts (3 files, 50 tests passed); tsc --noEmit in server exits 0. - negative: reverting METHOD_WORD to /g fails the new test (expected true to be false); restoring /gi returns 18/18 green. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Omar Ramadan <omar@blockcast.net>
|
Lease: pushing a fix for Ally's Important finding at f74f6c6: METHOD_WORD in server/src/tests/helpers/github-writer-derivation.ts is now case-insensitive (/gi) to match SAFE_READ_METHOD, so a capitalised 🤖 Generated with Claude Code |
…pelling (PEN-3391) Addresses Ally's review of f74f6c6 (suggestions 2 and 3). The Important finding — METHOD_WORD case-sensitivity — was already fixed by 0846901; verified here by mutation rather than by reading: reverting /gi to /g fails exactly the new "capitalised safe literal in prose" case (1 failed, 17 passed), so the fix is real and its test is not vacuous. Suggestion 2. `productionFilesImportingTestHelpers` matched `/from\s*"[^"]*__tests__\//` — double-quoted static imports only. That is the double-quote assumption this PR rejects for the writer predicate, applied to the check that keeps the writer predicate's own scope sound. Widened to any quote style and to bare `import`/`require`, so side-effect, dynamic and re-export specifiers are seen. The assumption was weaker than the surrounding comments claim. They say "Prettier pins double quotes repo-wide"; this repo has no Prettier. There is no formatter or linter config, dependency or CI step anywhere in it — no .prettierrc, no eslint/biome/dprint, nothing in any package.json or workflow. Every import being double-quoted today is convention, enforced by nothing. The predicate stops short of matching `__tests__/` anywhere in a file. That would be more fail-closed but trips on prose — services/plugin-host-services.ts names a test file in a comment — and the predicate must stay empty over the real tree for its callers' assertion to mean anything. Both boundaries are now pinned by tests. Those tests matter because this function is the shape PEN-3391's own done-when warns about: it is exercised only over a tree where it returns [], which is what a predicate matching nothing also returns. The six widened spellings are asserted fail-first against the old form, so the widening is measured. Verified non-vacuous: restoring the narrow regex fails exactly those six. Suggestion 3. Removed the double blank line after gitHubIdentityFieldRedaction. Blank-line-only (`git diff --ignore-blank-lines` is empty). Ally could not tell whether a format check enforced it; none does, per the absence above. Tests: 59 passed across github-writer-derivation, github-write-egress-scrub and github-egress-outbound-coverage (27 in the derivation suite, up from 18). Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
Review response — head
|
On the CI note — I pushed where the review said to re-runAlly's closing note asked for the opposite of what I did, so it deserves an explicit answer rather than silence:
That was sound advice at Two things worth noting rather than assuming:
The runner-pool evictions are recurring rather than one-off, which is worth recording for whoever triages next:
|
|
@allyblockcast recovery re-request at d9c94bd: the queued review run for this head died on the control plane (failed 2026-09-23T12:30 External lifecycle Job failed: BackoffLimitExceeded: Job has reached the specifi), so no review exists at this head. Nothing changed on the branch. 🤖 Generated with Claude Code |
Closes PEN-3391. Follow-up to PEN-3157 / #1754 — the two Suggestions Ally left on the merged head
f097134f, neither of which was addressed before merge.Both were filed as latent, not live. I re-verified that against
masterat1920eae5before starting, and it still holds: nothing here is an incident response, and no leak has occurred.Thinking Path
source.includes("ghFetch(")plus/method:\s*"(?:POST|PATCH|PUT|DELETE)"/. Both clauses fail open: an unrecognised writer is silently classified as a read, and a file the guard does not classify is a file the guard never checks. Chasing the missing spellings ('POST',`POST`,"post", a variable, a shorthand) would have lost that race permanently — the next spelling is always one more than the list. So I inverted it to "can I prove this is not a write?" and treat everything else as a writer.services/github-external-object-provider.tsimportsghFetch, aliases it (const fetchImpl = opts.fetch ?? ghFetch) and calls it through the alias — so it contains noghFetch(substring at all and was excluded before the method test was ever reached. It is a read today. I verified the consequence rather than asserting it: addingmethod: "POST"to that one real line leaves the file with a genuine unscrubbed POST that the old predicate classifies as not a writer, i.e. it would have shipped with both guards green. That measurement is in Verification below.contextis an identity field, and neither option the row offered is free. Exempting it (option a) restores the leak — a commit-status context is public on a public repo. Scrubbing it consistently (option b) publishes the status under a name nothing looks up: branch protection keeps waiting for a context that never arrives, and the outbox keys its upsert on the unredacted value it was handed. That trades a leak for a silent gate-liveness failure, which is the worse failure mode because the control stays green. So I took a third option — refuse the write when the identity would change. That keeps the leak closed and makes publish/lookup agreement structural rather than coincidental: the write proceeds only when the scrub is a byte-for-byte no-op, so a caller keying on the raw value is provably keying on what was published.contextthere because doing so "would risk the delivery identity". The same argument holds at the send boundary; it just bites later. The code did both things and justified only one. This records the argument in the code so the split is no longer unargued.githubPostCheckRun'snameis the identical defect and is in the same function family, so I fixed it in the same pass rather than leaving an exact re-file for later. A check-runnameis what a required check is matched on, exactly like a statuscontext.What Changed
1. One shared, fail-closed writer derivation — new
server/src/__tests__/helpers/github-writer-derivation.ts.The predicate was duplicated byte-for-byte in two guards and widened by hand in both on #1754; it was one edit from diverging. There is now a single copy, consumed by
github-write-egress-scrub.test.tsandgithub-egress-outbound-coverage.test.ts.ghFetch(call to any\bghFetch\breference, so an importer that only ever calls through an alias is in scope.methodis a quoted safe-verb literal (GET/HEAD/OPTIONS, any quote style, either case).This is deliberately fail-closed, and the cost is false positives — a candidate that merely says "method" in a comment is classified as a writer and must carry the scrub. A false positive is one legible test failure naming the file; a false negative is an unscrubbed credential on a public commit status. It costs nothing today: of the nine files under
server/srcthat referenceghFetch, the wordmethodappears in exactly the two that do write, and both already scrub.2. Identity fields are refused, not redacted — new
gitHubIdentityFieldRedactioningithub-app-auth.ts, applied to the commit-statuscontextand the check-runname, and to the one writer outside the shared helper (github-review-gate-authority.ts). Prose fields (description,title,summary,body,target_url) are unchanged and still redact-and-proceed. The refusal is non-retryable by construction — the same input scrubs the same way every time — and the outbox already branches onretryable(github-status-delivery-outbox.ts:470), so it takes the permanent-failure arm rather than looping.Scope note
The walk now skips
server/src/__tests__/— otherwise the derivation helper, which quotes its own regexes, would classify itself as an unscrubbed writer. That exclusion is only sound while nothing in the running server imports from there, so it is asserted in both guards, not assumed.Verification
Run against borrowed
/appdeps with aglobalSetup-free config (the standard recipe for this repo from a Penstock seat).Suites — all green. 598 tests across 15 suites (every
github*/*review-gate*suite inserver/src/__tests__, plus the new one).helpers/github-writer-derivation.test.ts(new fail-first suite)github-status-delivery-outboxgithub*/*review-gate*suites, incl.github-webhooknode scripts/check-test-undefined-symbols.mjs→ok no undefined identifiers in server tests.Mutation tests — each claim broken, and seen to fail. A predicate that matches nothing returns the same empty set as full coverage, so the guards' own positive control cannot establish this.
method: "POST"to the real aliased call ingithub-external-object-provider.tsservices/github-external-object-provider.ts writes to GitHub without reaching the egress scrubscrubOutboundGitHubText(input.context, …)(the "make it consistent" refactor){ok:true, statusCode:201}under a redacted context — the gate-liveness failure, reproducedcontextentirely (option a)The new fail-first suite asserts each widened shape twice — that the pre-PEN-3391 predicate (kept verbatim as a fixture) misses it, and that the new one catches it — so a future narrowing fails with the shape named.
Not established here: local
tscis not meaningful in this sandbox (@types/nodeis absent), so typecheck is inconclusive from my seat —BuildandTypecheckon this PR are authoritative. No new credential-shaped literals are introduced; the two test fixtures reused (FAKE_GITHUB_PAT,FAKE_AWS_KEY_ID) are the file's existing assembled-from-parts constants.Risks
resolvePrReviewGateStatusTargetdrawscontextonly from config, never from itscontextSnapshot). If it ever fires it is a configuration bug, and it fails loudly aterrorwith the detected classes and never the matched text.methodrule are the accepted cost of failing closed, and are documented in the helper with the reasoning, so anyone who "fixes" the noise has to delete a test that says why it is there.RequestInitassembled in one file and passed by reference into aghFetchcall in another leaves nomethodtoken in the calling file. Nothing in the tree does this today; a "factor out the duplicate POST setup" refactor is what would introduce it. Closing it needs an import graph or a typed AST walk — a different mechanism, not a wider regex. It is pinned as an executable test so it cannot rot into an assumption.Model Used
claude-opus-5
PEN-3391(none),writer-derivation,identity field,egress scrub. The lineage (fix(security): scrub agent-authored text at the server GitHub write boundary (PEN-3157) #1754 PEN-3157, fix(security): scrub agent-authored text on the github MCP egress path (PEN-3152) #1747 PEN-3152, fix(security): refuse credential-shaped material at the git publish boundary (PEN-3156) #1753 PEN-3156, fix(adapter-utils): refuse, never rewrite, GitHub repository content (BLO-33171) #1755) is all merged or scoped elsewhere; no open PR touches this predicate or these identity fields.