carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 7fdb1a832509c5378f99cc4d47a38e84eb685c08 parent e48b542
3 files changed, +58 −5
  • docs/superpowers/plans/2026-10-24-edge-protection-1-request-records.md +6 −3
  • internal/reqrec/redact.go +28 −2
  • internal/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)