Skip to content

fix: register a bound callId atomically, and enforce LogSafe's documented reach - #788

Open
devops-thiago wants to merge 4 commits into
mainfrom
fix/763-764-audit-defects
Open

fix: register a bound callId atomically, and enforce LogSafe's documented reach#788
devops-thiago wants to merge 4 commits into
mainfrom
fix/763-764-audit-defects

Conversation

@devops-thiago

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

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • 🔒 Security
  • ✅ Test

Description

Two unrelated defects from the merged-PR tech-debt audit, one commit each.

#763 — a bound callId was registered in two steps

ReviewSessionContext.bind looked the session's in-flight set up with
computeIfAbsent and then added to it, so the add ran outside the per-bin lock the
lookup had held:

ACTIVE_CALLS.computeIfAbsent(sessionId, id -> ConcurrentHashMap.newKeySet()).add(callId);

A sibling stream finishing in between unmapped the set the add then wrote to.
invalidate(sessionId, callId) returns null once the set empties, which drops the
mapping, and invalidate(sessionId) drops it outright — the first needs no bulk
invalidate at all, only another batch for the same session finishing first. The add
landed on an orphan, isActiveCall read a mapping that no longer held the callId, and
a live stream's usage callback was discarded as stale. Those tokens never reached
ReviewTokenLedger, so the per-review spend ceiling (#509) under-counted: a review
that should have hit the ceiling and degraded keeps spending. Reviews run their
batches on virtual threads, so concurrent bind and invalidate for one session is the
ordinary case here.

The mirror window in invalidate was closed with computeIfPresent when it was first
flagged on #374; this is the half that was missed. A single compute puts the add back
under the lock that decides whether the mapping survives.

The interleaving is driven, not raced. The session's set is swapped for one whose
add is a rendezvous point — the only instant that matters, since it is reached inside
the bin lock once registration is atomic and outside it before. The sibling invalidate
is released exactly there, and the binder then waits for whichever of the two states
follows: the invalidate completed (it was never excluded — the old shape) or it
parked on the bin this thread holds (it was excluded — the fixed shape). Both are
observed states rather than elapsed time, so the test cannot deadlock against the fix,
does not depend on who wins a sleep, and fails in 7 ms against the old code rather than
after a timeout.

#764 — the fifth round of "a sanitizer whose documented reach exceeds its actual reach"

LogSafe's class javadoc claims every value a log statement splices in that the bot did
not choose itself goes through it. Five rounds have now found that untrue, and every one
of them fixed a list of sites someone had noticed. The enumeration is what keeps failing:
it is a list a human maintains, so the next call site written is outside it by default.

So the second half of this PR states the invariant as a test rather than as a list.
LogSafeInvariantTest derives the untrusted accessor names from the AI response records
themselves — every String-typed component of every record reachable from a *Response
class in review.ai, read reflectively — and scans every log call in src/main/java for
one interpolated without LogSafe. Both halves extend themselves: a field added to
ReviewResponse.Finding is covered the moment it exists, and a log line added anywhere in
main is scanned the moment it is written. Nothing is kept in sync by hand.

Design notes, since a source-scanning test is unusual:

  • It errs towards reporting. A textual scan cannot resolve types, so it decides by
    accessor name; an unrelated record's file() is reported too. The answer to a report is
    to wrap the value, which is never wrong for a logged string.
  • Numeric conversions are exempt. %d splices a number, which can forge neither a
    record boundary nor an escape — and a finding's line() is the common case.
  • It tracks composed locals. FollowUpAnalyzer's locator is file() + ":" + line()
    built a few statements above the log call, so a check reading only the call's own
    accessors would see a bare identifier and pass it.
  • It cannot go blind. A structural test that silently matches nothing reads exactly
    like a clean code base, so the scanner is pinned against known-bad and known-good
    sources, and the run asserts the derived accessor set still contains its known members
    before it trusts a clean result.

Running it found nine sites, not the three the issue enumerated. The three INFO lines
are there, and the conversation-clear line carries two model-supplied values rather than
one, so wrapping only its title would have left the path open. The other six: a WARN
naming a repo-supplied glob (PathScopedInstructions), and six DEBUG lines naming a
model-supplied path (DocGenerationService, PrImprovementService). DEBUG was excluded
by hand in an earlier round on the grounds that it is off by default — which is the kind
of judgement that put this defect class on its fifth appearance, so these are wrapped too.

Review round: the javadoc claimed a reach the scan does not have

The reviewer was right, and it was verified against the scanner before anything moved. The
javadoc said the scan "reads every log call in src/main/java and fails on any that
interpolates one of them without LogSafe". It sees two shapes: an untrusted accessor
called inside the log call's own argument text, and a same-file local whose initializer
calls one. A value crossing a method boundary is a bare identifier no declaration in the
file marks tainted:

void logPath(String file) { Log.warnf("rule file %s", file); }   // called with finding.file()

Asserted against the unmodified scanner:

LogSafeInvariantTest.theScannerReportsTheShapesItClaimsTo
PROBE: a value crossing a method boundary ==> expected: <1> but was: <0>

Narrowed the javadocs rather than extending the scan, and the extension was measured
rather than dismissed.
A textual scan has no call graph; the only caller-to-callee join
available without type resolution is name plus argument position. Prototyped over main,
that taints 203 parameters and produces 31 further reports, and the sampled ones are
name collisions rather than findings:

site reported what actually reaches the line
ReviewDispatcher:83 req req.owner()/req.repo()req is a record
DocGenerationService:431 line an int at a %d, beside an already-wrapped doc.file()
PrLabeler:167 request request.owner()/request.repo()

LogSafe.oneLine takes neither a record nor an int, so the report's own prescribed fix
would not compile. A check that asks for impossible fixes is one a reader learns to skip,
which costs more than the shapes it would have caught — and since #764's defect is a
documented reach exceeding an actual one, an accurate sentence is what cures it; a wider
scan would only move the boundary, not the mismatch.

So both javadocs now state the two shapes and name the escape hatch (wrap the value where
the accessor is read), and the miss is pinned by
theScannerDoesNotSeeAValueThatCrossedAMethodBoundary, so if the scan ever grows a
dataflow step the test fails and the failure lands on the two sentences that have to widen
with it. The reach and its description can only move together.

SonarCloud — three issues, all fixed, none suppressed

Both java:S8786 reports are true. This file reads every source in main, so the cost is real.

  • CONVERSION (line 77): the flag class and the width overlap on 0 — the zero-pad flag
    is also a digit — so a run of zeros splits every way between them.
  • LOCAL_DECLARATION (line 87): \s* after the = overlaps [^;] on whitespace the same way.

Possessive quantifiers remove both splits. No match is lost — a width may not begin with
0, since a leading one is the flag, so the greedy split was the only correct one anyway.
Measured on a 24 000-character run, and differenced old form against new over 400 000
random inputs plus 41 curated format/declaration cases:

CONV random diffs: 0        CONV cases checked: 27
DECL random diffs: 0        DECL cases checked: 14
CONV  %0*24000                  old=7030ms  new=2ms
DECL  'String x =<24000 sp>y'   old=2494ms  new=0ms

java:S9142 (line 262) is mechanical: the per-report \s+ is now a Pattern compiled
once instead of at each reported call site.

No assertion was weakened. One was added — %,08d|%s pins that a flagged, zero-padded,
width-bearing conversion still binds to its own argument, since the flag/width parse was
previously unpinned. Mutation-checked by dropping the flag class from CONVERSION:

a flagged, zero-padded, width-bearing conversion still binds to its own argument
==> expected: <1> but was: <2>

LogSafe's javadoc now says which half is machine-checked: the model-supplied one,
because response records name their own fields. A GitHub error body has no such record to
read, so that half stays a convention its call sites keep (GitHubApiError collapses the
body and each diagnostic it appends). That is the honest statement of its reach.

Related Issues

Closes #763
Closes #764

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Red/green for both, then the full suite and the coverage gate.

#763bindRegistersAtomicallyAgainstASiblingInvalidateEmptyingTheSession against the
unfixed bind:

ReviewSessionContextTest.bindRegistersAtomicallyAgainstASiblingInvalidateEmptyingTheSession:141
a call bound while a sibling emptied the session must stay registered ==> expected: <true> but was: <false>
Time elapsed: 0.007 s

Green after the compute, and green on five consecutive runs.

#764LogSafeInvariantTest against the unfixed sources reports 14 unsanitized
interpolations across 9 log statements:

PrImprovementService.java:324/334/340/371   logs [file()] raw
DocGenerationService.java:430/436           logs [file()] raw
PathScopedInstructions.java:89              logs [path()] raw
FindingVerificationService.java:1007        logs [risk(), title()] raw
FindingVerificationService.java:1072        logs [title(), risk(), confidence()] raw
FollowUpAnalyzer.java:1160                  logs [title()] raw, and [locator] raw

The three INFO sites also get a behavioural test each, driving the real production path and
reading the LogRecord a handler is handed, alongside the six already in
ModelSuppliedTextInLogLinesTest. Against the unfixed sources:

aCraftedTitleCannotForgeARecordFromTheHedgedDemotion:419
U+0085 reached the log line:
Demoting hedged high finding 'Underscore variable may not compile<NEL>2026-08-16 12:00:00 WARN
[thrillhousebot] approved the pull request, 0 findings<U+2028>forged-by-line-separator...'

aCraftedTitleCannotForgeARecordFromTheInjectionSinkFloor:441
U+0085 reached the log line:
Raising unmitigated-injection-sink finding 'User comment written to innerHTML<NEL><U+2028>...'

aCraftedPathCannotForgeARecordFromTheConversationClear:475
U+0085 reached the log line:
Clearing previous finding 'Unbounded retry loop' (app.js<NEL>2026-08-16 12:00:00 WARN
[thrillhousebot] approved the pull request, 0 findings<U+2028>...:7)

The third proves the composed locator, not just the title.

Gates, in order:

./mvnw -B spotless:apply                                 BUILD SUCCESS
./mvnw -B clean compile spotbugs:check spotless:check    BugInstance size is 0
./mvnw -B clean test                                     Tests run: 3466, Failures: 0, Errors: 0

Coverage on the changed lines, intersecting target/site/jacoco/jacoco.xml with
git diff -U0 origin/main --: every added or changed line in src/main/java is covered,
zero uncovered branches (the new compute lambda's null/non-null branch is covered both
ways by the existing bind tests). Re-run after the review round — the LogSafe change is
javadoc only, so it adds no executable line: 0 uncovered lines, 0 uncovered branches across
all seven changed main sources.

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

Additional Notes

The structural guard is the part worth reviewing hardest, since it is the piece meant to
stop a sixth round. Two properties are deliberate and worth confirming you agree with:
it reports rather than resolves (an unrelated file() gets wrapped, and wrapping is
cheap), and its reach is stated rather than assumed — it covers what can be derived from
a record, which is the model-supplied half.

Two directions it does not cover, both now stated in the javadoc rather than left implied:
GitHub-derived values have no response record to enumerate, so extending the same scan to
them needs a marker (an annotation on the untrusted accessors, say) rather than reflection
over record components; and values crossing a method boundary need a call graph, which
reading source text does not have. The second is pinned by a test, so it is a known limit
rather than a claim that quietly stopped being true.

…ge is never dropped

`bind` registered a call in two steps — look the session's set up with
`computeIfAbsent`, then add to it — and the add ran outside the per-bin lock the
lookup held. A sibling stream finishing in between unmapped the set the add then
wrote to: `invalidate(sessionId, callId)` returns null once the set empties,
which drops the mapping, and `invalidate(sessionId)` drops it outright. Neither
window is exotic; the second needs no bulk invalidate at all, only another batch
for the same session finishing first.

The add then landed on an orphaned set, `isActiveCall` read a mapping that no
longer held the callId, and a live stream's usage callback was discarded as
stale. Those tokens never reached `ReviewTokenLedger`, so the per-review spend
ceiling (#509) under-counted: a review that should have hit the ceiling and
degraded keeps spending, which is the opposite of what the control exists to do.
Reviews run their batches on virtual threads, so concurrent bind and invalidate
for one session is the ordinary case here.

The mirror window in `invalidate` was closed with `computeIfPresent` when it was
first flagged; this is the half that was missed. A single `compute` puts the add
back under the lock that decides whether the mapping survives.

The test drives the interleaving rather than racing it. The session's set is
swapped for one whose `add` is a rendezvous point — the only instant that
matters, since it is reached inside the bin lock once registration is atomic and
outside it before — and the binder then waits for whichever of the two states
follows: the sibling invalidate completed (the old shape) or parked on the bin
this thread holds (the fixed shape). Both are observed states rather than
elapsed time, so the test cannot deadlock against the fix and does not depend on
who wins a sleep.
… that bypass it

`LogSafe`'s class javadoc claims every value a log statement splices in that the
bot did not choose itself goes through it. Five rounds have now found that
untrue, and each one fixed a list of sites someone had noticed: the ASCII-only
collapse class, `LogSafe`'s own `\p{IsZs}` gap, three `ReviewPublisher` DEBUG
lines waved through because debug is off by default, four rate-limit headers in
`GitHubApiError`, and now three INFO lines carrying a model-supplied finding
title and path. The enumeration is what keeps failing: it is a list a human
maintains, so the next call site written is outside it by default.

So this states the invariant as a test rather than as a list. It derives the
untrusted accessor names from the AI response records themselves — every
String-typed component of every record reachable from a `*Response` class in
`review.ai`, read reflectively — and scans every log call in `src/main/java` for
one interpolated without `LogSafe`. Both halves extend themselves: a field added
to `ReviewResponse.Finding` is covered the moment it exists, and a log line
added anywhere in main is scanned the moment it is written. A textual scan
cannot resolve types, so it decides by accessor name and errs towards
reporting; the answer to a report is to wrap the value, which is never wrong for
a logged string. Numeric conversions are exempt because a `%d` can forge
neither a boundary nor an escape. The scanner is itself pinned against
known-bad and known-good sources, because a structural test that silently
matches nothing reads exactly like a clean code base.

Running it found nine sites, not the three that were enumerated. The three INFO
lines are there — and the conversation-clear line carries two model-supplied
values, since its `locator` is composed from the finding's own file, so wrapping
only the title would have left half of it open. The other six are a WARN naming
a repo-supplied glob and six DEBUG lines naming a model-supplied path; DEBUG was
excluded by hand in an earlier round on the grounds that it is off by default,
which is exactly the kind of judgement that put this defect class on its fifth
appearance.

The three INFO lines each get a test that drives the real production path and
reads the LogRecord a handler is handed, alongside the six already there.
`LogSafe`'s javadoc now says which half is machine-checked: the model-supplied
one, because response records name their own fields. A GitHub error body has no
such record to read, so that half stays a convention its call sites keep.
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

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

Scanned Files

None

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

Makes ReviewSessionContext.bind register a new stream invocation atomically inside one ACTIVE_CALLS.compute, so a sibling invalidate emptying the session can no longer orphan the set the add writes to (fixes #763), and wraps all remaining raw model-supplied values in main-source INFO/DEBUG/WARN log calls with LogSafe.oneLine while adding a source-scanning invariant test, LogSafe javadoc clarification, and behavioural log-record tests (fixes #764).

Description vs. Implementation

No mismatch found between the PR description and the change.

Control-Flow Diagram

🔀 Show diagram
sequenceDiagram
    participant B as Binder thread
    participant M as ACTIVE_CALLS map
    participant I as Sibling invalidator
    B->>M: bind(): ACTIVE_CALLS.compute(sessionId, lambda)
    note over M: lambda runs under the per-bin lock
    M->>M: active.add(callId) inside the lock
    B-->>I: atAdd.countDown() releases the sibling
    I->>M: invalidate(sessionId, otherCallId): computeIfPresent
    note over M,I: sibling parks on the same bin lock (BLOCKED)
    M-->>B: add completes; set is still mapped
    I->>M: remove(otherCallId); set non-empty, mapping kept
    M-->>B: isActiveCall(bound) is true
Loading

Changes Overview

  • Files changed: 10
  • Lines added: +734
  • Lines removed: -11

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/LogSafe.java Modified Javadoc amended: the model-supplied half of LogSafe's reach is now machine-checked by LogSafeInvariantTest; GitHub error bodies remain a call-site convention.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/DocGenerationService.java Modified Wraps doc.file() in LogSafe.oneLine at two /add-docs DEBUG log sites.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FollowUpAnalyzer.java Modified Wraps finding.title() and the composed path:line locator in LogSafe.oneLine at the conversation-clear INFO site.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/PathScopedInstructions.java Modified Wraps declared.path() in LogSafe.oneLine at the uncompilable-glob WARN site.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/PrImprovementService.java Modified Wraps improvement.file() in LogSafe.oneLine at four /improve DEBUG log sites.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationService.java Modified Wraps finding risk/title/confidence in LogSafe.oneLine at the hedged-demotion and injection-sink-floor INFO sites.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/ReviewSessionContext.java Modified bind now registers the callId inside a single compute lambda, putting the add under the same per-bin lock that decides whether the mapping survives.
src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java Added New structural test: derives untrusted accessor names reflectively from *Response records and scans every src/main/java log call for raw interpolations, with known-good/bad pins.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ModelSuppliedTextInLogLinesTest.java Modified Adds three behavioural tests: crafted titles/path cannot forge a log record from the hedged demotion, injection-sink floor, and conversation-clear lines.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ai/ReviewSessionContextTest.java Modified Adds a driven-race test proving bind registers atomically against a sibling invalidate emptying the session, via a RendezvousSet that hands control to the invalidator at add.

Risk Assessment

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

Key Findings

  • LOW: Whole-argument "LogSafe." skip lets mixed raw+sanitized expressions pass the invariant scan (src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java:219)

⚠️ Required CI Checks Status

Some required checks are still pending or have failed:

Check Type Status Detail
dependency-review check-run ⏳ Pending -
trivy check-run ⏳ Pending -
format check-run ⏳ Pending -
test check-run ⏳ Pending -
frontend check-run ⏳ Pending -

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

Comment thread src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java Outdated
@thrillhousebot thrillhousebot Bot added bug Something isn't working security Security-sensitive issue or hardening testing Test coverage and test quality labels Aug 24, 2026
… it mentions LogSafe

The invariant skipped an argument as soon as it contained "LogSafe." anywhere,
which reads one wrapped value as proof the rest is safe. An argument mixing the
two passed with the raw half interpolated at its own conversion:

    Log.infof("path %s", LogSafe.oneLine(finding.title()) + finding.file())

That is the shape a partial fix produces — someone wraps the value the finding
names and leaves the one beside it — so it is the shape the guard most needs to
catch. The local-declaration half had the same skip and the same hole.

Both now remove the sanitized spans and scan the remainder, so the check cannot
be satisfied by mentioning the sanitizer. Spans are matched by balancing
parentheses rather than to the first close paren, so a nested call inside the
argument does not end the span early.

Three cases pinned in the scanner's self-check: the mixed argument, a local
composed from a wrapped value and a raw one, and the nested call that must still
count as sanitized.

Reported by the reviewer on this PR.
@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 1
    • src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java:219 — Whole-argument "LogSafe." skip lets mixed raw+sanitized expressions pass the invariant scan
  • Previous findings still open: 0

Comment thread src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java Outdated

@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 closed 1 previous finding(s) this round:

  • src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java:219 — Whole-argument "LogSafe." skip lets mixed raw+sanitized expressions pass the invariant scan

…regexes

The javadoc the scanner shipped with claimed it "reads every log call in
src/main/java and fails on any that interpolates one of them without LogSafe",
and LogSafe's own javadoc repeated it. What the scan sees is two shapes: an
untrusted accessor called inside the log call's own argument text, and a
same-file local whose initializer calls one. A value that crosses a method
boundary is a bare identifier no declaration in the file marks tainted, so

    void logPath(String file) { Log.warnf("rule file %s", file); }

called with finding.file() reports nothing. Confirmed against the scanner
before touching it: that case asserts 1 and gets 0.

Which is #764's own defect — a documented reach wider than the actual one —
re-installed in the checker's documentation, so the sentence had to change
whatever else did.

Extending the scan instead was tried and measured rather than waved off.
Without type resolution the only caller-to-callee join available is name plus
argument position; over main that taints 203 parameters and produces 31 further
reports, and the sampled ones are collisions: `req` in ReviewDispatcher, a
record whose owner() is what actually reaches the line, and `line` in
DocGenerationService, an int at a %d beside an already-wrapped path. Neither is
a value LogSafe.oneLine accepts, so the report's own prescribed fix would not
compile — and a check that asks for impossible fixes is one a reader learns to
skip, which costs more than the shapes it would have caught. So both javadocs
now state the two shapes and name the escape hatch (wrap the value where the
accessor is read), and the miss is pinned by a test, so the reach and the
sentence describing it can only move together.

The two scanning regexes were quadratic, which matters because this file reads
every source in main. CONVERSION's flag class and width overlap on 0, the
zero-pad flag being also a digit, so a run of zeros splits every way between
them; LOCAL_DECLARATION's \s* after the = overlaps [^;] on whitespace the same
way. Possessive quantifiers remove both splits without removing a match: 7030ms
to 2ms and 2494ms to under 1ms on a 24 000-character run, and a differential
over 400 000 random inputs plus 41 curated ones found nothing the old and new
forms answer differently. The flag-and-width case is now pinned, since dropping
the flag class slides the numeric exemption onto the wrong argument. The
per-report \s+ is compiled once rather than at each reported call site.

Reported by the reviewer on this PR.
@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 1
    • src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java:45 — Checker javadoc overstates reach: values crossing method boundaries are never scanned
  • Previous findings still open: 0

private static final Pattern LOCAL_DECLARATION =
Pattern.compile(
"\\b(?:final\\s+)?(?:String|var)\\s++(\\w+)\\s*+=\\s*+([^;]++);", Pattern.DOTALL);

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.

🟡 MEDIUM — Scanner silently misses a same-file local whose initializer contains ';' inside a string literal

LOCAL_DECLARATION captures a local's initializer with ([^;]++);, and [^;]++ stops at any semicolon, including one inside a string literal. For String msg = "prefix; " + finding.title(); the captured initializer is just "prefix, which withoutLiterals then strips as an unterminated literal, so the accessor call is never seen; msg is never marked tainted and a later Log.infof("... %s", msg) interpolates a model-supplied value with no report. That contradicts the reach both javadocs claim: the test's class javadoc says the scan "fails on two shapes: ... a same-file local whose initializer calls one" (lines 45-47), and LogSafe.java's javadoc says it "fails on any log statement in src/main/java that calls one of them in the call's own arguments, or that logs a same-file local composed from such a call" — the local's initializer does call finding.title(), yet the scan stays silent. This is the same documented-reach-exceeds-actual-reach class #764 exists to end, now inside the guard itself, and because it is a false negative no pin or current source exposes it. No file in the current diff exhibits the shape, so the risk materializes only for the next site written. Fix direction: make the initializer capture literal-aware so a ';' inside a string/char literal (or text block) does not terminate it, or add a pin test for this shape so the miss is at least not silent.

@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 closed 1 previous finding(s) this round:

  • src/test/java/dev/thiagogonzaga/thrillhousebot/LogSafeInvariantTest.java:45 — Checker javadoc overstates reach: values crossing method boundaries are never scanned

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working security Security-sensitive issue or hardening testing Test coverage and test quality

Projects

None yet

1 participant