Skip to content

fix(review): floor unmitigated-injection-sink findings at high risk - #594

Merged
devops-thiago merged 2 commits into
release/v0.6.0from
fix/570-587-severity-emission
Aug 12, 2026
Merged

fix(review): floor unmitigated-injection-sink findings at high risk#594
devops-thiago merged 2 commits into
release/v0.6.0from
fix/570-587-severity-emission

Conversation

@devops-thiago

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

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • 🔒 Security

Description

Round 3 reproduced #570's controlled pair with brand new code: the same planted
stored-XSS defect published as CRITICAL, inline, top Key Finding behind
Angular's bypassSecurityTrustHtml and as MEDIUM, collapsed, low confidence
behind React's dangerouslySetInnerHTML. A string-concatenated SQL tenant
filter published the same way — asserted as fact in the walkthrough row,
hedged at MEDIUM/low-confidence in the finding for the same line.

#575 addressed this in PrReviewPrompts.SYSTEM alone. The floor it added is
prose, and nothing downstream enforced it, so two separate mechanisms could
still undo it:

  1. The verifier's severity cap. FindingVerificationService.downgrade
    applies whatever risk the verifier proposes, in the lowering direction,
    with no floor. The verifier's own rejection grounds name "routing and
    rendering semantics" as remembered framework behavior capped at
    "medium" risk with "low" confidence — which is precisely how a
    demonstrated dangerouslySetInnerHTML sink is described. That produces
    exactly the observed MEDIUM + low-confidence pair, and explains why the
    walkthrough (never verified) asserts what the finding (verified) hedges.
  2. The hedging demotion reading the wrong text. demoteHedgedBlockingFindings
    scans the whole title and description for may|might|could|potentially|possibly.
    fix(review): keep security severity on the defect class, not on uncertainty #575's own instruction — "lower the confidence, name in the description the
    exact layer to verify" — puts one of those words into the description of
    every demonstrated-sink finding by construction. The guard then read that
    mandated clause as a hedge of the claim and took the finding's confidence
    from high to medium, which costs it its blocking eligibility
    (BlockingStrictness.BALANCED requires Confidence.HIGH) and stamps a
    "verify before acting" disclaimer on a defect the diff demonstrates.
    Production logs from the round-3 corpus show it firing on
    SQL injection in findPendingByChannel via unsanitized channel.

What changed

A deterministic severity floor, applied to the audit's output. A finding
that names an injection sink and states that nothing sanitizes, escapes,
validates, encodes or parameterizes the value reaching it publishes at no less
than high risk. It is applied in verify, wrapping every path — the model's
own rating, a verifier downgrade, and every fail-open return — and the summary
counts are recomputed when it fires.

Only risk moves, and only upward. Confidence is left exactly where the model or
the verifier put it, which is the separation #570 asks for: severity is a
property of the defect class and its blast radius, while "some layer I was not
shown might neutralize this" is a statement about confidence. Placement
follows severity
: Finding.postsInline keeps a high finding on an inline
thread however low its confidence, so the same defect class stops landing in
the collapsed "Things to double-check" block in one framework and on the diff
in another.

The trigger requires both halves stated in the finding's own words, so it does
not fire on a sink mentioned in passing (an escape hatch rendering a literal
template) or on an unrelated finding that happens to say "unvalidated". A
critical keeps its level — the floor lifts, never lowers.

Both halves are token matches, though, so a sentence that rules the sink out
could satisfy them out of its own negation — "sql injection" matches inside "so
no SQL injection is possible", and the absence group matches "not concatenated
but parameterized", the very mitigation that sentence asserts. Two defeaters,
either of which suppresses the floor anywhere in the text, close that: a
denied sink ("no XSS", "not vulnerable to SQL injection") and a
mitigation the finding says IS present ("it is escaped on render", "React
escapes them"). Negated forms are excluded from the second, so "is not escaped"
stays an absence claim rather than defeating itself, and the denial gap is one
word so "no protection against SQL injection" still reads as an assertion of
the defect. Under-firing leaves a finding where this class already stood;
over-firing escalates a non-defect and, at high confidence, would block the
merge on it — the defeaters take the safe direction.

The hedging demotion now reads clause by clause. A hedge word inside a
clause that names something to verify is the verification request the prompt
mandates next to a demonstrated defect, not a hedge of the defect. A hedge
anywhere else still demotes, so the guard's intent — a claim that hedges itself
cannot request changes on its own — is untouched.

Note on the collapsed-block routing

Routing was investigated and is not itself broken.
Finding.postsInline() keys on Confidence.LOW only, so medium confidence
still posts inline; a critical/high finding posts inline at any confidence.
The collapsed block therefore requires both a below-high risk and low
confidence, which is why the severity floor — not a routing change — is the
fix. No renderer file is touched by this PR.

Related Issues

Fixes #570

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Eight new tests in FindingVerificationServiceTest, including the regression
test #570 asks for (the same class in two frameworks must publish the same
severity and the same placement).

