Skip to content

fix(review): collapse every model-supplied value a log line interpolates - #760

Merged
devops-thiago merged 2 commits into
fix/757-credential-floorsfrom
fix/755-log-collapse
Aug 17, 2026
Merged

fix(review): collapse every model-supplied value a log line interpolates#760
devops-thiago merged 2 commits into
fix/757-credential-floorsfrom
fix/755-log-collapse

Conversation

@devops-thiago

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

Copy link
Copy Markdown
Owner

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?

  • 🐛 Bug fix
  • ✨ Feature
  • 📝 Documentation
  • 🔧 Refactor
  • 🚀 Performance
  • ✅ Test
  • 🔒 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?

  • 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

  • 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: 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

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

This change lifts the Unicode-aware whitespace/control/format collapsing pattern from GitHubApiError into a shared LogSafe.oneLine helper and applies it to eight log lines that interpolate model-supplied finding titles, file paths, and verdict reasons, so characters like NEL, U+2028/U+2029, NUL, ESC, and bidi overrides cannot forge or corrupt log records. GitHubApiError is refactored to reuse the same helper instead of keeping a private duplicate of the pattern.

Description vs. Implementation

No mismatch found between the PR description and the change.

Changes Overview

  • Files changed: 11
  • Lines added: +608
  • Lines removed: -38

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/LogSafe.java Added Added the shared LogSafe.oneLine helper and its javadoc, collapsing whitespace, Unicode controls, format chars, and line separators.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java Modified Replaced the private WHITESPACE pattern with LogSafe.oneLine, removing the duplicate pattern and javadoc.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FindingDeduplicator.java Modified Sanitized file and title in the duplicate-merge INFO log line.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FindingPipeline.java Modified Sanitized title and file in the missing-anchor backfill INFO log line.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FindingQuoteValidator.java Modified Sanitized title and file in the quote-validator demotion INFO log line.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FollowUpAnalyzer.java Modified Sanitized finding title, file, and duplicate title in the re-raised-finding drop log line.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FrameworkFalsePositiveFilter.java Modified Sanitized title and file in the false-positive drop INFO log line.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java Modified Switched the two WARN fallback log lines from MarkdownSafe.oneLine to the wider LogSafe.oneLine.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationService.java Modified Sanitized title, file, and verdict reason in the verifier-rejection INFO log line.
src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeTest.java Added Added unit tests pinning LogSafe.oneLine behavior for control/format characters, run collapsing, trimming, null, and ordinary values.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ModelSuppliedTextInLogLinesTest.java Added Added end-to-end tests that capture LogRecords from all eight sanitized log sites and assert crafted model strings cannot forge records.

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 0
🔵 Low 0

Everything's coming up Thrillhouse! 🎉

No issues found in this PR.


Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot added the bug Something isn't working label Aug 16, 2026
@thrillhousebot thrillhousebot Bot added java Pull requests that update java code security Security-sensitive issue or hardening labels Aug 16, 2026
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
@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: LogSafe.oneLine leaves Zs space separators (EM SPACE, IDEOGRAPHIC SPACE) unsplit despite the javadoc's "every run of whitespace" contract (src/main/java/dev/thiagogonzaga/thrillhousebot/LogSafe.java:61)
    Input not in the diff: a model-supplied title or path containing U+2003 EM SPACE, U+3000 IDEOGRAPHIC SPACE, U+2000–U+2006, U+2008–U+200A or U+205F (all Unicode category Zs). The pattern on line 61 — Pattern.compile("[\s\p{IsCc}\p{IsCf}\u2028\u2029]+") — combines the ASCII six from \s with the Cc and Cf categories, but no class in it matches Zs, so a value like "a\u2003b" passes through replaceAll(" ") unchanged and the raw separator reaches the log line. The javadoc at lines 66–67 states "every run of whitespace, control and format characters becomes a single space", which the code contradicts for interior runs: String.strip() on line 75 uses Character.isWhitespace (which returns true for U+2003 and U+3000, trimming them at the ends), but the pattern does not collapse them in the middle. NBSP-family U+00A0/U+2007/U+202F are likewise left intact. The gap cannot forge a record boundary or reorder text, so impact is limited, but the documented contract of the one helper all eight sites route through is wrong for these inputs; verify whether downstream log shippers treat Zs as whitespace before deciding to widen with \p{Zs}.

