Skip to content

fix(github): bound an error body before redacting it, and collapse every line terminator - #740

Merged
devops-thiago merged 3 commits into
mainfrom
fix/731-clean-cap-before-redact
Aug 16, 2026
Merged

fix(github): bound an error body before redacting it, and collapse every line terminator#740
devops-thiago merged 3 commits into
mainfrom
fix/731-clean-cap-before-redact

Conversation

@devops-thiago

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

Copy link
Copy Markdown
Owner

Stacked on #738 (fix/730-retry-after-floor). Based against that branch so the diff reads clean; re-target to main once #738 merges. Only the last commit (154d172) belongs to this PR. Note that ci.yml triggers on pull requests into main/develop/release/** only, so the full CI run happens on re-target — the gates below were run locally on this exact tree.

What type of PR is this?

  • 🐛 Bug fix
  • 🔒 Security

Description

Two defects in the same method, both in the path that exists to explain a failed write.

1. Redaction ran before the cap, and the JWT shape is quadratic — the primary fix

readBody reads the entity with no size bound of its own, and clean() redacted the whole of it, capping at 512 characters only afterwards. So the cost of logging one 4xx was set by whatever the configured API host chose to send.

CREDENTIAL_SHAPED_VALUE's JWT alternative is eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}. [\w-] excludes ., so on a body of repeated eyJ each greedy run consumes to end-of-input, fails to find its separator and backtracks one character at a time — from one position in three. That is quadratic, and it was measured as such:

body size time
20 000 331 ms
40 000 1 213 ms
80 000 4 843 ms
160 000 19 404 ms
1.2 MB ~1 078 623 ms (≈ 18 minutes)

Every doubling costs about 4×. That time is spent on the review's own carrier thread, inside GitHubApiError.from, before the retry decision the cleaned body feeds is even reached.

The fix is the one the audit called for: cut, then redact, then cap.

var collapsed = WHITESPACE.matcher(raw).replaceAll(" ").strip();
var bounded = cutTo(collapsed, MAX_BODY_CHARS * 2);
var redacted = redactCredentials(bounded);
var capped = cutTo(redacted, MAX_BODY_CHARS);
return capped.length() < redacted.length() || bounded.length() < collapsed.length()
    ? capped + "…"
    : capped;

Every regex pass is now bounded at a constant. The pre-cut is twice MAX_BODY_CHARS rather than exactly it because redaction only ever shortens, so the wider window leaves material to fill the 512-char cap with; past that the output was going to be truncated anyway, and what a wider window would pull into view is more of the token-shaped run being masked out. The ellipsis now marks either cut, so a body shortened before redaction is never mistaken for one GitHub sent whole. The surrogate-pair guard that protected the 512 cut is factored into cutTo and now protects both cuts.

2. \s is the ASCII six, so most line terminators survived the collapse

WHITESPACE was \\s+, which java.util.regex reads as [ \t\n\x0B\f\r] unless the pattern asks for Unicode character classes. CR and LF being collapsed closes the classic forged-record vector, but NEL (U+0085), LINE SEPARATOR (U+2028), PARAGRAPH SEPARATOR (U+2029), NUL and the ANSI escape all reached the warn line intact — and a log viewer, a terminal, or a JSON/ECS shipper may treat any of them as a record boundary or a screen-control sequence. The class already documents a body as attacker-influenced text on its way to a log file and already pays for a collapse pass on that basis; the pass simply did not cover the class it claimed to.

Now [\\s\\p{IsCc}\\u2028\\u2029]+\p{IsCc} is the Unicode general category rather than POSIX \p{Cntrl}, so it reaches the C1 controls (U+0080–U+009F, NEL among them) as well as C0 and DEL. The strip moved after the collapse so a terminator at either end does not survive as a stray space.

