Skip to content

fix(github): mask a credential the bound cut, and stop masking ordinary text - #750

Merged
devops-thiago merged 3 commits into
mainfrom
fix/746-credential-shapes
Aug 16, 2026
Merged

fix(github): mask a credential the bound cut, and stop masking ordinary text#750
devops-thiago merged 3 commits into
mainfrom
fix/746-credential-shapes

Conversation

@devops-thiago

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

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • 🔒 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?

  • 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:checkBugInstance size is 0, Error size is 0, spotless clean
  • ./mvnw -B clean testTests 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

  • 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

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.

…ry text

#740 bounded the body to 1024 characters before redacting it, and widened
the JWT alternative so a token the bound severs is still masked. The same
reasoning was never carried to the other three shapes: `gh[pousr]_`,
`github_pat_` and `bearer` still required ten value characters, so a token
the bound cut below that floor stopped matching and reached the warn line
with up to nine characters of the secret intact. v0.6.3 redacted the whole
collapsed body before capping and masked the same input whole.

The floors drop to four. The sigil is the discriminator here — `ghp_`,
`github_pat_` and `Bearer ` do not occur in prose — so the length was
buying nothing the sigil did not already buy, and cost the one case the
bound can produce.

The other half of the same oversight is the widened JWT shape. It compiled
under a pattern-wide `(?i)` and its header was unanchored, so it matched
`eyj` in any case, anywhere inside a longer run, followed by a dot at any
distance: `eyjafjallajokull.internal.example.com` came out as `***.com`,
and an `EyJ` in the middle of a request id blanked four hundred characters
around it. That is the outcome the shape's own javadoc says it was narrowed
to avoid. A JWT header is base64url of `{"` and is therefore always
literally `eyJ`, so the case-insensitivity is scoped to the bearer half and
`(?<![\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.

Also documents `\p{IsCf}` in the javadoc that exists to explain the
collapse class, which listed only `\p{IsCc}`.
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

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

Scanned Files

None

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

Fixes #746: credential redaction floors drop from ten to four characters so a token severed by clean()'s 1024-char pre-redaction bound is still masked, and the JWT shape's case-insensitivity is scoped to the bearer alternative and anchored to a token boundary so ordinary text like 'eyjafjallajokull' is no longer masked. The whitespace-collapse javadoc is expanded to document the \p{IsCf} character class.

⚠️ 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["error body arrives"] --> B["collapse unicode and whitespace runs"]
  B --> C{"len >= 1024?"}
  C -- yes --> D["cut body at 1024 before redaction"]
  C -- no --> E["scan for leftmost credential shape"]
  D --> E
  E --> F{"bearer value with 4+ chars, any case?"}
  F -- yes --> G["mask value as ***"]
  F -- no --> H{"eyJ header at token boundary with a dot ahead?"}
  H -- yes --> G
  H -- no --> I{"ghp_ family or github_pat_ prefix with 4+ chars?"}
  I -- yes --> G
  I -- no --> J["text stays intact"]
  G --> K["cap redacted body at 1024"]
  J --> K
  K --> L["warn and log; retry and throttle decision reads body"]
Loading

Changes Overview

  • Files changed: 2
  • Lines added: +115
  • Lines removed: -2

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java Modified Lowers cred floors to 4; scopes (?i) to bearer; anchors eyJ to a token boundary; javadoc.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiErrorTest.java Modified Adds tests for cut-token masking, eyJ prose non-masking, and a bearer case-control.

Risk Assessment

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

Key Findings

  • MEDIUM: Bearer floor lowered to {4,} masks ordinary prose like 'bearer token' (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:105)

Things to double-check

1 lower-confidence finding
  • LOW: 'always literally eyJ'javadoc claim is arithmetically wrong; digit-key JWT headers are missed (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:95) (low confidence — verify before acting)

⚠️ Required CI Checks Status

Some required checks are still pending or have failed:

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

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

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: 'always literally eyJ'javadoc claim is arithmetically wrong; digit-key JWT headers are missed (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:95)
    Line 95's new javadoc asserts a JWT header 'is base64url of {"' and is therefore always literally 'eyJ''. The arithmetic does not support 'always': base64url of the two bytes {" is 'eyI=' (16 bits -> three chars), and the third base64 character is 0010 plus the top two bits of the third byte, so it is 'J' only when the header's first key character is a letter (0x40-0x7F). Input not in the diff: a valid JWT whose JSON header starts with a digit key, e.g. {"1":"HS256"}, encodes to 'eyIx...'; an empty-string key {"":...} encodes to 'eyIi...'. Neither matches the anchored 'eyJ' literal at line 105, so such a token's payload would reach the log unmasked. Practical exposure is limited to nonstandard headers — standard headers ('alg', 'typ', 'kid'...) do start with 'eyJ' — so verify whether any configured endpoint (GHES/reverse proxy) could emit such tokens. Either document the assumption or widen the anchor (e.g. ey[I-L]) after assessing the prose-overmasking trade-off.

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

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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

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

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:95 — 'always literally eyJ'javadoc claim is arithmetically wrong; digit-key JWT headers are missed

Correct, and the claim was mine. Verified the arithmetic before changing anything:

{"alg":"HS256"}    -> eyJhbG
{"typ":"JWT"}      -> eyJ0eX
{"kid":"a"}        -> eyJraW
{"1":"HS256"}      -> eyIxIj
{"":"x"}           -> eyIiOi

The third character is fixed by the first key's leading byte, not by the braces, so "always" was wrong. The javadoc now says what actually holds: ey plus a third character that is J for any first key beginning with a letter, which RFC 7515 guarantees in practice by requiring alg.

I did not widen the anchor to ey[IJ]. A digit-first or empty-string JWT header is legal JSON that no issuer emits, and this same PR is closing an over-masking defect where the shape ate ordinary text — spending that ground again for a token shape nobody produces is the wrong side of the trade. The assumption is documented instead, which is the other option your finding offered.


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.

…each

The bearer and JWT shapes shared a single alternation, which two static
analysis rules objected to: the JWT half needed its capturing group only to
sit beside the bearer half, and the combined pattern's complexity scored 22
against a budget of 20.

Splitting them costs nothing, because the redaction pass was never tied to
two patterns — it scans an array of matchers, takes the leftmost match, and
prefers the earlier shape on a tie. The array simply grows by one, and the
start sentinels are filled to its length rather than written out.

Also adjusts the surrounding wording for readability: the union javadoc and
the quadratic-cost note now name the pattern each describes, the eyJ anchor
is explained as the arithmetic it is rather than a claim about spelling, and
the bearer shape records that masking only the value was tried and loses a
credential tail on an overlapping match.
@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 0
  • Previous findings resolved: 1
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java:95 — 'always literally eyJ'javadoc claim is arithmetically wrong; digit-key JWT headers are missed
  • 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 test is pending
  • Check frontend is pending
  • Check trivy is pending
  • Check format is pending
  • Check dependency-review is pending

@devops-thiago
devops-thiago merged commit df4d727 into main Aug 16, 2026
17 checks passed
@devops-thiago
devops-thiago deleted the fix/746-credential-shapes branch August 16, 2026 19:35
devops-thiago added a commit that referenced this pull request Aug 16, 2026
…mpile flag (#754)

## What type of PR is this?

- [x] ♻️ Refactor

## Description

#750 split `CREDENTIAL_SHAPED_VALUE` into `BEARER_SHAPED_VALUE` and
`JWT_SHAPED_VALUE` to get the combined pattern's complexity back under
budget. That split left one loose end, which the analysis on that PR
flagged and which merged with it:

```
java:S6395 | MAJOR | GitHubApiError.java:134 | Unwrap this unnecessarily grouped subpattern.
```

The rule is right. `(?i:bearer\s+[\w.~+/=-]{4,})` existed to scope
case-insensitivity to the *bearer alternative* of a two-alternative
pattern — that scoping was #746's fix, and it is why
`eyjafjallajokull.internal.example.com` stopped coming out as `***.com`.
Once the alternation was gone there was nothing left to scope the flag
away from, so the group wraps the entire pattern and does nothing.

The flag moves to the compile call:

```java
Pattern.compile("bearer\\s+[\\w.~+/=-]{4,}", Pattern.CASE_INSENSITIVE)
```

**Behaviour is identical, and the identity matters.**
`Pattern.CASE_INSENSITIVE` without `UNICODE_CASE` matches ASCII case
only — exactly what `(?i:...)` did. That is the correct scope here: the
shape is matching the literal HTTP `Bearer` auth-scheme token, not
prose, so Unicode case folding would only widen what a log line masks.
The javadoc now records it, so the ASCII scope reads as the decision it
is rather than as something to "fix" by adding `UNICODE_CASE`.

`JWT_SHAPED_VALUE` stays case-sensitive, unchanged.

## Related Issues

Follow-up to #750; no separate issue filed, since the finding arrived
through the analysis on that PR and is a two-line correction to it.

## How Has This Been Tested?

- [x] Unit tests

No new test, and deliberately so: this is a refactor with no behavioural
delta, so there is no red state to demonstrate. A test written for it
would pass before the change as well, which proves nothing.

The existing coverage is what pins the equivalence, and two tests in
`GitHubApiErrorTest` bear directly on it:

- `masksABearerHeaderWhateverCaseItArrivedIn` — the case-insensitivity
survives the move off the inline group
- `doesNotMaskOrdinaryTextThatMerelyBeginsLikeAJwtHeaderInSomeOtherCase`
— the JWT shape stays case-*sensitive*, i.e. the flag did not leak
across the split

Both are **controls**: green before and after.

### Gates

- `./mvnw -B spotless:apply` → clean
- `./mvnw -B clean compile spotbugs:check spotless:check` →
**BugInstance size is 0**, BUILD SUCCESS
- `./mvnw -B clean test` → **Tests run: 3324, Failures: 0, Errors: 0,
Skipped: 0**
- jacoco ∩ `git diff -U0 HEAD` on changed main code → one executable
line changed (the `Pattern.compile` call), **zero uncovered lines, zero
uncovered branches**

## Checklist

- [x] My code follows the project's coding standards
- [x] 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
- [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

The rest of the diff is javadoc. The block above `BEARER_SHAPED_VALUE`
still opened by calling itself "the bearer and JWT shapes", which was
accurate while it documented one field and stopped being so when the
split made two. It now says which shape each paragraph is about and why
the two are documented together.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Credential shapes: the bound strands a token prefix, and the JWT shape masks ordinary text

1 participant