reqrec: grouped hex and digit tokens are not lower-case words (re-review of e48b542f)
wordShaped also refuses a segment holding a compound that the fragment rule's maskableCompound masks (24+ hex characters with a digit once - and _ are dropped), scanned in place. Regressions for the four reported inputs; grouped-hex and grouped-digits property kinds with zero leaks. The plan note says minute records carry no env.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+58
−5
docs/superpowers/plans/2026-10-24-edge-protection-1-request-records.md+6 −3internal/reqrec/redact.go+28 −2internal/reqrec/redact_test.go+24 −0
diff --git a/docs/superpowers/plans/2026-10-24-edge-protection-1-request-records.md b/docs/superpowers/plans/2026-10-24-edge-protection-1-request-records.md| index a195e6f5f..65de252e5 100644 |
| --- a/docs/superpowers/plans/2026-10-24-edge-protection-1-request-records.md |
| +++ b/docs/superpowers/plans/2026-10-24-edge-protection-1-request-records.md |
| @@ -25,10 +25,13 @@ code follows this list where a task says otherwise: |
| records (request records, then blocks, then summaries), never app or |
| platform lines. |
| - The stored method is `GET`..`TRACE` or `OTHER`; request text has controls |
| - escaped; records carry `env`; QueryNames drops components with no `=`. |
| + escaped; not-found, block and watch records carry `env`, and minute |
| + records carry none (their counts are per app, across environments); |
| + QueryNames drops components with no `=`. |
| - Paths and query names are masked by an allowlist of harmless segment |
| - shapes (short, or lower-case words) with neighbour guards, not by token |
| - detection; masking reads at most 2048 bytes of input (`MaxMaskInput`). |
| + shapes (short, or lower-case words that are not grouped hex or digits) |
| + with neighbour guards, not by token detection; masking reads at most 2048 |
| + bytes of input (`MaxMaskInput`). |
| - The edge leaves `Response.When` zero and the recorder stamps it; |
| `newRequestRecorder` takes a `now` clock. |
| - The edge-refs answer always carries `record_status`: `found`, `absent` or |
diff --git a/internal/reqrec/redact.go b/internal/reqrec/redact.go| index d1f89471e..06a2dab95 100644 |
| --- a/internal/reqrec/redact.go |
| +++ b/internal/reqrec/redact.go |
| @@ -272,6 +272,9 @@ func clean(s string, max int) string { |
| // 20), digits only (at most 8), or a mix of at most 6 (v2, mp4, h264). |
| // That is a slug, a file name or a version. A word holding '@' or '+' |
| // is none of these, so such a segment is kept only when it is short. |
| +// A segment whose groups are each short words but which together hold |
| +// 24 or more hex characters with a digit (maskableCompound, as for |
| +// fragments) is a grouped token, not a slug, and is masked. |
| // |
| // A short piece is harmless alone, not beside the rest of its token, so |
| // three guards look at a short segment's neighbours. They only ever mask |
| @@ -313,9 +316,32 @@ const ( |
| minSplitToken = 16 |
| ) |
| -// wordShaped reports whether seg is the lower-case word shape above. |
| +// hasMaskableCompound reports whether any maximal compound of |
| +// [A-Za-z0-9+=_-] in seg passes maskableCompound: hex or digits grouped by - |
| +// or _ (9f3a-c1d2-e4b5-a697-8877-6655, 12345678-87654321-...), whose every |
| +// group is a short word but which together are a token. It scans in place. |
| +func hasMaskableCompound(seg string) bool { |
| + for i := 0; i < len(seg); { |
| + if !isCompoundByte(seg[i]) { |
| + i++ |
| + continue |
| + } |
| + j := i |
| + for j < len(seg) && isCompoundByte(seg[j]) { |
| + j++ |
| + } |
| + if maskableCompound(seg[i:j]) { |
| + return true |
| + } |
| + i = j |
| + } |
| + return false |
| +} |
| + |
| +// wordShaped reports whether seg is the lower-case word shape above, and is |
| +// not a grouped token (hasMaskableCompound). |
| func wordShaped(seg string) bool { |
| - if len(seg) > maxWordSegment { |
| + if len(seg) > maxWordSegment || hasMaskableCompound(seg) { |
| return false |
| } |
| letters, digits, other := 0, 0, false |
diff --git a/internal/reqrec/redact_test.go b/internal/reqrec/redact_test.go| index 3b664a0d0..cdc118865 100644 |
| --- a/internal/reqrec/redact_test.go |
| +++ b/internal/reqrec/redact_test.go |
| @@ -57,6 +57,13 @@ func TestMaskPath(t *testing.T) { |
| "/i/eyJhbGciOiJIUzI1NiJ9xQ2w9pLmNb7Ks4Ua/x.png": "/i/[masked]/x.png", |
| "/a/Xq3vB9kL+mN2pQr7s/T1uVwYz4aBcDeFg=/b.js": "/a/[masked]/[masked]/b.js", |
| "/r/Xq3vB9kLmN2pQr7sT1uVw-Yz4aBcDeFgHiJkLmNoPq": "/r/[masked]", |
| + // Grouped hex and digits: lower-case word shapes, but the compound |
| + // judgement (24+ hex characters once - and _ are dropped) masks them. |
| + "/key/9f3a-c1d2-e4b5-a697-8877-6655": "/key/[masked]", |
| + "/key/9f3a_c1d2_e4b5_a697_8877_6655": "/key/[masked]", |
| + "/activate/3f9a0c-1d2e4b-5a6978-877665": "/activate/[masked]", |
| + "/n/12345678-87654321-11223344-55667788": "/n/[masked]", |
| + "/d/report.9f3a-c1d2-e4b5-a697-8877-6655.pdf": "/d/[masked]", |
| // Short pieces that only together could be a token (rule 5). |
| "/s/aB3dE6/f6GhI7/j9KlM8/x": "/s/[masked]/[masked]/[masked]/[masked]", |
| } |
| @@ -130,6 +137,22 @@ func TestMaskPathTokensProperty(t *testing.T) { |
| {"base64url-32", false, func() string { return base64.RawURLEncoding.EncodeToString(raw(32)) }}, |
| {"hex-16", false, func() string { return hex.EncodeToString(raw(16)) }}, |
| {"uuid", false, uuid}, |
| + {"grouped-hex", false, func() string { |
| + h := hex.EncodeToString(raw(12)) |
| + return h[:4] + "-" + h[4:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] |
| + }}, |
| + {"grouped-digits", false, func() string { |
| + var b strings.Builder |
| + for g := 0; g < 4; g++ { |
| + if g > 0 { |
| + b.WriteByte('-') |
| + } |
| + for d := 0; d < 8; d++ { |
| + b.WriteByte(byte('0' + rng.IntN(10))) |
| + } |
| + } |
| + return b.String() |
| + }}, |
| } |
| // throughWord reports whether a token kept a piece of itself that is a |
| // word to the guards (lower-case letters, or Title Case like Account) |
| @@ -216,6 +239,7 @@ func TestQueryNames(t *testing.T) { |
| "utm_source=x&page=2": "utm_source,page", |
| "reset_token_3f9a0c1d2e4b5a69788776655=1&q=2": "[masked],q", |
| "Filter%5BName%5D=a&FilterName=b&x=1": "[masked],x", |
| + "9f3a-c1d2-e4b5-a697-8877-6655=1&q=2": "[masked],q", |
| } { |
| if got := QueryNames(in); got != want { |
| t.Errorf("QueryNames(%q) = %q, want %q", in, got, want) |