Not in this PR

  • The finding.file() interpolation (ReviewPublisher.java:752 and :814) — the third item on the issue. ReviewPublisher is being changed by separate work in this round, so touching it here would conflict; it needs MarkdownSafe.oneLine at both warn sites and is left to that change.
  • Bounding readEntity itself. The audit lists it as optional. Every regex pass is bounded now, and the remaining cost of a huge body is one linear collapse pass plus the read that already happened.
  • A5's "match on more than the log-shaped body". That is a separate finding about which string the retry decision reads, not about the cost of producing it.

Related Issues

Fixes #731

How Has This Been Tested?

  • Unit tests

Four tests added to GitHubApiErrorTest.BodyHandling, all reusing the audit's probes:

  • doesNotScanAWholeOversizedBodyLookingForCredentials — 200 KB of eyJ (probe P2c), bounded at 2 000 ms. The bound is enormously slack against what this costs once the body is cut first; it is sized to fail only on the quadratic, never on a slow machine.
  • collapsesTheLineTerminatorsAndControlsThatAreNotAsciiWhitespace — probe P3b's body, asserted as one clean line.
  • stripsALineTerminatorAtEitherEndRatherThanLeavingASpace
  • marksABodyCutBeforeRedactionAsTruncatedEvenWhenTheMaskFitsUnderTheCap — a 4 000-char bearer value masks to ***, and the result must still say it was cut.

The existing capsAnOverLongBodySoOneFailureCannotFloodTheLog and neverCutsAnOverLongBodyThroughASurrogatePair are unchanged and still pass, which is what pins the cap and the surrogate guard through the restructuring.

Verbatim red output on unfixed code

