fix(redact): match Supabase secret prefixes without a leading word boundary · Entire

fix(redact): match Supabase secret prefixes without a leading word boundary

6f707a8→main·

suhaanthayyil·6d ago·2 files·+76 added/-2 removed

The provider-token layer anchored sb_secret_/sbp_ with a leading \b, which only matches after a non-word character. A secret glued to a preceding word character therefore slipped through: an underscore-joined name, or — in the JSONL fall-back / raw redact.Bytes path that redacts undecoded text — a JSON escape whose trailing letter abuts the prefix (e.g. "…line1\nsb_secret_…", where the byte before "sb" is the literal 'n'). These bodies are low-entropy, so no other layer backs the provider layer up and the raw key would reach the checkpoint blob.

Drop the \b anchor. The 10/4-char prefixes plus the {20,} length floor keep a legitimate mid-word collision vanishingly unlikely, and any high-entropy incidental match is already covered by the entropy layer. Add mutation-verified regression tests for the word-char-preceded and escape-glued cases (String path) and the malformed-line JSONL fall-back.

Changes

2

36 unmodified lines

37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
41
42
53
54
55
56
57

36 unmodified lines

// base64url, sbp_ tokens are lowercase). The {20,} floor comfortably
// catches the current and plausibly-longer future formats while rejecting
// short identifier-like collisions such as "sb_secret_short".
//
// The prefix is deliberately NOT preceded by a \b word boundary. \b requires
// the character before the prefix to be a non-word char, so a secret glued to
// a preceding word character — an underscore-joined name (FOO_sb_secret_…) or,
// in the JSONL fall-back / raw redact.Bytes path, a JSON escape whose trailing
// letter abuts the prefix (…line1\nsb_secret_…, where the byte before "sb" is
// the literal 'n') — would slip past. Because these low-entropy secrets are
// backed up by no other layer, missing them means the raw key reaches the
// checkpoint blob. Dropping the anchor is redaction-completeness-safe: the
// 10/4-char prefixes plus the {20,} floor make a legitimate mid-word collision
// vanishingly unlikely, and any high-entropy incidental match would already be
// caught by the entropy layer.
var providerTokenPatterns = []*regexp.Regexp{
    regexp.MustCompile(`\bsb_secret_[A-Za-z0-9_-]{20,}`),
    regexp.MustCompile(`\bsbp_[a-z0-9_-]{20,}`),
    regexp.MustCompile(`sb_secret_[A-Za-z0-9_-]{20,}`),
    regexp.MustCompile(`sbp_[a-z0-9_-]{20,}`),
}

// detectProviderTokens returns tagged regions for every occurrence of a

Mredact/providers.go+14/-2

470 unmodified lines

471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538

470 unmodified lines

}
}

// TestString_SupabaseProviderTokenBoundaries pins that the provider layer
// redacts a Supabase secret even when the prefix abuts a preceding *word*
// character. A \b anchor before the prefix only fires after a non-word
// character, so a secret glued to a preceding letter/digit/underscore — an
// underscore-joined name, or (in the raw redact.Bytes / JSONL fall-back path
// that runs String on undecoded text) a JSON escape whose trailing letter sits
// against the prefix, e.g. "…line1\nsb_secret_…" where the byte before "sb" is
// the literal 'n' — would slip past. These bodies are deliberately low-entropy,
// so no other layer backs the provider layer up: a miss reaches the blob raw.
// Each case fails if the leading \b anchor is reintroduced.
func TestString_SupabaseProviderTokenBoundaries(t *testing.T) {

t.Parallel()

secret := supabaseSecretPrefix() + "probe_20260710_7f91c2d8e4a6b3f0"
    sbpToken := supabasePersonalPrefix() + "test_probe_20260710_test_probe_2026071"

assertStringRedactionCases(t, []stringRedactionCase{
        {
            name:  "sb_secret_ glued to a preceding word char",
            input: "x" + secret,
            want:  "xREDACTED",
        },
        {
            // Raw-text fall-back shape: the transcript line failed to parse as
            // JSON, so String runs on the undecoded bytes where "\n" is a literal
            // backslash-n and the 'n' abuts the prefix.
            name:  "sb_secret_ preceded by a literal JSON escape letter",
            input: `first line\n` + secret,
            want:  `first line\nREDACTED`,
        },
        {
            name:  "sbp_ preceded by a literal JSON escape letter",
            input: `first line\n` + sbpToken,
            want:  `first line\nREDACTED`,
        },
    })
}

// TestJSONLContent_SupabaseSecretMalformedLineFallback drives the secret
// through the JSONL fall-back branch (jsonlContentImpl calls the per-leaf
// redactor on the raw line when json.Unmarshal fails), with the secret glued to
// a literal "\n" escape so the byte before the prefix is a word char. This is
// the realistic path by which a malformed/truncated transcript line could leak
// a low-entropy Supabase secret; it must still be redacted.
func TestJSONLContent_SupabaseSecretMalformedLineFallback(t *testing.T) {

t.Parallel()
    secret := supabaseSecretPrefix() + "probe_20260710_7f91c2d8e4a6b3f0"
    // Trailing garbage after the closing brace makes json.Unmarshal fail, forcing
    // the raw-line fall-back; inside, "\n" is a literal backslash-n before "sb".
    line := `{"content":"line1\n` + secret + `"} <-- truncated`
    got, err := JSONLContent(line)
    if err != nil {
        t.Fatalf("JSONLContent error: %v", err)
    }
    if strings.Contains(got, secret) {
        t.Fatalf("secret survived JSONL fall-back redaction: %q", got)
    }
    if !strings.Contains(got, "REDACTED") {
        t.Fatalf("expected REDACTED placeholder in %q", got)
    }
}

func TestString_CredentialedURIs(t *testing.T) {
tests := []struct {
    name  string