fix(github): take the credential value floors to a single character - #758
Conversation
The bound before redaction cuts a response body at 1024 characters, and a token severed below a shape's length floor stops matching and reaches the warn line. #746 lowered the three floors from ten to four on that basis but stopped there, which narrows the window rather than closing it: a cut that leaves one, two or three value characters is still under four, so the sigil and up to three characters of the secret still reach the log. Lower all three shapes to one — gh[pousr]_, github_pat_ and bearer. The sigil is the whole discriminator, which is the argument the javadoc already makes; none of the three occurs in prose, so the length was never carrying the discrimination. One is where the argument ends, since a cut leaving no value characters strands the sigil alone. The javadoc described the severed-token case as closed while three characters still went through; it now states the residual it actually has. Pins the boundary in ACredentialTheBoundCutJustShortOfItsLengthFloor at one surviving character for each shape alongside the nine it already covered, plus the zero-character case the javadoc names. Every over-masking control (gh_2.40.0, ghost_writer, the ghp_ prefix in prose) stays green. Fixes #757
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
🤖 ThrillhouseBot PR SummaryWhat this PR doesLowers the credential-shape length floors in GitHub API error redaction from {4,} to {1,} so a token severed by the 1024-character pre-redaction cut still matches and is masked, and pins the new boundary with tests at one surviving value character for each shape plus a zero-character control test.
|
| File | Change | Summary |
|---|---|---|
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java |
Modified | Lowers value floors to {1,} for gh[pousr]/github_pat and bearer patterns; javadoc updated to describe the new boundary and the bare-sigil residual. |
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiErrorTest.java |
Modified | Generalises the cut fixture to a parameterised surviving-char count; adds one-char tests for all three shapes and a zero-char control pinning the bare sigil. |
Risk Assessment
| Risk | Count |
|---|---|
| 🔴 Critical | 0 |
| 🟠 High | 0 |
| 🟡 Medium | 0 |
| 🔵 Low | 0 |
No new issues found in this PR, but the review cannot be approved until required CI is confirmed green.
⚠️ Required CI Checks Status
Some required checks are still pending or have failed:
| Check | Type | Status | Detail |
|---|---|---|---|
| format | check-run | ⏳ Pending | - |
| test | check-run | ⏳ Pending | - |
| frontend | check-run | ⏳ Pending | - |
| trivy | check-run | ⏳ Pending | - |
| dependency-review | check-run | ⏳ Pending | - |
Automated review by ThrillhouseBot. Reply with /review to re-run.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The two forms are the same quantifier; the concise one is what the rest of the file already uses, and static analysis flags the long form.
…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.
… too The three debug lines interpolate the same model-supplied path the warn lines do, and were left out on the grounds that debug is off in a default install. That left LogSafe's own javadoc claiming every value a log statement splices in goes through it while three did not, which is the kind of claim that stops the next reader checking. Widening the coverage rather than qualifying the claim: the sanitizing call is the same one the warn lines already make, and a debug-enabled install has exactly the forgery surface the warn lines were fixed for.
|
@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/LogSafe.java:22 — LogSafe javadoc overclaims that every untrusted value in every log line goes through it Raised on #760, which merged into this branch carrying it open. Verified and fixed here in The claim was false as written. All three lines interpolate
Widened the coverage rather than qualifying the claim. The sanitizing call is the one the warn lines beside them already make, and "debug is off by default" bounds who is exposed, not whether the vector exists — a debug-enabled install has precisely the surface the warn lines were fixed for. Qualifying the javadoc would have left a helper documenting its own gaps, which is the failure mode this PR exists to remove: Grep confirms no This push also merges Gates: 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: Log line reason slots interpolate rejectionReason raw despite LogSafe sweep (
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java:771)
The changed log lines in ReviewPublisher sanitize finding.file() but still interpolate the rejection-derived value raw: line 771LogSafe.oneLine(finding.file()), finding.line(), reason);, line 833LogSafe.oneLine(finding.file()), rejectionReason(e));in the file-level warn, and the trailing: %sslot (line 883) intryPostInlineComment. LogSafe's own javadoc claims it is "The single place untrusted text is flattened before it is interpolated into a log line" and lists "a GitHub error body" among the values that go through it; a rejection reason drawn from a GitHub rejection response is exactly that class of value. IfrejectionReason(e)orreasoncan propagate any part of the raw response body — the #757 threat model explicitly includes a compromised endpoint — the forged-record vector this PR closes for title/file stays open through the reason slot.rejectionReason's implementation is not in the diff: verify it cannot return response-body text (or wrap the value in LogSafe.oneLine, which also handles null). The new ModelSuppliedTextInLogLinesTest cannot distinguish this: it drives the path with a constant WebApplicationException reason ("nope") containing no control characters.
…e too diagnostics() reads four values straight off the response and interpolates them verbatim, one field away from a body it collapses. A header is response data from the configured API host, so on the hosts this class already names as its threat model — a GHES or reverse-proxy error page, a misconfigured base URL, a compromised endpoint — it is as untrusted as the body beside it, and it reaches the same warn line. The collapse goes in the one place every header passes through, so a header added later is covered without anyone remembering to. The reason a rejection carries when the exception brought no response was raw for the same reason, and takes the same treatment.
|
@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java:771 — Log line reason slots interpolate rejectionReason raw despite LogSafe sweep Investigated as asked. The finding pointed at the right slot; the mechanism turned out to be one field over, and there was a real defect there. Fixed in On the question actually posed — can But append(text, "retry-after", retryAfter);
append(text, "x-ratelimit-remaining", rateLimitRemaining);
append(text, "x-ratelimit-reset", rateLimitReset);
append(text, "x-ratelimit-resource", rateLimitResource);They come from The collapse now goes in The Worth recording that your note about the new test was correct and is why this needed a separate one: Full suite 3376 tests, 0 failures; Generated by Claude Code |
|
The next review will close every previous finding this comment names by its |
🤖 ThrillhouseBot — changes since the last review
|
|