…hind

The class covered the ASCII six plus the control and format categories,
but not Zs, so NBSP, the EM/EN and figure spaces, the narrow NBSP, the
medium mathematical space and the ideographic space reached the log line
intact — against a javadoc promising that every run of whitespace becomes
a single space.

The trim was no backstop, and inconsistently so: it reads
Character.isWhitespace, which is true for U+2003 and U+3000 but false for
U+00A0, U+2007 and U+202F, so the first pair were removed at the ends yet
never collapsed inside, and the other three survived at every position.

These forge no record boundary, which makes them the cheapest of the four
classes to justify. They are worth collapsing for the same reason the
invisible characters already were: two values differing only by one of
them render identically to the operator reading the line.

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/LogSafe.java:61 — LogSafe.oneLine leaves Zs space separators (EM SPACE, IDEOGRAPHIC SPACE) unsplit despite the javadoc's "every run of whitespace" contract

Correct, and fixed in 697d7c2 by widening the class rather than narrowing the javadoc. A helper whose documented contract is wider than its code is the precise defect this PR exists to remove — MarkdownSafe.oneLine promised the same thing and did not deliver it, which is why #742 looked closed for a week.

The gap is slightly wider than the finding states. Character.isWhitespace is false for U+00A0, U+2007 and U+202F, so strip() was not trimming those even at the ends:

old pattern Character.isWhitespace old net effect
U+2003, U+3000, U+2000, U+205F not matched true trimmed at ends, never collapsed inside
U+00A0, U+2007, U+202F not matched false survived at every position

So the inconsistency was not only interior-versus-edge; three of the seven were never removed at all.

The class is now `[\s\p{IsZs}\p{IsCc}\p{IsCf}

]+. Eight new cases in LogSafeTest`, red on the previous pattern with the new tests in place — verbatim:

[ERROR] Tests run: 29, Failures: 8, Errors: 0, Skipped: 0
NO-BREAK SPACE (U+00A0) ==> expected: <a b> but was: <a?b>
EN QUAD (U+2000) ==> expected: <a b> but was: <a?b>
EM SPACE (U+2003) ==> expected: <a b> but was: <a?b>
FIGURE SPACE (U+2007) ==> expected: <a b> but was: <a?b>
NARROW NO-BREAK SPACE (U+202F) ==> expected: <a b> but was: <a?b>
MEDIUM MATHEMATICAL SPACE (U+205F) ==> expected: <a b> but was: <a?b>
IDEOGRAPHIC SPACE (U+3000) ==> expected: <a b> but was: <a?b>
trimsASpaceSeparatorTheWhitespaceTestDoesNotRecognise expected: <boom> but was: <?boom?>

(The ? is the terminal rendering the separators; the assertions run against the literal code points.)

On the point about downstream shippers: no, they are not treated as whitespace, and that is the argument for collapsing rather than against. These cannot forge a boundary — which is why this is the cheapest of the four classes to justify — but two values differing only by an NBSP render identically to the operator reading the line, which is the same harm the \p{IsCf} paragraph already accepts a cost to prevent. The javadoc now records that reasoning alongside the others.

Full suite green at 3364 tests, BugInstance size 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: 1
  • Previous findings resolved: 1
    • src/main/java/dev/thiagogonzaga/thrillhousebot/LogSafe.java:61 — LogSafe.oneLine leaves Zs space separators (EM SPACE, IDEOGRAPHIC SPACE) unsplit despite the javadoc's "every run of whitespace" contract
  • 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: LogSafe javadoc overclaims that every untrusted value in every log line goes through it (src/main/java/dev/thiagogonzaga/thrillhousebot/LogSafe.java:22)
    The class javadoc states "Every value a log statement splices in that the bot did not choose itself — a GitHub error body, a model-supplied finding title or path — goes through here". The PR's own description discloses that the three ReviewPublisher DEBUG lines (:720, :728, :874) carry the same model-supplied values and were deliberately left unsanitized for a separate pass, so the "every ... goes through here" claim is not currently true. Those lines are outside the diff hunks, so verify at ReviewPublisher.java:720/728/874 that they interpolate title/file as the description states; if so, qualify the javadoc so a maintainer reading it does not assume the class covers every log statement.

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.

1 participant