Skip to content

fix(github): take the credential value floors to a single character - #758

Merged
devops-thiago merged 6 commits into
mainfrom
fix/757-credential-floors
Aug 18, 2026
Merged

fix(github): take the credential value floors to a single character#758
devops-thiago merged 6 commits into
mainfrom
fix/757-credential-floors

Conversation

@devops-thiago

@devops-thiago devops-thiago commented Aug 16, 2026

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • ✨ Feature
  • 📝 Documentation
  • 🔧 Refactor
  • 🚀 Performance
  • ✅ Test
  • 🔒 Security
  • 📦 Dependency update
  • 🏗️ CI/CD

Description

clean() cuts a response body to 1024 characters before redactCredentials runs, so a
credential 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 not
where 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,} and github_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_ and Bearer do not occur in prose, so the length floor was
never carrying the discrimination. {4,} was a stopping point rather than a reasoned bound — no
test 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-90 described the
severed-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-129 is 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 the
mid-run eyJ cases. Nothing in the suite regressed — full run below.

Related Issues

Fixes #757

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

GitHubApiErrorTest.ACredentialTheBoundCutJustShortOfItsLengthFloor already pinned nine surviving
value 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 — the
console renders the trailing as ? because the surefire console stream is not UTF-8 here):

[ERROR] Tests run: 67, Failures: 3, Errors: 0, Skipped: 0
isStillMaskedForATokenPrefixCutToASingleCharacter
***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,ghp_A… ==> expected: <false> but was: <true>

isStillMaskedForAFineGrainedPersonalAccessTokenCutToASingleCharacter
***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,github_pat_A… ==> expected: <false> but was: <true>

isStillMaskedForABearerValueCutToASingleCharacter
***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,Bearer S… ==> expected: <false> but was: <true>

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 and
land in the body= field.

Green after

Tests run: 3328, Failures: 0, Errors: 0, Skipped: 0.

One further test, leavesTheBareSigilBehindWhenTheCutLandsBeforeTheFirstValueCharacter, is a
control 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's
true boundary and carries nothing secret.

Checklist

  • My code follows the project's coding standards
  • 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
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • 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:checkBugInstance size is 0, BUILD SUCCESS
  • ./mvnw -B clean testTests run: 3328, 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
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 main once this merges.

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
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

Lowers 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.

⚠️ Description vs. Implementation

The PR description does not fully match the change:

  • PR description claims ghp_, github_pat_ and Bearer "do not occur in prose" and that the length floor was never carrying discrimination; the code's own javadoc above BEARER_SHAPED_VALUE says the word bearer "does mask the noun in prose such as missing bearer token", so the premise is false for the bearer shape and the new {1,} floor newly masks short prose phrases such as "bearer of" that the {4,} floor previously left intact.

Changes Overview

  • Files changed: 2
  • Lines added: +63
  • Lines removed: -22

Changed Files

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.

@thrillhousebot thrillhousebot Bot added bug Something isn't working java Pull requests that update java code security Security-sensitive issue or hardening labels Aug 16, 2026
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

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.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check dependency-review is pending
  • Check format is pending
  • Check test is pending
  • Check frontend is pending
  • Check trivy is pending

…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.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check format is pending
  • Check trivy is pending
  • Check test is pending
  • Check frontend is pending
  • Check dependency-review is pending

… 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.

Copy link
Copy Markdown
Owner Author

@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 271efd6.

The claim was false as written. All three lines interpolate finding.file() raw:

line statement
ReviewPublisher.java:721 Line for %s:%d is outside the PR diff — filing the finding on the file instead
:729 Adjusted inline comment line for %s from %d to %d
:877 Inline comment rejected for %s:%d (suggestion=%s): %s

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: MarkdownSafe.oneLine promised a collapse it did not perform, and #742 read as closed for a week because of it.

Grep confirms no Log.*f call in ReviewPublisher now interpolates finding.file() or finding.title() without going through the helper, so the class javadoc's "every value" is now true rather than aspirational.

This push also merges main (picking up #759) and carries the Zs widening from the earlier finding on #760.

Gates: BugInstance size is 0, spotless clean, full suite 3370 tests, 0 failures.


Generated by Claude Code

@thrillhousebot

Copy link
Copy Markdown
Contributor

The next review will close every previous finding this comment names by its path:line and title; anything it does not name stays open.

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 0
  • Previous findings still open: 0

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 771 LogSafe.oneLine(finding.file()), finding.line(), reason);, line 833 LogSafe.oneLine(finding.file()), rejectionReason(e)); in the file-level warn, and the trailing : %s slot (line 883) in tryPostInlineComment. 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. If rejectionReason(e) or reason can 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.

Copy link
Copy Markdown
Owner Author

@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 7d46425.

On the question actually posed — can rejectionReason propagate raw response body text? No. rejectionReason returns GitHubApiError.diagnostics(), whose body component is body.logged(), and clean() collapses that through LogSafe before redacting and capping it. The body reaches the reason slot already flattened.

But diagnostics() also interpolates four response headers, and those were verbatim:

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 response.getHeaderString(...) and went into the line untouched — one field away from a body being collapsed for exactly this reason. GitHubApiErrorHeaderCollapseTest, red before the fix:

[ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0
NEL survived into the diagnostics line:
  status=403 x-ratelimit-resource=core?forged WARN approved body={"message":"no"}
ESC survived into the diagnostics line:
  status=403 x-ratelimit-remaining=0?[2Kcleared body={"message":"no"}
LINE SEPARATOR survived into the diagnostics line:
  status=403 retry-after=30?forged body={"message":"no"}

The collapse now goes in append, the one place every header passes through, so a header added later is covered without anyone having to remember. Two further tests pin that the value is kept rather than blanked, and that an ordinary header is untouched.

The orElseGet(e::toString) fallback — used when the exception carried no response at all — was raw for the same reason and now takes the same treatment, as does the non-WebApplicationException return.

Worth recording that your note about the new test was correct and is why this needed a separate one: ModelSuppliedTextInLogLinesTest drives the path with a constant reason containing no control characters, so it could never have caught this.

Full suite 3376 tests, 0 failures; BugInstance size is 0.


Generated by Claude Code

@thrillhousebot

Copy link
Copy Markdown
Contributor

The next review will close every previous finding this comment names by its path:line and title; anything it does not name stays open.

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 0
  • Previous findings resolved: 1
    • src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java:771 — Log line reason slots interpolate rejectionReason raw despite LogSafe sweep
  • Previous findings still open: 0

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@sonarqubecloud

Copy link
Copy Markdown

@devops-thiago
devops-thiago merged commit c4550f8 into main Aug 18, 2026
17 checks passed
@devops-thiago
devops-thiago deleted the fix/757-credential-floors branch August 18, 2026 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working java Pull requests that update java code security Security-sensitive issue or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The credential-shape floors still strand up to three characters of a token the bound cut, so #746 is PARTIAL

1 participant