Red output on unfixed code

[ERROR] Tests run: 41, Failures: 5, Errors: 0, Skipped: 0
[ERROR]   FindingVerificationServiceTest.floorsAnUnsanitizedInjectionSinkFindingTheModelRatedBelowHigh:762 expected: <high> but was: <medium>
[ERROR]   FindingVerificationServiceTest.floorsStringBuiltSqlNamedByItsShapeRatherThanTheWordsSqlInjection:787 expected: <high> but was: <medium>
[ERROR]   FindingVerificationServiceTest.keepsAnInjectionSinkFindingAtHighRiskWhenTheVerifierDowngradesIt:807 expected: <high> but was: <medium>
[ERROR]   FindingVerificationServiceTest.ratesBothHalvesOfThePlantedXssPairTheSameAndKeepsBothInline:823 expected: <high> but was: <medium>
[ERROR]   FindingVerificationServiceTest.keepsBlockingConfidenceWhenTheOnlyHedgeSitsInAVerificationRequest:851 expected: <ReviewResponse[findings=[Finding[risk=high, confidence=high, ... title=SQL injection in findPendingByChannel via unsanitized channel, ...]]]> but was: <ReviewResponse[findings=[Finding[risk=high, confidence=medium, ... title=SQL injection in findPendingByChannel via unsanitized channel, ...]]]>

leavesCriticalInjectionFindingsAndUnrelatedRatingsAlone passes before and
after — it pins what the floor must not do.

Red output for the two defeaters

Both defeater tests fail on the floor as first written, each raising a finding
that rules the sink out to high (note confidence=high on the first: it
would have become blocking-eligible):

[ERROR] Tests run: 43, Failures: 2, Errors: 0, Skipped: 0
[ERROR]   FindingVerificationServiceTest.doesNotFloorAFindingThatDeniesTheSinkItNames:900
  expected: <...Finding[risk=low, confidence=high, ... description=The user input is not
  concatenated but parameterized, so no SQL injection is possible; ...]...>
  but was: <...Finding[risk=high, confidence=high, ...]... summary=Summary[..., high=1, ...]>
[ERROR]   FindingVerificationServiceTest.doesNotFloorAFindingThatDescribesTheMitigationAsPresent:925
  expected: <...Finding[risk=low, confidence=high, ... description=React escapes them at
  render, so the XSS exposure is limited to the stored value itself.]...>
  but was: <...Finding[risk=high, confidence=high, ...]... summary=Summary[..., high=1, ...]>

Gates

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

Coverage — jacoco ∩ git diff -U0 fda4bc7...HEAD on changed main code: 60
instrumented changed lines, zero uncovered lines and zero uncovered
branches
.

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 verifier's own prompt (FindingVerifierPrompts.SYSTEM) still lists
"routing and rendering semantics" as a downgrade ground with no carve-out for a
sink whose tainted value is visible in the diff, the way it already carves out
config/IaC, mock-fidelity, heuristic and producer→consumer findings. That file
is outside this change's scope; the deterministic floor makes the outcome
correct regardless of what the verifier proposes, but a matching carve-out
there would stop the verifier spending a downgrade on it in the first place.

The same planted stored-XSS defect published as CRITICAL inline behind
Angular's escape hatch and as MEDIUM in a collapsed low-confidence block
behind React's. Severity is a property of the defect class, so nothing
downstream enforced the prompt's own floor: the review call could rate the
class differently per framework, and the verifier could take it down again
because a rendering or query-dialect semantic "is not verifiable here".

Apply the floor deterministically to the audit's output. A finding that
names an injection sink AND states that nothing sanitizes, escapes,
validates, encodes or parameterizes the value reaching it publishes at no
less than high risk. Only risk moves, and only upward: confidence stays
exactly where the model or the verifier put it, so the hedge survives while
the misclassification does not. Placement follows, since a high-risk finding
opens an inline thread however low its confidence.

