fix(github): bound an error body before redacting it, and collapse every line terminator - #740
Conversation
🤖 ThrillhouseBot PR SummaryWhat this PR doesBounds the error-body logging path: clean() now collapses Unicode controls and line/paragraph separators, pre-cuts the collapsed body to 1024 chars before credential redaction, caps at 512 chars after, and appends an ellipsis when either cut fired; factors the surrogate-pair guard into a shared cutTo helper and adds four BodyHandling tests covering cost, control collapsing, edge stripping and the ellipsis semantics.
|
| File | Change | Summary |
|---|---|---|
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java |
Modified | Reorder clean() collapse/pre-cut/redact/cap pipeline; widen WHITESPACE to Unicode controls; add cutTo. |
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiErrorTest.java |
Modified | Four new BodyHandling tests: bounded redaction cost, Unicode terminator collapse, edge strip, pre-cut ellipsis. |
Risk Assessment
| Risk | Count |
|---|---|
| 🔴 Critical | 0 |
| 🟠 High | 0 |
| 🟡 Medium | 1 |
| 🔵 Low | 1 |
Key Findings
- LOW: Javadoc says every regex pass is bounded at a constant; the collapse pass still scans the whole body (
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:424)
Things to double-check
1 lower-confidence finding
- MEDIUM: Pre-cut can split an unlabeled JWT so redaction misses it and payload chars reach the log (
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:435) (low confidence — verify before acting)
Automated review by ThrillhouseBot. Reply with /review to re-run.
There was a problem hiding this comment.
ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):
- MEDIUM: Pre-cut can split an unlabeled JWT so redaction misses it and payload chars reach the log (
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:435)
Input not in the diff: a collapsed body longer than 1024 chars in which an unlabeled JWT (no "Bearer " prefix) starts within the first ~512 chars and is long enough that the pre-cut lands inside its middle (in the payload segment before the second dot, or within the first 7 chars after the final dot).var bounded = cutTo(collapsed, MAX_BODY_CHARS * 2);truncates the token beforeredactCredentials(bounded)(line 436) runs. The JWT alternative of CREDENTIAL_SHAPED_VALUE, quoted verbatim in this PR's description and Javadoc aseyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}, requires all three dot-separated segments with 8+ chars after each dot inside the 1024-char window. When the window ends inside the payload before its closing dot, or with fewer than 8 chars after the final dot, the alternative fails and the truncated token is returned unredacted; the 512-char cap then logs the prefix plus the JWT header and a large part of the payload verbatim (e.g. ~100 chars of prefix + a ~1100-char JWT whose second dot sits beyond index 1024 exposes header + ~387 payload chars). The removed code ranredactCredentials(collapsed)over the whole collapsed body first, so the complete token matched and was masked before the cap — this is a regression of the redaction guarantee on the very path this PR hardens, under the same threat model the PR cites (hostile bodies from a compromised endpoint). The in-diff tests do not exercise this shape: the 200 KB test body is dotless "eyJ" runs that match in neither version, and the bearer test is rescued by the "Bearer" label alternative. Verify the full CREDENTIAL_SHAPED_VALUE definition — if another alternative matches a bare word-char run (e.g. a generic[\w-]+without the dot structure), the leak is closed. Otherwise the pre-cut must not split a token: redact the tail before cutting, or add a truncation-tolerant alternative that masks an unterminated eyJ-shaped prefix at the window boundary.
…ery line terminator clean() redacted the whole body and capped it only afterwards, so the credential patterns scanned however much the configured API host chose to send. The JWT alternative backtracks from one position in three, which is quadratic: 20 000 chars cost 331ms, 160 000 cost 19 404ms, and a 1.2 MB body about eighteen minutes of CPU inside GitHubApiError.from — on the review's own carrier thread, in the path that exists to explain a failed write. Cutting to twice the 512-char cap first bounds every pass at a constant; the ellipsis now marks either cut. The collapse pass also used \s, which java.util.regex reads as the ASCII six, so NEL, LINE SEPARATOR, PARAGRAPH SEPARATOR, NUL and the ANSI escape reached the log line intact — enough to forge what reads as a second log record. It now covers the Unicode Cc category and the two separators. Fixes #731
154d172 to
163cde9
Compare
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:
- Check format is pending
- Check test is pending
- Check frontend is pending
- Check trivy is pending
- Check dependency-review is pending
Additionally, No new issues in this revision, but 2 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Bounding the body before redacting narrowed what redaction covers: a JWT whose second dot fell past the bound no longer matched the three-segment shape, so it went through unmasked and the cap logged its header and the payload characters that fit. The third segment is now optional, which masks a token cut mid-payload; the first dot stays mandatory, since a run of word characters carrying no dot is not a JWT and matching one would blank the body this line exists to explain. Also corrects the clean() javadoc: the whitespace collapse is one linear pass over the whole body, and it is the cut that bounds the redaction pass, not every pass. Refs #731
|
@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:435 — Pre-cut can split an unlabeled JWT so redaction misses it and payload chars reach the log Confirmed and fixed, not deferred — the finding was right that bounding before redaction narrowed what redaction covers, and that this PR introduced it. Reproduced verbatim before the fix: The JWT's third segment is now optional, so a token the bound cut mid-payload is masked from its One correction to my own reasoning in passing: with the mandatory first dot this does not eliminate the quadratic backtracking, so the 1024-char bound remains the thing that keeps the cost constant. The javadoc says that now rather than claiming otherwise. The LOW on the same file ( New test: Generated by Claude Code |
|
The next review will close every previous finding this comment names by its |
🤖 ThrillhouseBot — changes since the last review
|
There was a problem hiding this comment.
ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):
- LOW: Bidi format controls (U+202E/U+2066, category Cf) survive the collapse and can reorder log text (
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:113)
Input not in the diff: a 4xx body containing U+202E (RIGHT-TO-LEFT OVERRIDE) or U+2066 (LEFT-TO-RIGHT ISOLATE), Unicode category Cf rather than Cc. The new collapse class[\s\p{IsCc}\u2028\u2029]+covers ASCII whitespace, C0/C1 controls (NUL, ESC, NEL, ...) and U+2028/U+2029, but not Cf format controls, so bidi overrides reach the WARN line unchanged. The Javadoc's own contract for this pass is that a "log viewer, a terminal, or a JSON/ECS shipper" must not receive record boundaries or screen-control sequences; a terminal that honors the Unicode bidi algorithm (iTerm2, Windows Terminal; xterm-class terminals typically ignore it) renders the text after the override right-to-left, which can reorder or hide log content — the same log-forgery family the PR fixes, though weaker (visual reordering, not record splitting). Verify whether the deployment's log consumers honor bidi overrides; if they do, extend the class with\p{Cf}, which also collapses ZWSP/ZWNJ/BOM — harmless in a one-line log.
… controls A per-segment length floor left a token unmasked when the bound cut within the first few payload characters, and a masked bearer run ahead of it shortens the text so the leak lands inside the cap rather than past it. Everything after the first dot is now optional in length and count. The first dot stays mandatory, so the one unmasked shape is a cut before it - the header prefix, which carries no secret; the javadoc says that instead of claiming every cut is covered. The collapse also takes Cf format controls: a bidi override is not Cc, and reordering what an operator reads is the same family of harm as splitting the record. Refs #731
|
@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:113 — Bidi format controls (U+202E/U+2066, category Cf) survive the collapse and can reorder log text Fixed rather than deferred. The collapse class now takes Reasoning for taking it rather than gating it on whether the deployment's consumers honour bidi: the javadoc's contract for this pass is that no log viewer, terminal or shipper receives control sequences, and that contract shouldn't depend on which terminal happens to read the line. Collapsing ZWSP/ZWNJ/BOM along with it is harmless on a single-line log. New test: Generated by Claude Code |
|
The next review will close every previous finding this comment names by its |
🤖 ThrillhouseBot — changes since the last review
|
|
… log gets (#753) > **Stacked on #750 (`fix/746-credential-shapes`).** Based against that branch so the diff reads clean; **re-target to `main` once #750 merges**. Only the last commit (`afb2d41`) belongs to this PR. `ci.yml` triggers on pull requests into `main`/`develop`/`release/**` only, so the full CI run happens on re-target — the gates below were run locally on this exact tree. ## What type of PR is this? - [x] 🐛 Bug fix ## Description One change closing two issues, because they are the same coupling seen twice. `isThrottled()` and `blocksContentCreation()` matched against `this.body` — the string `diagnostics()` prints. So every narrowing the log line asks for narrowed the retry decision with it, and the consequence is not a shorter wait: `GitHubWriteRetry.retryDelay` returns `Optional.empty()` on `!isThrottled()`, the `WebApplicationException` is rethrown on the first attempt, and the write is **not repeated at all** — the pre-#495 behaviour this area exists to prevent. The 30-second floor #738 just widened is never consulted. **#732** is the 512-character cap doing it: a body with a long `documentation_url` or echoed headers ahead of the message classifies as a refusal. Equally true at v0.6.3. **#747** is the bound #740 put on the redaction *input*. v0.6.3 had exactly one path by which wording deeper than the cap still survived — redaction compressing a long credential-shaped prefix to `***` and carrying the message forward — and cutting to 1024 before redacting closed it. Threshold sweep from the audit, body = `"Bearer " + "a".repeat(n) + " " + <content-creation block>`: | prefix | v0.6.3 | v0.6.4 | |---|---|---| | 900 chars | throttled, `PT30S` | throttled, `PT30S` | | 1010 chars | throttled | **not throttled**, `PT5S` | | 4000 chars | throttled | **not throttled**, `PT5S` | Same remedy for both, so they land together. **The fix.** One collapse pass now feeds two readings that are kept apart in a small `Body` record. The logged line is unchanged — same `bounded → redacted → capped` order, same ellipsis, same 512 characters. Classification reads the collapsed body bounded at 8 KB and nothing else. The bound is still wanted (the entity is read with no size limit of its own, and #731 is about not letting the configured host set the cost of explaining a failed write), but both patterns are flat literal alternations with no backtracking, so widening the window costs a linear scan; the quadratic shape #731 found is in the credential redaction, which still sees only its own 1024. Deliberately the **unredacted** text. Masking runs before the classifier could read it, and a mask that swallowed the word `blocked` turned a content-creation block into a permission refusal — the classification tail of the JWT over-match in #746. Nothing in that window is ever logged or returned; the two patterns answer yes or no and the string is dropped. **Also, the `Instant.ofEpochSecond` guard #732 asks for in its second half.** `Long.parseLong` accepts values `Instant.ofEpochSecond` rejects, and the resulting `DateTimeException` was thrown from inside `derivedDelay`, out through `GitHubWriteRetry.retryDelay` and `call`, past every `catch (WebApplicationException)` in the write path. `GitHubLostWrites.recording` catches that type specifically, so a write that died this way was not remembered as lost either — the write and the record of its loss went together, over one header from an intermediary. A value that cannot be an instant now means what a non-numeric header already means here: unspecified, and the linear fallback takes over. Neither classification bug is reachable against api.github.com, whose error bodies are ~300 characters with the message first; both need a large body from the configured API host. The header case needs an intermediary sending a ~10^17 reset. ## Related Issues Fixes #747 Fixes #732 ## How Has This Been Tested? - [x] Unit tests - [ ] Integration tests - [ ] Manual testing Eight new behavioural assertions, all red on the parent branch (`1e0d7fc`) in exactly the claimed way and green after. Verbatim, from `./mvnw -o test -Dtest=GitHubApiErrorTest,GitHubWriteRetryTest` with only the test files applied: ``` [ERROR] Failures: [ERROR] GitHubApiErrorTest.isStillReadWhenItSitsPastTheLengthCapTheLogLineUses body={"documentation_url":"xxxxx…xxx… ==> expected: <true> but was: <false> [ERROR] GitHubApiErrorTest.isStillReadWhenMoreCredentialShapedMaterialPrecedesItThanTheRedactionBoundHolds body=***… ==> expected: <true> but was: <false> [ERROR] GitHubApiErrorTest.isNotSomethingTheCredentialMaskCanDeleteBeforeItIsRead body={"message":"prefix *** from content creation"} ==> expected: <true> but was: <false> [ERROR] GitHubApiErrorTest.isReadFromABoundedWindowRatherThanFromAnUnboundedBody expected: <true> but was: <false> [ERROR] GitHubWriteRetryTest.anOutOfRangeResetHeaderStillFailsAsTheExceptionEveryCallerCatches:332 Unexpected exception type thrown, expected: <jakarta.ws.rs.WebApplicationException> but was: <java.time.DateTimeException> [ERROR] Errors: [ERROR] GitHubApiErrorTest.aRateLimitResetTooLargeToBeAnInstantIsTreatedAsUnspecified » DateTime Instant exceeds minimum or maximum instant [ERROR] GitHubApiErrorTest.aRateLimitResetTooSmallToBeAnInstantIsTreatedAsUnspecified » DateTime Instant exceeds minimum or maximum instant [ERROR] GitHubWriteRetryTest.anOutOfRangeResetHeaderOnAThrottleFallsBackToTheLinearWait:356 » DateTime Instant exceeds minimum or maximum instant [ERROR] Tests run: 93, Failures: 5, Errors: 3, Skipped: 0 ``` The escape path in full, from the same run — this is the whole of the second finding: ``` java.time.DateTimeException: Instant exceeds minimum or maximum instant at java.base/java.time.Instant.ofEpochSecond(Instant.java:308) at …GitHubApiError.lambda$derivedDelay$0(GitHubApiError.java:325) at …GitHubApiError.derivedDelay(GitHubApiError.java:325) at …GitHubApiError.retryDelay(GitHubApiError.java:312) at …GitHubWriteRetry.retryDelay(GitHubWriteRetry.java:254) at …GitHubWriteRetry.call(GitHubWriteRetry.java:189) ``` Two of the six new `GitHubApiError` tests are labelled **controls** rather than proof, and both are green before the fix: `doesNotWidenWhatReachesTheLog` (the log line keeps its own 512-character cap and its ellipsis whatever the classifier may see) and `doesNotMakeAPermissionRefusalLookLikeAThrottle` (a 4 KB body with none of the wording is still a refusal, so the wider window did not make the classifier credulous). `isReadFromABoundedWindowRatherThanFromAnUnboundedBody` is half proof and half honest edge: wording at 4 000 characters is now read, wording at 64 000 is still not. Gates on this tree: - `./mvnw -B spotless:apply` → clean - `./mvnw -B clean compile spotbugs:check spotless:check` → **BugInstance size is 0**, Error size is 0, spotless clean - `./mvnw -B clean test` → **Tests run: 3320, Failures: 0, Errors: 0, Skipped: 0** - jacoco ∩ `git diff -U0 fc54d93...HEAD` over changed main code → 139 changed lines, **zero uncovered lines and zero uncovered branches** ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my own code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings or errors ## Additional Notes `GitHubWriteRetry`'s main code is untouched — the guard belongs where the exception is raised, and the retry loop's contract (a `WebApplicationException` in, the same one back out) is what the new test pins from the outside. The 8 KB window is a judgement call, not a measurement: it is sixteen times the log cap and several times any body GitHub sends, chosen so the classifier stops depending on where in a body the message sits while the pass over it stays a linear scan.
…ry text (#750) ## What type of PR is this? - [x] 🐛 Bug fix - [x] 🔒 Security ## Description Two halves of one oversight in `GitHubApiError`'s credential shapes, both introduced by #740. **1. The bound strands a token prefix unmasked (security, bounded at ≤9 characters).** `clean()` cuts the collapsed body to 1024 characters *before* `redactCredentials`. The JWT alternative was widened in #740 precisely so a token the cut severs is still masked — but `gh[pousr]_\w{10,}`, `github_pat_\w{10,}` and `bearer\s+[\w.~+/=-]{10,}` kept their ten-character floors. A token straddling index 1024 with six to nine value characters on the visible side falls below every one of those quantifiers, stops matching, and reaches the warn line. v0.6.3 redacted the whole collapsed body and capped afterwards, so the same input was masked whole. The three floors drop to `{4,}`. The sigil is the discriminator — `ghp_`, `github_pat_` and `Bearer ` do not occur in prose — so the length was buying nothing the sigil did not already buy, while costing the one case the pre-cut can produce. The cap site cuts *after* redaction and can never strand, so this is the only site that could. **2. The widened JWT shape masks ordinary text.** `eyJ[\w-]{8,}(?:\.[\w-]*){1,2}` compiled under a pattern-wide `(?i)` with an unanchored header, so it matched `eyj` in any case, anywhere inside a longer run, followed by a dot at any distance. Measured on the shipped code: | input | v0.6.4 | |---|---| | `cannot resolve host eyjafjallajokull.internal.example.com` | `cannot resolve host ***.com` | | `request id 7f3aeyJQm9keVRleHRIZXJl.log not found` | `request id 7f3a*** not found` | That is the exact outcome the shape's own javadoc says it was narrowed to avoid, and it is not fail-safe for either job the line has — explaining a failure to an operator, and feeding the retry decision, which reads the redacted body. A JWT header is base64url of `{"` and is therefore always literally `eyJ`, so `(?i)` is scoped to the bearer alternative alone and a negative lookbehind on `[\w-]` pins the header to a token boundary. The cut-token property #740 added is untouched: a cut anywhere past the first dot still masks the whole run, which `masksAJwtTheBoundCutWithinTheFirstPayloadCharacters` and `masksAJwtTheBoundCutMidPayload` still pin. **Doc.** `\p{IsCf}` is in the shipped collapse class but was missing from the javadoc paragraph that exists to explain that class. It is the half with the widest blast radius (bidi overrides, ZWJ/ZWNJ/ZWSP, the BOM, the soft hyphen), so it now carries its own defence, including the accepted cost — an echoed user string loses its grapheme clusters — and why the replacement is a space rather than a deletion. Not reachable against api.github.com, which does not echo credentials and whose error bodies are ~300 characters. Both halves need the same non-GitHub host the #740 severity note already describes: a GHES or reverse-proxy error page, a misconfigured base URL, a compromised endpoint. ## Related Issues Fixes #746 ## How Has This Been Tested? - [x] Unit tests - [ ] Integration tests - [ ] Manual testing Five new behavioural tests, all red on `fc54d93` in exactly the claimed way and green after. Verbatim, from `./mvnw -o test -Dtest=GitHubApiErrorTest` with only the test file applied: ``` [ERROR] GitHubApiErrorTest.isStillMaskedForATokenPrefix ***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,ghp_A1b2C3d4E… ==> expected: <false> but was: <true> [ERROR] GitHubApiErrorTest.isStillMaskedForAFineGrainedPersonalAccessToken ***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,github_pat_A1b2C3d4E… ==> expected: <false> but was: <true> [ERROR] GitHubApiErrorTest.isStillMaskedForABearerValue ***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,Bearer S3cr3tV4l… ==> expected: <false> but was: <true> [ERROR] GitHubApiErrorTest.doesNotMaskOrdinaryTextThatMerelyBeginsLikeAJwtHeaderInSomeOtherCase expected: <{"message":"cannot resolve host eyjafjallajokull.internal.example.com"}> but was: <{"message":"cannot resolve host ***.com"}> [ERROR] GitHubApiErrorTest.doesNotMaskAJwtHeaderFoundInsideALongerRunOfWordCharacters expected: <{"message":"request id 7f3aeyJQm9keVRleHRIZXJl.log not found"}> but was: <{"message":"request id 7f3a*** not found"}> [ERROR] Tests run: 55, Failures: 5, Errors: 0, Skipped: 0 ``` `masksABearerHeaderWhateverCaseItArrivedIn` is a **control**, not proof — it is green before the fix as well. It pins the half of the `(?i)` that has to survive being scoped to one alternative, so a later tightening cannot quietly drop bearer's case-insensitivity. Gates on this tree: - `./mvnw -B spotless:apply` → clean - `./mvnw -B clean compile spotbugs:check spotless:check` → **BugInstance size is 0**, Error size is 0, spotless clean - `./mvnw -B clean test` → **Tests run: 3310, Failures: 0, Errors: 0, Skipped: 0** - jacoco ∩ `git diff -U0 fc54d93...HEAD` over changed main code → 33 changed lines, **zero uncovered lines and zero uncovered branches** ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my own code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings or errors ## Additional Notes Scoped to the two credential patterns and the collapse javadoc. The ordering in `clean()` is unchanged — the bound before redaction is #731's fix and stays — so the 200 KB timing test (`doesNotScanAWholeOversizedBodyLookingForCredentials`) and the oversized-body ellipsis test stay green; anchoring the header in fact removes the last backtracking path on an `eyJ`-dense run. The other consequence named in the audit — redaction eating the wording `isThrottled()` reads — is not addressed here. It is fixed by classifying ahead of redaction, which is #747/#732's change, stacked on this branch.
A log record is a thing an operator reads and acts on, and the model chooses the finding titles and paths spliced into these lines. A value carrying a line terminator splits the record and forges a second one; one carrying a bidi override or an ANSI escape reorders or erases what is left of the first. #744 routed the two ReviewPublisher warn lines through MarkdownSafe.oneLine, whose collapse class is Pattern.compile("\\s+") — the ASCII six. NEL (U+0085), U+2028, U+2029, NUL, ESC and a bidi override all go through it untouched, and String.strip() does not catch them either since it trims ends and these are interior. So the vector those lines were sanitized against is still open on them. GitHubApiError met the same threat in #740 and installed the class that does cover it. Lift that class, and the reasoning that chose it, into LogSafe — a helper for log-destined strings — and route every site through it: - the two ReviewPublisher warn lines, replacing MarkdownSafe.oneLine - FindingQuoteValidator, FindingVerificationService, FrameworkFalsePositiveFilter, FindingPipeline, FindingDeduplicator and FollowUpAnalyzer, which interpolate raw title/file/reason at INFO — on in a default install, since the repository configures no log levels - GitHubApiError itself, which now calls the helper rather than keeping a private duplicate of the pattern MarkdownSafe.oneLine is deliberately left as it is. It also feeds markdown the bot posts to GitHub, where the wider class has a real rendering cost — an emoji ZWJ sequence comes apart — that a log line pays gladly and a posted comment should not. ModelSuppliedTextInLogLinesTest drives all eight sites through their real production paths and asserts on the LogRecord each emits, so the proof does not depend on which handler is installed. Fixes #755
…tes (#760) > **Stacked on #758.** The base is `fix/757-credential-floors` because both PRs touch > `GitHubApiError.java`. This re-targets `main` once #758 merges; only the last two commits of the > compare view belong to this PR. ## What type of PR is this? - [x] 🐛 Bug fix - [ ] ✨ Feature - [ ] 📝 Documentation - [ ] 🔧 Refactor - [ ] 🚀 Performance - [ ] ✅ Test - [x] 🔒 Security - [ ] 📦 Dependency update - [ ] 🏗️ CI/CD ## Description A log record is something an operator reads and acts on, and the model chooses the finding titles and paths that get spliced into these lines. A value carrying a line terminator splits the record and forges a second one; a value carrying a bidi override or an ANSI escape reorders or erases what is left of the first. #744 routed the two `ReviewPublisher` warn lines through `MarkdownSafe.oneLine`, whose collapse class is `Pattern.compile("\\s+")` — the ASCII six (`[ \t\n\x0B\f\r]`), because `java.util.regex` reads a bare `\s` that way unless the pattern asks for Unicode character classes. NEL (U+0085), LINE SEPARATOR (U+2028), PARAGRAPH SEPARATOR (U+2029), NUL, ESC and RLO all pass straight through it, and `String.strip()` does not catch them either — it trims the ends and these are interior. The vector those two lines were sanitized against is therefore still open on them. `GitHubApiError` met the same threat in #740 and installed the class that does cover it: `[\s\p{IsCc}\p{IsCf} ]+`. That reasoning never crossed over. ### The fix Lift that class — and the javadoc that argues for it — into a new `LogSafe.oneLine`, a helper for **log-destined** strings, and route all eight sites through it: | site | level | model-supplied values | |---|---|---| | `ReviewPublisher.java:770` | WARN | `file` (was `MarkdownSafe.oneLine`) | | `ReviewPublisher.java:832` | WARN | `file` (was `MarkdownSafe.oneLine`) | | `FindingQuoteValidator.java:83` | INFO | `title`, `file` | | `FindingVerificationService.java:1229` | INFO | `title`, `file`, `verdict.reason()` | | `FrameworkFalsePositiveFilter.java:74` | INFO | `title`, `file` | | `FindingPipeline.java:1370` | INFO | `title`, `file` | | `FindingDeduplicator.java:55` | INFO | `file`, `title` | | `FollowUpAnalyzer.java:633` | INFO | `title`, `file`, `duplicateOf.title()` | All six INFO lines are live in a default install — the repository configures no `quarkus.log` levels anywhere. Each line number was checked against the tree before editing; all six still held. `GitHubApiError` now calls the helper rather than keeping a private duplicate of the pattern, so there is one class and one explanation rather than two that can drift. ### `MarkdownSafe.oneLine` is deliberately left alone It also feeds markdown the bot posts to GitHub, where the wider class has a real rendering cost — `\p{IsCf}` splits an emoji ZWJ sequence or an Indic conjunct apart. A `body=` field or a warn line is a diagnostic identity and pays that cost gladly; a posted comment is a rendering surface and should not. Widening `WHITESPACE_RUN` in place would have changed what the bot posts. One pattern per destination, two intents kept apart. The moved javadoc carries both halves of the bargain with it: the accepted `\p{IsCf}` cost, and why the replacement is a space rather than a deletion (deleting would let `admin<ZWSP>istrator` close up into a different real word; a space cannot). ## Related Issues Fixes #755 ## How Has This Been Tested? - [x] Unit tests - [ ] Integration tests - [ ] Manual testing `ModelSuppliedTextInLogLinesTest` drives all eight sites through their **real production paths** and captures the `java.util.logging.LogRecord` each class emits — the object every handler (console, file, syslog, JSON/ECS shipper) is handed — so the assertion does not depend on which handler happens to be installed. Each test feeds one crafted `title` or `file` carrying NEL, U+2028, U+2029, NUL, ESC and RLO, then asserts the record still names the finding and carries none of the six. `LogSafeTest` pins the helper itself: sixteen characters a reader could take for a control (each built by code point, not referenced from the production pattern), run collapsing, end trimming, the space-not-deletion rule, an ordinary value left alone, and a `null` value. ### Red before the fix All seven tests fail on the unfixed tree, each on the first forbidden character (U+0085), with the remaining five visible in the rendered message: ``` [ERROR] Tests run: 7, Failures: 7, Errors: 0, Skipped: 0 ``` ``` ### aCraftedPathCannotForgeARecordFromEitherPublisherRejectionWarning U+0085 reached the log line: GitHub rejected inline comment for app.js<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever:2 (status=422 body=<unavailable>) — filing it on the file instead GitHub rejected the file-level thread for app.js<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever (status=422 body=<unavailable>) — the finding keeps no thread at all ==> expected: <false> but was: <true> ### aCraftedTitleCannotForgeARecordFromTheQuoteValidatorDemotion U+0085 reached the log line: Finding 'Missing null check<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever' (src/Main.java:2) quotes code that does not appear in the diff — dropping its suggestion and capping confidence ==> expected: <false> but was: <true> ### aCraftedTitleCannotForgeARecordFromTheVerifierRejection U+0085 reached the log line: Verifier rejected finding 'Missing null check<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever' (src/Main.java:10): fp Finding verification: 0 kept, 0 downgraded, 1 rejected ==> expected: <false> but was: <true> ### aCraftedTitleCannotForgeARecordFromTheFrameworkFilterDrop U+0085 reached the log line: Dropping finding 'Missing no-arg constructor<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever' (src/main/java/dev/example/PrSummaryGenerator.java:12) — it claims a missing no-arg constructor but the diff shows an injection-annotated constructor; constructor injection needs no no-arg constructor in CDI/Spring ==> expected: <false> but was: <true> ### aCraftedPathAndTitleCannotForgeARecordFromTheAnchorBackfill U+0085 reached the log line: Populating missing content anchor for finding 'Missing null check<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever' (app.js<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever:1) ==> expected: <false> but was: <true> ### aCraftedPathAndTitleCannotForgeARecordFromTheDeduplicatorMerge U+0085 reached the log line: Merging 2 duplicate findings at app.js<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever:42 ('Missing null check<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever') ==> expected: <false> but was: <true> ### aCraftedTitleCannotForgeARecordFromTheRepliedDuplicateDrop U+0085 reached the log line: Dropping re-raised finding 'Missing null check<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever' (src/B.java:5) — a maintainer already replied to the prior finding 'Missing null check<NEL>2026-08-16 12:00:00 WARN [thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator<U+2029>forged-by-paragraph-separator<NUL>after-nul<ESC>[2Kescaped<RLO>desrever' at the same location ==> expected: <false> but was: <true> ``` `<NEL>` and the rest are the test's own rendering, applied only when building the failure message — the assertion runs `joined.contains(forbidden)` against the raw record text with the literal code point. All six are present in every line; the assertion trips on the first. ### Green after `Tests run: 3356, Failures: 0, Errors: 0, Skipped: 0`. The first assertion of every test is that the log line ran at all and that the record still names the finding, so a fix that simply dropped the line or blanked the value would not pass. ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my own code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings or errors ## Screenshots / Logs Gates, in order: - `./mvnw -B spotless:apply` — BUILD SUCCESS - `./mvnw -B clean compile spotbugs:check spotless:check` — **BugInstance size is 0**, BUILD SUCCESS - `./mvnw -B clean test` — **Tests run: 3356, Failures: 0, Errors: 0, Skipped: 0** Coverage over `git diff -U0 73cc33d...HEAD` intersected with the jacoco report: **zero uncovered lines and zero uncovered branches** across all nine changed main files. ## Additional Notes Milestone v0.6.5, which is not tagged yet. The DEBUG lines in `ReviewPublisher` (`:720`, `:728`, `:874`) carry the same values but are off in a default install; they are left for a separate pass rather than widening this diff.



What type of PR is this?
Description
Two defects in the same method, both in the path that exists to explain a failed write.
1. Redaction ran before the cap, and the JWT shape is quadratic — the primary fix
readBodyreads the entity with no size bound of its own, andclean()redacted the whole of it, capping at 512 characters only afterwards. So the cost of logging one 4xx was set by whatever the configured API host chose to send.CREDENTIAL_SHAPED_VALUE's JWT alternative iseyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}.[\w-]excludes., so on a body of repeatedeyJeach greedy run consumes to end-of-input, fails to find its separator and backtracks one character at a time — from one position in three. That is quadratic, and it was measured as such:Every doubling costs about 4×. That time is spent on the review's own carrier thread, inside
GitHubApiError.from, before the retry decision the cleaned body feeds is even reached.The fix is the one the audit called for: cut, then redact, then cap.
Every regex pass is now bounded at a constant. The pre-cut is twice
MAX_BODY_CHARSrather than exactly it because redaction only ever shortens, so the wider window leaves material to fill the 512-char cap with; past that the output was going to be truncated anyway, and what a wider window would pull into view is more of the token-shaped run being masked out. The ellipsis now marks either cut, so a body shortened before redaction is never mistaken for one GitHub sent whole. The surrogate-pair guard that protected the 512 cut is factored intocutToand now protects both cuts.2.
\sis the ASCII six, so most line terminators survived the collapseWHITESPACEwas\\s+, which java.util.regex reads as[ \t\n\x0B\f\r]unless the pattern asks for Unicode character classes. CR and LF being collapsed closes the classic forged-record vector, but NEL (U+0085), LINE SEPARATOR (U+2028), PARAGRAPH SEPARATOR (U+2029), NUL and the ANSI escape all reached the warn line intact — and a log viewer, a terminal, or a JSON/ECS shipper may treat any of them as a record boundary or a screen-control sequence. The class already documents a body as attacker-influenced text on its way to a log file and already pays for a collapse pass on that basis; the pass simply did not cover the class it claimed to.Now
[\\s\\p{IsCc}\\u2028\\u2029]+—\p{IsCc}is the Unicode general category rather than POSIX\p{Cntrl}, so it reaches the C1 controls (U+0080–U+009F, NEL among them) as well as C0 and DEL. The strip moved after the collapse so a terminator at either end does not survive as a stray space.Not in this PR
finding.file()interpolation (ReviewPublisher.java:752and:814) — the third item on the issue.ReviewPublisheris being changed by separate work in this round, so touching it here would conflict; it needsMarkdownSafe.oneLineat both warn sites and is left to that change.readEntityitself. The audit lists it as optional. Every regex pass is bounded now, and the remaining cost of a huge body is one linear collapse pass plus the read that already happened.Related Issues
Fixes #731
How Has This Been Tested?
Four tests added to
GitHubApiErrorTest.BodyHandling, all reusing the audit's probes:doesNotScanAWholeOversizedBodyLookingForCredentials— 200 KB ofeyJ(probe P2c), bounded at 2 000 ms. The bound is enormously slack against what this costs once the body is cut first; it is sized to fail only on the quadratic, never on a slow machine.collapsesTheLineTerminatorsAndControlsThatAreNotAsciiWhitespace— probe P3b's body, asserted as one clean line.stripsALineTerminatorAtEitherEndRatherThanLeavingASpacemarksABodyCutBeforeRedactionAsTruncatedEvenWhenTheMaskFitsUnderTheCap— a 4 000-char bearer value masks to***, and the result must still say it was cut.The existing
capsAnOverLongBodySoOneFailureCannotFloodTheLogandneverCutsAnOverLongBodyThroughASurrogatePairare unchanged and still pass, which is what pins the cap and the surrogate guard through the restructuring.Verbatim red output on unfixed code
(The
?are the terminal's rendering of the surviving U+0085 / U+2028 / U+2029 and of the ellipsis.)92 877 ms for one 200 KB body — this machine is slower than the one the audit measured 24 s on, which only sharpens the point. After the fix the whole 15-test
BodyHandlingnest runs in 3.4 s, and all four go green:Gates
./mvnw -B spotless:apply→ clean./mvnw -B clean compile spotbugs:check spotless:check→BugInstance size is 0, BUILD SUCCESS./mvnw -B clean test→Tests run: 3290, Failures: 0, Errors: 0, Skipped: 0git diff -U0on this commit's main code → 0 uncovered lines, 0 uncovered branches (both conditions of the ellipsis test and both arms of the surrogate guard are exercised)Checklist
Screenshots / Logs
See the verbatim red output above.
Additional Notes
Severity is latent, not live: GitHub's real error bodies are around 300 characters, and no path was found by which a PR author gets GitHub itself to echo hundreds of kilobytes into a 4xx body. It needs a large body from the configured API host — a GHES or reverse-proxy error page, a misconfigured base URL, a compromised endpoint. The redaction half is pre-existing from #704 (v0.6.2); #723 made the same
clean()output load-bearing for a retry decision, and promoted a debug line to warn, which is what widened the exposure of the second half.