fix(github): mask a credential the bound cut, and stop masking ordinary text - #750
Conversation
…ry text #740 bounded the body to 1024 characters before redacting it, and widened the JWT alternative so a token the bound severs is still masked. The same reasoning was never carried to the other three shapes: `gh[pousr]_`, `github_pat_` and `bearer` still required ten value characters, so a token the bound cut below that floor stopped matching and reached the warn line with up to nine characters of the secret intact. v0.6.3 redacted the whole collapsed body before capping and masked the same input whole. The floors drop to four. The sigil is the discriminator here — `ghp_`, `github_pat_` and `Bearer ` do not occur in prose — so the length was buying nothing the sigil did not already buy, and cost the one case the bound can produce. The other half of the same oversight is the widened JWT shape. It compiled under a pattern-wide `(?i)` and its header was unanchored, so it matched `eyj` in any case, anywhere inside a longer run, followed by a dot at any distance: `eyjafjallajokull.internal.example.com` came out as `***.com`, and an `EyJ` in the middle of a request id blanked four hundred characters around it. That is the outcome the shape's own javadoc says it was narrowed to avoid. A JWT header is base64url of `{"` and is therefore always literally `eyJ`, so the case-insensitivity is scoped to the bearer half and `(?<![\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. Also documents `\p{IsCf}` in the javadoc that exists to explain the collapse class, which listed only `\p{IsCc}`.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
🤖 ThrillhouseBot PR SummaryWhat this PR doesFixes #746: credential redaction floors drop from ten to four characters so a token severed by clean()'s 1024-char pre-redaction bound is still masked, and the JWT shape's case-insensitivity is scoped to the bearer alternative and anchored to a token boundary so ordinary text like 'eyjafjallajokull' is no longer masked. The whitespace-collapse javadoc is expanded to document the \p{IsCf} character class.
|
| File | Change | Summary |
|---|---|---|
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java |
Modified | Lowers cred floors to 4; scopes (?i) to bearer; anchors eyJ to a token boundary; javadoc. |
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiErrorTest.java |
Modified | Adds tests for cut-token masking, eyJ prose non-masking, and a bearer case-control. |
Risk Assessment
| Risk | Count |
|---|---|
| 🔴 Critical | 0 |
| 🟠 High | 0 |
| 🟡 Medium | 1 |
| 🔵 Low | 1 |
Key Findings
- MEDIUM: Bearer floor lowered to {4,} masks ordinary prose like 'bearer token' (
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:105)
Things to double-check
1 lower-confidence finding
- LOW: 'always literally eyJ'javadoc claim is arithmetically wrong; digit-key JWT headers are missed (
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:95) (low confidence — verify before acting)
⚠️ Required CI Checks Status
Some required checks are still pending or have failed:
| Check | Type | Status | Detail |
|---|---|---|---|
| test | check-run | ⏳ Pending | - |
| format | check-run | ⏳ Pending | - |
| trivy | check-run | ⏳ Pending | - |
| frontend | check-run | ⏳ Pending | - |
| dependency-review | check-run | ⏳ Pending | - |
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):
- LOW: 'always literally eyJ'javadoc claim is arithmetically wrong; digit-key JWT headers are missed (
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:95)
Line 95's new javadoc asserts a JWT header 'is base64url of {"' and is therefore always literally 'eyJ''. The arithmetic does not support 'always': base64url of the two bytes {" is 'eyI=' (16 bits -> three chars), and the third base64 character is 0010 plus the top two bits of the third byte, so it is 'J' only when the header's first key character is a letter (0x40-0x7F). Input not in the diff: a valid JWT whose JSON header starts with a digit key, e.g. {"1":"HS256"}, encodes to 'eyIx...'; an empty-string key {"":...} encodes to 'eyIi...'. Neither matches the anchored 'eyJ' literal at line 105, so such a token's payload would reach the log unmasked. Practical exposure is limited to nonstandard headers — standard headers ('alg', 'typ', 'kid'...) do start with 'eyJ' — so verify whether any configured endpoint (GHES/reverse proxy) could emit such tokens. Either document the assumption or widen the anchor (e.g. ey[I-L]) after assessing the prose-overmasking trade-off.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… 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.
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.
|
@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:95 — 'always literally eyJ'javadoc claim is arithmetically wrong; digit-key JWT headers are missed Correct, and the claim was mine. Verified the arithmetic before changing anything: The third character is fixed by the first key's leading byte, not by the braces, so "always" was wrong. The javadoc now says what actually holds: I did not widen the anchor to Generated by Claude Code |
|
The next review will close every previous finding this comment names by its |
…each The bearer and JWT shapes shared a single alternation, which two static analysis rules objected to: the JWT half needed its capturing group only to sit beside the bearer half, and the combined pattern's complexity scored 22 against a budget of 20. Splitting them costs nothing, because the redaction pass was never tied to two patterns — it scans an array of matchers, takes the leftmost match, and prefers the earlier shape on a tie. The array simply grows by one, and the start sentinels are filled to its length rather than written out. Also adjusts the surrounding wording for readability: the union javadoc and the quadratic-cost note now name the pattern each describes, the eyJ anchor is explained as the arithmetic it is rather than a claim about spelling, and the bearer shape records that masking only the value was tried and loses a credential tail on an overlapping match.
|
🤖 ThrillhouseBot — changes since the last review
|
…mpile flag (#754) ## What type of PR is this? - [x] ♻️ Refactor ## Description #750 split `CREDENTIAL_SHAPED_VALUE` into `BEARER_SHAPED_VALUE` and `JWT_SHAPED_VALUE` to get the combined pattern's complexity back under budget. That split left one loose end, which the analysis on that PR flagged and which merged with it: ``` java:S6395 | MAJOR | GitHubApiError.java:134 | Unwrap this unnecessarily grouped subpattern. ``` The rule is right. `(?i:bearer\s+[\w.~+/=-]{4,})` existed to scope case-insensitivity to the *bearer alternative* of a two-alternative pattern — that scoping was #746's fix, and it is why `eyjafjallajokull.internal.example.com` stopped coming out as `***.com`. Once the alternation was gone there was nothing left to scope the flag away from, so the group wraps the entire pattern and does nothing. The flag moves to the compile call: ```java Pattern.compile("bearer\\s+[\\w.~+/=-]{4,}", Pattern.CASE_INSENSITIVE) ``` **Behaviour is identical, and the identity matters.** `Pattern.CASE_INSENSITIVE` without `UNICODE_CASE` matches ASCII case only — exactly what `(?i:...)` did. That is the correct scope here: the shape is matching the literal HTTP `Bearer` auth-scheme token, not prose, so Unicode case folding would only widen what a log line masks. The javadoc now records it, so the ASCII scope reads as the decision it is rather than as something to "fix" by adding `UNICODE_CASE`. `JWT_SHAPED_VALUE` stays case-sensitive, unchanged. ## Related Issues Follow-up to #750; no separate issue filed, since the finding arrived through the analysis on that PR and is a two-line correction to it. ## How Has This Been Tested? - [x] Unit tests No new test, and deliberately so: this is a refactor with no behavioural delta, so there is no red state to demonstrate. A test written for it would pass before the change as well, which proves nothing. The existing coverage is what pins the equivalence, and two tests in `GitHubApiErrorTest` bear directly on it: - `masksABearerHeaderWhateverCaseItArrivedIn` — the case-insensitivity survives the move off the inline group - `doesNotMaskOrdinaryTextThatMerelyBeginsLikeAJwtHeaderInSomeOtherCase` — the JWT shape stays case-*sensitive*, i.e. the flag did not leak across the split Both are **controls**: green before and after. ### 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: 3324, Failures: 0, Errors: 0, Skipped: 0** - jacoco ∩ `git diff -U0 HEAD` on changed main code → one executable line changed (the `Pattern.compile` call), **zero uncovered lines, zero uncovered branches** ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my own code - [ ] 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 The rest of the diff is javadoc. The block above `BEARER_SHAPED_VALUE` still opened by calling itself "the bearer and JWT shapes", which was accurate while it documented one field and stopped being so when the split made two. It now says which shape each paragraph is about and why the two are documented together.



What type of PR is this?
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 beforeredactCredentials. The JWT alternative was widened in #740 precisely so a token the cut severs is still masked — butgh[pousr]_\w{10,},github_pat_\w{10,}andbearer\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_andBearerdo 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 matchedeyjin any case, anywhere inside a longer run, followed by a dot at any distance. Measured on the shipped code:cannot resolve host eyjafjallajokull.internal.example.comcannot resolve host ***.comrequest id 7f3aeyJQm9keVRleHRIZXJl.log not foundrequest id 7f3a*** not foundThat 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 literallyeyJ, 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, whichmasksAJwtTheBoundCutWithinTheFirstPayloadCharactersandmasksAJwtTheBoundCutMidPayloadstill 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?
Five new behavioural tests, all red on
fc54d93in exactly the claimed way and green after. Verbatim, from./mvnw -o test -Dtest=GitHubApiErrorTestwith only the test file applied:masksABearerHeaderWhateverCaseItArrivedInis 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: 0git diff -U0 fc54d93...HEADover changed main code → 33 changed lines, zero uncovered lines and zero uncovered branchesChecklist
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 aneyJ-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.