Read the hedging demotion clause by clause too. The prompt requires a
demonstrated-sink finding to name the unshown layer to verify, which put a
hedge word into the description of every such finding by construction; a
whole-body scan then read that mandated clause as a hedge of the claim and
stripped the finding's blocking confidence for saying what it was told to
say.

Fixes #570
@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 12, 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

🤖 ThrillhouseBot PR Summary

What this PR does

Wraps the verification pipeline's output in a deterministic severity floor that publishes any finding whose text names an injection sink while claiming no sanitization at no less than HIGH risk, regardless of the model's rating, a verifier downgrade, or fail-open returns, and narrows the hedging-confidence demotion so a hedge word inside a clause that names something to verify no longer strips blocking confidence.

⚠️ Description vs. Implementation

The PR description does not fully match the change:

  • The PR states the floor 'does not fire on a sink mentioned in passing' and 'on an unrelated finding that happens to say unvalidated'; the code fires whenever a sink token and a no-mitigation token coexist in the text, including when the finding explicitly negates the sink ('so no SQL injection is possible') or claims parameterization ('not concatenated but parameterized'). See the medium finding at FindingVerificationService.java:378.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
  A["verify()"]
  B["audit()"]
  C["demoteHedgedBlockingFindings()"]
  D{"hedge word in a clause without a verification cue?"}
  E["confidence HIGH to MEDIUM"]
  F{"verifier enabled?"}
  G["apply verifier verdicts, fail-open return"]
  H["floorInjectionSinkRisk()"]
  I{"risk below HIGH, has sink token, claims no sanitization?"}
  J["raise risk to HIGH, recount summary"]
  K["publish ReviewResponse"]
  A --> B
  B --> C
  C --> D
  D -- "yes" --> E
  D -- "no" --> F
  E --> F
  F -- "yes" --> G
  F -- "no" --> H
  G --> H
  H --> I
  I -- "yes" --> J
  I -- "no" --> K
  J --> K
Loading

Changes Overview

  • Files changed: 2
  • Lines added: +367
  • Lines removed: -4

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationService.java Modified Adds a deterministic high-risk floor for unmitigated injection-sink findings applied in verify() over the extracted audit() path, plus clause-scoped hedging detection with a verification-cue exemption.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationServiceTest.java Modified Adds seven tests: the #570 XSS-pair regression with matching severity and placement, string-built-SQL and verifier-downgrade flooring, clause-scoped hedge demotion, and no-op pinning.

Risk Assessment

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

Key Findings

  • MEDIUM: Floor trigger matches negated sinks and positive mitigation claims (src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationService.java:378)

Things to double-check

1 lower-confidence finding
  • LOW: RiskLevel.fromString(...).ordinal() dereferenced without null guard (src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationService.java:371) (low confidence — verify before acting)

⚠️ CI Checks Status

Some checks are still pending or have failed:

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

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

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown

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: RiskLevel.fromString(...).ordinal() dereferenced without null guard (src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationService.java:371)
    belowInjectionSinkFloor runs RiskLevel.fromString(finding.risk()).ordinal() for every finding in every review, with no null check. The pre-existing same-file isBlockingEligible deliberately handles the same call null-tolerantly (risk == RiskLevel.CRITICAL || risk == RiskLevel.HIGH), and this file's strictRisk returns null for unrecognized labels — an asymmetry suggesting fromString might also be able to yield null (or throw) for a risk string the pipeline did not normalize. If that is possible, one malformed model label would NPE the whole verify() path instead of failing soft, dropping the review. This cannot be confirmed from the provided material: verify RiskLevel.fromString's contract (does it throw, default, or return null on an unrecognized label?) and whether the review-response parser normalizes risk labels before constructing ReviewResponse.Finding. If null is possible, guard the call — e.g., treat an unparseable risk as 'do not floor' (return false) — before calling .ordinal().

@thrillhousebot thrillhousebot Bot added bug Something isn't working security Security-sensitive issue or hardening labels Aug 12, 2026
…the sink out

Both floor signals are token matches scanned over the whole finding text, so a
sentence that DENIES the sink satisfied them out of its own negation: "sql
injection" matched inside "so no SQL injection is possible", and the absence
group matched "not concatenated but parameterized" — the very mitigation the
sentence asserts. The floor then raised that finding to high, and at high
confidence it would go on to block the merge. That is this floor's own failure
mode pointed the other way, and it contradicted the trigger's documented
narrowness.