What type of PR is this?
Description
clean()cuts a response body to 1024 characters beforeredactCredentialsruns, so acredential the cut severs can fall below its shape's length floor, stop matching, and reach the
warn line with whatever survived the cut intact.
#746 lowered the three floors from
{10,}to{4,}on exactly that reasoning — but four is notwhere the reasoning ends. A cut that leaves one, two or three value characters is still under the
floor, so the sigil plus up to three characters of the secret still reach the log. The window is a
third of its old width, not closed.
This takes all three shapes to
{1,}:gh[pousr]_\w{1,}andgithub_pat_\w{1,}(GitHubApiError.java:93)bearer\s+[\w.~+/=-]{1,}(GitHubApiError.java:138)The sigil is the whole discriminator, which is the argument the javadoc above these patterns
already makes:
ghp_,github_pat_andBearerdo not occur in prose, so the length floor wasnever carrying the discrimination.
{4,}was a stopping point rather than a reasoned bound — notest pinned it. One is where the argument genuinely ends: a cut that leaves no value characters
strands the sigil by itself, and a sigil is not secret.
The second half of the issue is the javadoc.
GitHubApiError.java:85-90described thesevered-token case as closed while three characters were still going through. It now states the
residual it actually has, so the next reader starts from the true premise. The bearer shape's
paragraph at
:127-129is updated to match.Over-masking
Every existing over-masking control stays green with no edit:
gh_2.40.0_linux_amd64.tar.gz,ghost_writer,the ghp_ prefix and the ghs_ prefix, the JWT-lookalike hostname and themid-run
eyJcases. Nothing in the suite regressed — full run below.Related Issues
Fixes #757
How Has This Been Tested?
GitHubApiErrorTest.ACredentialTheBoundCutJustShortOfItsLengthFlooralready pinned nine survivingvalue characters for each of the three shapes. Its fixture helper is generalised to take the
surviving-character count, and three tests are added at one surviving character — the tightest
point of the floor, and the case the fix has to close.
Red before the fix
The three new tests on the unfixed tree at
73cc33d(verbatim, from the surefire XML report — theconsole renders the trailing
…as?because the surefire console stream is not UTF-8 here):That is the claimed mechanism exactly: the credential-shaped prefix ahead of the token compresses
to
***, so the stranded sigil and its one surviving character clear the 512-character cap andland in the
body=field.Green after
Tests run: 3328, Failures: 0, Errors: 0, Skipped: 0.One further test,
leavesTheBareSigilBehindWhenTheCutLandsBeforeTheFirstValueCharacter, is acontrol rather than proof — it is green before and after. It pins the residual the javadoc now
names: a cut landing before any value character leaves
ghp_…in the line, which is the floor'strue boundary and carries nothing secret.
Checklist
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: 3328, Failures: 0, Errors: 0, Skipped: 0Coverage over
git diff -U0 73cc33d...HEADintersected with the jacoco report: zero uncoveredlines and zero uncovered branches in the changed main code.
Additional Notes
Milestone v0.6.5, which is not tagged yet — this belongs in the release rather than on top of it.
#755 stacks on this branch, since it touches the same file; it re-targets
mainonce this merges.