[ERROR] Tests run: 15, Failures: 4, Errors: 0, Skipped: 0, Time elapsed: 95.25 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.github.GitHubApiErrorTest$BodyHandling
[ERROR]   GitHubApiErrorTest.doesNotScanAWholeOversizedBodyLookingForCredentials cleaning a 200 KB body took 92877ms ==> expected: <true> but was: <false>
[ERROR]   GitHubApiErrorTest.collapsesTheLineTerminatorsAndControlsThatAreNotAsciiWhitespace expected: <a WARN forged-by-NEL WARN forged-by-LS WARN forged-by-PS NUL [2J ansi> but was: <a?WARN forged-by-NEL ?WARN forged-by-LS ?WARN forged-by-PS  NUL  ansi>
[ERROR]   GitHubApiErrorTest.stripsALineTerminatorAtEitherEndRatherThanLeavingASpace expected: <boom> but was: <boom ?>
[ERROR]   GitHubApiErrorTest.marksABodyCutBeforeRedactionAsTruncatedEvenWhenTheMaskFitsUnderTheCap expected: <***?> but was: <***>

(The ? are the terminal's rendering of the surviving U+0085 / U+2028 / U+2029 and of the ellipsis.)

92 877 ms for one 200 KB body — this machine is slower than the one the audit measured 24 s on, which only sharpens the point. After the fix the whole 15-test BodyHandling nest runs in 3.4 s, and all four go green:

[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.393 s -- in dev.thiagogonzaga.thrillhousebot.github.GitHubApiErrorTest$BodyHandling

Gates

  • ./mvnw -B spotless:apply → clean
  • ./mvnw -B clean compile spotbugs:check spotless:checkBugInstance size is 0, BUILD SUCCESS
  • ./mvnw -B clean testTests run: 3290, Failures: 0, Errors: 0, Skipped: 0
  • jacoco ∩ git diff -U0 on this commit's main code → 0 uncovered lines, 0 uncovered branches (both conditions of the ellipsis test and both arms of the surrogate guard are exercised)

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

See the verbatim red output above.

Additional Notes

Severity is latent, not live: GitHub's real error bodies are around 300 characters, and no path was found by which a PR author gets GitHub itself to echo hundreds of kilobytes into a 4xx body. It needs a large body from the configured API host — a GHES or reverse-proxy error page, a misconfigured base URL, a compromised endpoint. The redaction half is pre-existing from #704 (v0.6.2); #723 made the same clean() output load-bearing for a retry decision, and promoted a debug line to warn, which is what widened the exposure of the second half.

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

Bounds the error-body logging path: clean() now collapses Unicode controls and line/paragraph separators, pre-cuts the collapsed body to 1024 chars before credential redaction, caps at 512 chars after, and appends an ellipsis when either cut fired; factors the surrogate-pair guard into a shared cutTo helper and adds four BodyHandling tests covering cost, control collapsing, edge stripping and the ellipsis semantics.

⚠️ Description vs. Implementation

Every mismatch found between the description and the change is reported as a finding below, so it is not repeated here.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
  A["raw body from response entity"] --> B{"raw == null?"}
  B -- "yes" --> Z["return empty string"]
  B -- "no" --> C["collapse controls, separators and ASCII whitespace to single spaces"]
  C --> D["strip leading and trailing spaces"]
  D --> E["cut to 1024 chars, never through a surrogate pair"]
  E --> F["redact credential-shaped values"]
  F --> G["cut to 512 chars, never through a surrogate pair"]
  G --> H{"either cut happened?"}
  H -- "yes" --> I["append ellipsis"]
  H -- "no" --> J["return capped text"]
  I --> K["return capped text plus ellipsis"]
Loading

Changes Overview

  • Files changed: 2
  • Lines added: +109
  • Lines removed: -12

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java Modified Reorder clean() collapse/pre-cut/redact/cap pipeline; widen WHITESPACE to Unicode controls; add cutTo.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiErrorTest.java Modified Four new BodyHandling tests: bounded redaction cost, Unicode terminator collapse, edge strip, pre-cut ellipsis.

Risk Assessment

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

Key Findings

  • LOW: Javadoc says every regex pass is bounded at a constant; the collapse pass still scans the whole body (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:424)

Things to double-check

1 lower-confidence finding
  • MEDIUM: Pre-cut can split an unlabeled JWT so redaction misses it and payload chars reach the log (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:435) (low confidence — verify before acting)

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

@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):

  • MEDIUM: Pre-cut can split an unlabeled JWT so redaction misses it and payload chars reach the log (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:435)
    Input not in the diff: a collapsed body longer than 1024 chars in which an unlabeled JWT (no "Bearer " prefix) starts within the first ~512 chars and is long enough that the pre-cut lands inside its middle (in the payload segment before the second dot, or within the first 7 chars after the final dot). var bounded = cutTo(collapsed, MAX_BODY_CHARS * 2); truncates the token before redactCredentials(bounded) (line 436) runs. The JWT alternative of CREDENTIAL_SHAPED_VALUE, quoted verbatim in this PR's description and Javadoc as eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}, requires all three dot-separated segments with 8+ chars after each dot inside the 1024-char window. When the window ends inside the payload before its closing dot, or with fewer than 8 chars after the final dot, the alternative fails and the truncated token is returned unredacted; the 512-char cap then logs the prefix plus the JWT header and a large part of the payload verbatim (e.g. ~100 chars of prefix + a ~1100-char JWT whose second dot sits beyond index 1024 exposes header + ~387 payload chars). The removed code ran redactCredentials(collapsed) over the whole collapsed body first, so the complete token matched and was masked before the cap — this is a regression of the redaction guarantee on the very path this PR hardens, under the same threat model the PR cites (hostile bodies from a compromised endpoint). The in-diff tests do not exercise this shape: the 200 KB test body is dotless "eyJ" runs that match in neither version, and the bearer test is rescued by the "Bearer" label alternative. Verify the full CREDENTIAL_SHAPED_VALUE definition — if another alternative matches a bare word-char run (e.g. a generic [\w-]+ without the dot structure), the leak is closed. Otherwise the pre-cut must not split a token: redact the tail before cutting, or add a truncation-tolerant alternative that masks an unterminated eyJ-shaped prefix at the window boundary.

@thrillhousebot thrillhousebot Bot added bug Something isn't working performance Speed or resource-usage improvement security Security-sensitive issue or hardening labels Aug 16, 2026
Base automatically changed from fix/730-retry-after-floor to main August 16, 2026 03:05
…ery line terminator

clean() redacted the whole body and capped it only afterwards, so the
credential patterns scanned however much the configured API host chose
to send. The JWT alternative backtracks from one position in three,
which is quadratic: 20 000 chars cost 331ms, 160 000 cost 19 404ms, and
a 1.2 MB body about eighteen minutes of CPU inside GitHubApiError.from
— on the review's own carrier thread, in the path that exists to
explain a failed write. Cutting to twice the 512-char cap first bounds
every pass at a constant; the ellipsis now marks either cut.

The collapse pass also used \s, which java.util.regex reads as the
ASCII six, so NEL, LINE SEPARATOR, PARAGRAPH SEPARATOR, NUL and the
ANSI escape reached the log line intact — enough to forge what reads as
a second log record. It now covers the Unicode Cc category and the two
separators.

Fixes #731
@devops-thiago
devops-thiago force-pushed the fix/731-clean-cap-before-redact branch from 154d172 to 163cde9 Compare August 16, 2026 03:21
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

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

Scanned Files

None

@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

Additionally, No new issues in this revision, but 2 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.

@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!

Bounding the body before redacting narrowed what redaction covers: a JWT
whose second dot fell past the bound no longer matched the three-segment
shape, so it went through unmasked and the cap logged its header and the
payload characters that fit. The third segment is now optional, which
masks a token cut mid-payload; the first dot stays mandatory, since a run
of word characters carrying no dot is not a JWT and matching one would
blank the body this line exists to explain.

Also corrects the clean() javadoc: the whitespace collapse is one linear
pass over the whole body, and it is the cut that bounds the redaction
pass, not every pass.

Refs #731

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:435 — Pre-cut can split an unlabeled JWT so redaction misses it and payload chars reach the log

Confirmed and fixed, not deferred — the finding was right that bounding before redaction narrowed what redaction covers, and that this PR introduced it. Reproduced verbatim before the fix:

org.opentest4j.AssertionFailedError: xxxx…xxxx eyJaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.PPPPPPPP…PPPP? ==> expected: <false> but was: <true>

The JWT's third segment is now optional, so a token the bound cut mid-payload is masked from its eyJ header onward. The first dot stays mandatory deliberately: my first attempt made both tail segments optional, which matched any long run of word characters and swallowed an entire oversized body into ***doesNotScanAWholeOversizedBodyLookingForCredentials caught it, and blanking 512 characters of diagnostic body would have been a worse trade than the hole being closed.

One correction to my own reasoning in passing: with the mandatory first dot this does not eliminate the quadratic backtracking, so the 1024-char bound remains the thing that keeps the cost constant. The javadoc says that now rather than claiming otherwise.

The LOW on the same file (:424, javadoc overstating that cutting bounds every regex pass) is also fixed in the same commit — the whitespace collapse is one linear pass over the whole body, and the text now says so.

New test: masksAJwtTheBoundCutMidPayload. Suite 3302 green, BugInstance 0, changed lines and branches fully covered.


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: 3
  • Previous findings resolved: 2
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:435 — Pre-cut can split an unlabeled JWT so redaction misses it and payload chars reach the log
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:424 — Javadoc says every regex pass is bounded at a constant; the collapse pass still scans the whole body
  • Previous findings still open: 0

Comment thread src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java Outdated
Comment thread src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.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 noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • LOW: Bidi format controls (U+202E/U+2066, category Cf) survive the collapse and can reorder log text (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:113)
    Input not in the diff: a 4xx body containing U+202E (RIGHT-TO-LEFT OVERRIDE) or U+2066 (LEFT-TO-RIGHT ISOLATE), Unicode category Cf rather than Cc. The new collapse class [\s\p{IsCc}\u2028\u2029]+ covers ASCII whitespace, C0/C1 controls (NUL, ESC, NEL, ...) and U+2028/U+2029, but not Cf format controls, so bidi overrides reach the WARN line unchanged. The Javadoc's own contract for this pass is that a "log viewer, a terminal, or a JSON/ECS shipper" must not receive record boundaries or screen-control sequences; a terminal that honors the Unicode bidi algorithm (iTerm2, Windows Terminal; xterm-class terminals typically ignore it) renders the text after the override right-to-left, which can reorder or hide log content — the same log-forgery family the PR fixes, though weaker (visual reordering, not record splitting). Verify whether the deployment's log consumers honor bidi overrides; if they do, extend the class with \p{Cf}, which also collapses ZWSP/ZWNJ/BOM — harmless in a one-line log.

… controls

A per-segment length floor left a token unmasked when the bound cut within
the first few payload characters, and a masked bearer run ahead of it
shortens the text so the leak lands inside the cap rather than past it.
Everything after the first dot is now optional in length and count. The
first dot stays mandatory, so the one unmasked shape is a cut before it -
the header prefix, which carries no secret; the javadoc says that instead
of claiming every cut is covered.

The collapse also takes Cf format controls: a bidi override is not Cc, and
reordering what an operator reads is the same family of harm as splitting
the record.

Refs #731

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:113 — Bidi format controls (U+202E/U+2066, category Cf) survive the collapse and can reorder log text

Fixed rather than deferred. The collapse class now takes \p{IsCf} alongside \p{IsCc}. Red first, verbatim:

org.opentest4j.AssertionFailedError: expected: <before after isolated> but was: <before?after?isolated>

Reasoning for taking it rather than gating it on whether the deployment's consumers honour bidi: the javadoc's contract for this pass is that no log viewer, terminal or shipper receives control sequences, and that contract shouldn't depend on which terminal happens to read the line. Collapsing ZWSP/ZWNJ/BOM along with it is harmless on a single-line log.

New test: collapsesBidiOverridesThatCouldReorderTheLoggedLine.


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: 3
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:80 — Pre-cut still splits a JWT whose visible segment is under 8 chars, leaking header and payload prefix
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:71 — Javadoc claims any mid-payload cut is masked; segments shorter than 8 chars pass unmasked
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:113 — Bidi format controls (U+202E/U+2066, category Cf) survive the collapse and can reorder log text
  • 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 trivy is pending
  • Check test is pending
  • Check format is pending
  • Check dependency-review is pending
  • Check frontend is pending

@sonarqubecloud

Copy link
Copy Markdown

devops-thiago added a commit that referenced this pull request Aug 16, 2026
… log gets (#753)

> **Stacked on #750 (`fix/746-credential-shapes`).** Based against that
branch so the diff reads clean; **re-target to `main` once #750
merges**. Only the last commit (`afb2d41`) belongs to this PR. `ci.yml`
triggers on pull requests into `main`/`develop`/`release/**` only, so
the full CI run happens on re-target — the gates below were run locally
on this exact tree.

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

One change closing two issues, because they are the same coupling seen
twice.

`isThrottled()` and `blocksContentCreation()` matched against
`this.body` — the string `diagnostics()` prints. So every narrowing the
log line asks for narrowed the retry decision with it, and the
consequence is not a shorter wait: `GitHubWriteRetry.retryDelay` returns
`Optional.empty()` on `!isThrottled()`, the `WebApplicationException` is
rethrown on the first attempt, and the write is **not repeated at all**
— the pre-#495 behaviour this area exists to prevent. The 30-second
floor #738 just widened is never consulted.

**#732** is the 512-character cap doing it: a body with a long
`documentation_url` or echoed headers ahead of the message classifies as
a refusal. Equally true at v0.6.3.

**#747** is the bound #740 put on the redaction *input*. v0.6.3 had
exactly one path by which wording deeper than the cap still survived —
redaction compressing a long credential-shaped prefix to `***` and
carrying the message forward — and cutting to 1024 before redacting
closed it. Threshold sweep from the audit, body = `"Bearer " +
"a".repeat(n) + " " + <content-creation block>`:

| prefix | v0.6.3 | v0.6.4 |
|---|---|---|
| 900 chars | throttled, `PT30S` | throttled, `PT30S` |
| 1010 chars | throttled | **not throttled**, `PT5S` |
| 4000 chars | throttled | **not throttled**, `PT5S` |

Same remedy for both, so they land together.

**The fix.** One collapse pass now feeds two readings that are kept
apart in a small `Body` record. The logged line is unchanged — same
`bounded → redacted → capped` order, same ellipsis, same 512 characters.
Classification reads the collapsed body bounded at 8 KB and nothing
else. The bound is still wanted (the entity is read with no size limit
of its own, and #731 is about not letting the configured host set the
cost of explaining a failed write), but both patterns are flat literal
alternations with no backtracking, so widening the window costs a linear
scan; the quadratic shape #731 found is in the credential redaction,
which still sees only its own 1024.

Deliberately the **unredacted** text. Masking runs before the classifier
could read it, and a mask that swallowed the word `blocked` turned a
content-creation block into a permission refusal — the classification
tail of the JWT over-match in #746. Nothing in that window is ever
logged or returned; the two patterns answer yes or no and the string is
dropped.

**Also, the `Instant.ofEpochSecond` guard #732 asks for in its second
half.** `Long.parseLong` accepts values `Instant.ofEpochSecond` rejects,
and the resulting `DateTimeException` was thrown from inside
`derivedDelay`, out through `GitHubWriteRetry.retryDelay` and `call`,
past every `catch (WebApplicationException)` in the write path.
`GitHubLostWrites.recording` catches that type specifically, so a write
that died this way was not remembered as lost either — the write and the
record of its loss went together, over one header from an intermediary.
A value that cannot be an instant now means what a non-numeric header
already means here: unspecified, and the linear fallback takes over.

Neither classification bug is reachable against api.github.com, whose
error bodies are ~300 characters with the message first; both need a
large body from the configured API host. The header case needs an
intermediary sending a ~10^17 reset.

## Related Issues

Fixes #747
Fixes #732

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Eight new behavioural assertions, all red on the parent branch
(`1e0d7fc`) in exactly the claimed way and green after. Verbatim, from
`./mvnw -o test -Dtest=GitHubApiErrorTest,GitHubWriteRetryTest` with
only the test files applied:

```
[ERROR] Failures:
[ERROR]   GitHubApiErrorTest.isStillReadWhenItSitsPastTheLengthCapTheLogLineUses body={"documentation_url":"xxxxx…xxx… ==> expected: <true> but was: <false>
[ERROR]   GitHubApiErrorTest.isStillReadWhenMoreCredentialShapedMaterialPrecedesItThanTheRedactionBoundHolds body=***… ==> expected: <true> but was: <false>
[ERROR]   GitHubApiErrorTest.isNotSomethingTheCredentialMaskCanDeleteBeforeItIsRead body={"message":"prefix *** from content creation"} ==> expected: <true> but was: <false>
[ERROR]   GitHubApiErrorTest.isReadFromABoundedWindowRatherThanFromAnUnboundedBody expected: <true> but was: <false>
[ERROR]   GitHubWriteRetryTest.anOutOfRangeResetHeaderStillFailsAsTheExceptionEveryCallerCatches:332 Unexpected exception type thrown, expected: <jakarta.ws.rs.WebApplicationException> but was: <java.time.DateTimeException>
[ERROR] Errors:
[ERROR]   GitHubApiErrorTest.aRateLimitResetTooLargeToBeAnInstantIsTreatedAsUnspecified » DateTime Instant exceeds minimum or maximum instant
[ERROR]   GitHubApiErrorTest.aRateLimitResetTooSmallToBeAnInstantIsTreatedAsUnspecified » DateTime Instant exceeds minimum or maximum instant
[ERROR]   GitHubWriteRetryTest.anOutOfRangeResetHeaderOnAThrottleFallsBackToTheLinearWait:356 » DateTime Instant exceeds minimum or maximum instant

[ERROR] Tests run: 93, Failures: 5, Errors: 3, Skipped: 0
```

The escape path in full, from the same run — this is the whole of the
second finding:

```
java.time.DateTimeException: Instant exceeds minimum or maximum instant
	at java.base/java.time.Instant.ofEpochSecond(Instant.java:308)
	at …GitHubApiError.lambda$derivedDelay$0(GitHubApiError.java:325)
	at …GitHubApiError.derivedDelay(GitHubApiError.java:325)
	at …GitHubApiError.retryDelay(GitHubApiError.java:312)
	at …GitHubWriteRetry.retryDelay(GitHubWriteRetry.java:254)
	at …GitHubWriteRetry.call(GitHubWriteRetry.java:189)
```

Two of the six new `GitHubApiError` tests are labelled **controls**
rather than proof, and both are green before the fix:
`doesNotWidenWhatReachesTheLog` (the log line keeps its own
512-character cap and its ellipsis whatever the classifier may see) and
`doesNotMakeAPermissionRefusalLookLikeAThrottle` (a 4 KB body with none
of the wording is still a refusal, so the wider window did not make the
classifier credulous).
`isReadFromABoundedWindowRatherThanFromAnUnboundedBody` is half proof
and half honest edge: wording at 4 000 characters is now read, wording
at 64 000 is still not.

Gates on this tree:

- `./mvnw -B spotless:apply` → clean
- `./mvnw -B clean compile spotbugs:check spotless:check` →
**BugInstance size is 0**, Error size is 0, spotless clean
- `./mvnw -B clean test` → **Tests run: 3320, Failures: 0, Errors: 0,
Skipped: 0**
- jacoco ∩ `git diff -U0 fc54d93...HEAD` over changed main code → 139
changed lines, **zero uncovered lines and zero uncovered branches**

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

`GitHubWriteRetry`'s main code is untouched — the guard belongs where
the exception is raised, and the retry loop's contract (a
`WebApplicationException` in, the same one back out) is what the new
test pins from the outside.

The 8 KB window is a judgement call, not a measurement: it is sixteen
times the log cap and several times any body GitHub sends, chosen so the
classifier stops depending on where in a body the message sits while the
pass over it stays a linear scan.
devops-thiago added a commit that referenced this pull request Aug 16, 2026
…ry text (#750)

## What type of PR is this?

- [x] 🐛 Bug fix
- [x] 🔒 Security

## Description

Two halves of one oversight in `GitHubApiError`'s credential shapes,
both introduced by #740.

**1. The bound strands a token prefix unmasked (security, bounded at ≤9
characters).**
`clean()` cuts the collapsed body to 1024 characters *before*
`redactCredentials`. The JWT alternative was widened in #740 precisely
so a token the cut severs is still masked — but `gh[pousr]_\w{10,}`,
`github_pat_\w{10,}` and `bearer\s+[\w.~+/=-]{10,}` kept their
ten-character floors. A token straddling index 1024 with six to nine
value characters on the visible side falls below every one of those
quantifiers, stops matching, and reaches the warn line. v0.6.3 redacted
the whole collapsed body and capped afterwards, so the same input was
masked whole.

The three floors drop to `{4,}`. The sigil is the discriminator —
`ghp_`, `github_pat_` and `Bearer ` do not occur in prose — so the
length was buying nothing the sigil did not already buy, while costing
the one case the pre-cut can produce. The cap site cuts *after*
redaction and can never strand, so this is the only site that could.

**2. The widened JWT shape masks ordinary text.**
`eyJ[\w-]{8,}(?:\.[\w-]*){1,2}` compiled under a pattern-wide `(?i)`
with an unanchored header, so it matched `eyj` in any case, anywhere
inside a longer run, followed by a dot at any distance. Measured on the
shipped code:

| input | v0.6.4 |
|---|---|
| `cannot resolve host eyjafjallajokull.internal.example.com` | `cannot
resolve host ***.com` |
| `request id 7f3aeyJQm9keVRleHRIZXJl.log not found` | `request id
7f3a*** not found` |

That is the exact outcome the shape's own javadoc says it was narrowed
to avoid, and it is not fail-safe for either job the line has —
explaining a failure to an operator, and feeding the retry decision,
which reads the redacted body.

A JWT header is base64url of `{"` and is therefore always literally
`eyJ`, so `(?i)` is scoped to the bearer alternative alone and a
negative lookbehind on `[\w-]` pins the header to a token boundary. The
cut-token property #740 added is untouched: a cut anywhere past the
first dot still masks the whole run, which
`masksAJwtTheBoundCutWithinTheFirstPayloadCharacters` and
`masksAJwtTheBoundCutMidPayload` still pin.

**Doc.** `\p{IsCf}` is in the shipped collapse class but was missing
from the javadoc paragraph that exists to explain that class. It is the
half with the widest blast radius (bidi overrides, ZWJ/ZWNJ/ZWSP, the
BOM, the soft hyphen), so it now carries its own defence, including the
accepted cost — an echoed user string loses its grapheme clusters — and
why the replacement is a space rather than a deletion.

Not reachable against api.github.com, which does not echo credentials
and whose error bodies are ~300 characters. Both halves need the same
non-GitHub host the #740 severity note already describes: a GHES or
reverse-proxy error page, a misconfigured base URL, a compromised
endpoint.

## Related Issues

Fixes #746

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Five new behavioural tests, all red on `fc54d93` in exactly the claimed
way and green after. Verbatim, from `./mvnw -o test
-Dtest=GitHubApiErrorTest` with only the test file applied:

```
[ERROR] GitHubApiErrorTest.isStillMaskedForATokenPrefix ***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,ghp_A1b2C3d4E… ==> expected: <false> but was: <true>
[ERROR] GitHubApiErrorTest.isStillMaskedForAFineGrainedPersonalAccessToken ***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,github_pat_A1b2C3d4E… ==> expected: <false> but was: <true>
[ERROR] GitHubApiErrorTest.isStillMaskedForABearerValue ***,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,Bearer S3cr3tV4l… ==> expected: <false> but was: <true>
[ERROR] GitHubApiErrorTest.doesNotMaskOrdinaryTextThatMerelyBeginsLikeAJwtHeaderInSomeOtherCase expected: <{"message":"cannot resolve host eyjafjallajokull.internal.example.com"}> but was: <{"message":"cannot resolve host ***.com"}>
[ERROR] GitHubApiErrorTest.doesNotMaskAJwtHeaderFoundInsideALongerRunOfWordCharacters expected: <{"message":"request id 7f3aeyJQm9keVRleHRIZXJl.log not found"}> but was: <{"message":"request id 7f3a*** not found"}>

[ERROR] Tests run: 55, Failures: 5, Errors: 0, Skipped: 0
```

`masksABearerHeaderWhateverCaseItArrivedIn` is a **control**, not proof
— it is green before the fix as well. It pins the half of the `(?i)`
that has to survive being scoped to one alternative, so a later
tightening cannot quietly drop bearer's case-insensitivity.

Gates on this tree:

- `./mvnw -B spotless:apply` → clean
- `./mvnw -B clean compile spotbugs:check spotless:check` →
**BugInstance size is 0**, Error size is 0, spotless clean
- `./mvnw -B clean test` → **Tests run: 3310, Failures: 0, Errors: 0,
Skipped: 0**
- jacoco ∩ `git diff -U0 fc54d93...HEAD` over changed main code → 33
changed lines, **zero uncovered lines and zero uncovered branches**

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

Scoped to the two credential patterns and the collapse javadoc. The
ordering in `clean()` is unchanged — the bound before redaction is
#731's fix and stays — so the 200 KB timing test
(`doesNotScanAWholeOversizedBodyLookingForCredentials`) and the
oversized-body ellipsis test stay green; anchoring the header in fact
removes the last backtracking path on an `eyJ`-dense run.

The other consequence named in the audit — redaction eating the wording
`isThrottled()` reads — is not addressed here. It is fixed by
classifying ahead of redaction, which is #747/#732's change, stacked on
this branch.
devops-thiago added a commit that referenced this pull request 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
devops-thiago added a commit that referenced this pull request Aug 17, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working performance Speed or resource-usage improvement security Security-sensitive issue or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error-body logging: quadratic redaction on large bodies, and line terminators that survive collapsing

1 participant