Add two defeaters, either of which suppresses the floor anywhere in the text: a
denied sink ("no XSS", "not vulnerable to SQL injection"), and a mitigation the
finding says IS present ("it is escaped on render", "React escapes them").
Negated forms are excluded from the second so "is not escaped" stays an absence
claim rather than defeating itself, and the denial gap is one word so "no
protection against SQL injection" is still read as an assertion of the defect.

Under-firing leaves a finding where this class already stood; over-firing
escalates a non-defect. The defeaters take the safe direction.
@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot — changes since the last review

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

Comment on lines +124 to +125
"\\b(unsanitiz\\w*|unescaped|unvalidated|unparameteriz\\w*|non-parameteriz\\w*"
+ "|(no|not|without|missing|never|lacks?|absent)\\s+(\\w+[\\s-]+){0,3}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MEDIUM — NO_MITIGATION cannot match "Nothing sanitizes …" — the floor misses its own trigger phrasing

The floor javadoc defines the trigger as a finding that "states that nothing sanitizes, escapes, validates, encodes or parameterizes the value reaching it publishes at no less than high", and the NO_MITIGATION comment says it captures "the finding's own statement that nothing neutralizes the tainted value". But the pattern's absence-word list is (no|not|without|missing|never|lacks?|absent): the compound words "nothing"/"nobody" are absent, and \bno\b cannot match inside "Nothing" because there is no word boundary after "no". A finding whose body is "Nothing sanitizes the channel value before it reaches innerHTML" matches INJECTION_SINK (innerhtml) but not NO_MITIGATION, so belowInjectionSinkFloor returns false and the finding still publishes at the model's below-high rating — the exact #570 drift this PR exists to prevent. Adding "nothing|nobody" to the negation group fixes it and does not change any in-diff test (the literal-template fixture only contains "nothing user-authored reaches it", which has no sanitize/escape/validate/parameterize root after the negation).

Suggested change
"\\b(unsanitiz\\w*|unescaped|unvalidated|unparameteriz\\w*|non-parameteriz\\w*"
+ "|(no|not|without|missing|never|lacks?|absent)\\s+(\\w+[\\s-]+){0,3}"
"\\b(unsanitiz\\w*|unescaped|unvalidated|unparameteriz\\w*|non-parameteriz\\w*"
+ "|(no|not|without|missing|never|nothing|nobody|lacks?|absent)\\s+(\\w+[\\s-]+){0,3}"

Comment on lines +139 to +140
"\\b(no|not|never)\\s+(\\w+[\\s-]+){0,1}"
+ "(xss|cross[- ]site scripting|sql injection|command injection|shell injection"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MEDIUM — SINK_DENIED swallows "does not prevent SQL injection" — an assertion, not a denial

The SINK_DENIED javadoc claims "an assertion of the defect that merely contains a negation ("no protection against SQL injection") is not mistaken for a denial", and belowInjectionSinkFloor suppresses the floor whenever SINK_DENIED matches. But the pattern \b(no|not|never)\s+(\w+[\s-]+){0,1} plus the sink alternation matches "not prevent SQL injection" (one bridge word between the negation and the sink name). A genuine defect finding — title "SQL injection in findPendingByChannel via unsanitized channel", description "The code does not prevent SQL injection because the channel is concatenated into the query text" — satisfies namesInjectionSink and NO_MITIGATION, then is exempted by SINK_DENIED and never floored. The one-word gap only excludes the two-word "no protection against SQL injection"; assertions phrased as "not prevent/stop/block ... SQL injection" are swallowed, so the under-rated publication #570 describes survives in a very common phrasing. The in-diff denial test uses "not concatenated but parameterized" and does not cover this shape.

Suggested change
"\\b(no|not|never)\\s+(\\w+[\\s-]+){0,1}"
+ "(xss|cross[- ]site scripting|sql injection|command injection|shell injection"
"\\b(no|not|never)\\s+(?!(prevent\\w*|stop\\w*|block\\w*|avoid\\w*|mitigat\\w*|defend\\w*)\\b)"
+ "(\\w+[\\s-]+){0,1}"
+ "(xss|cross[- ]site scripting|sql injection|command injection|shell injection"

@devops-thiago
devops-thiago merged commit b43d40f into release/v0.6.0 Aug 12, 2026
14 checks passed
@devops-thiago
devops-thiago deleted the fix/570-587-severity-emission branch August 12, 2026 07: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 security Security-sensitive issue or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant