test(redact): pin Supabase provider-token length and hyphen boundaries · Entire
test(redact): pin Supabase provider-token length and hyphen boundaries
0f6e21b→main·
suhaanthayyil·3d ago·2 files·+91 added/-11 removed
The {20,} length floor on both sb_secret_ and sbp_ patterns was only accidentally pinned by unrelated fixtures, and the body charset's hyphen (present in real base64url key bodies) had no test coverage at all. Add explicit boundary cases (exactly 20 chars redacts, 19 is preserved) and hyphen-bearing fixtures for both prefixes so a regex tightening or charset "tidy-up" fails a test instead of silently under-redacting real keys.
Also correct two inaccurate comments verified against the vendored betterleaks v1.5.0 rule source: the sbp_ rule (unlike sb_secret_) fires standalone and only misses tokens via its exact-40-char body regex, entropy filter, and digit-minimum filter, not because it requires a companion URL; and the over-redaction example used a body shorter than the {20,} floor so it didn't actually demonstrate the pattern.
Changes
2
redact
Mproviders.go+15/-6
Mredact_test.go+76/-5
7 unmodified lines
8
9
10
11
12
13
14
15
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
19 unmodified lines
46
47
48
41
49
50
51
52
53
7 unmodified lines
// entropy or the surrounding key name, so it catches low-entropy
// credential formats the other secret layers don't reliably flag.
//
// The betterleaks layer misses these in isolation: its Supabase
// secret-key rule is a *composite* rule (RequiredRules:
supabase-project-url) that only fires when a matching "*.supabase.co"
// URL is present in the same content, plus an entropy filter. A secret
// captured on its own therefore passes straight through.
// The betterleaks layer's coverage of these differs per prefix (verified
// against the vendored betterleaks v1.5.0 rule source):
// - sb_secret_: the supabase-project-api-key rule is a *composite* rule
// (RequiredRules: supabase-project-url) that only fires when a matching
// "*.supabase.co" URL is present in the same content, on top of an
// entropy<=4.0 filter. A secret captured on its own therefore passes
// straight through regardless of entropy.
// - sbp_: the supabase-management-token rule fires standalone (no
// RequiredRules), but only matches an exact 40-character lowercase body
// and is further filtered by entropy<=3.5 and a two-digit minimum. A
// high-entropy 40-char sbp_ token captured alone IS caught by
// betterleaks; what this layer adds for sbp_ is coverage of bodies at
// other lengths, lower entropy, or without two digits.
//
// Supabase (https://supabase.com/docs/guides/getting-started/api-keys):
// - sb_secret_... secret API key (replaces the legacy service_role
19 unmodified lines
// the {20,} length check is open-ended, sufficiently long snake_case
// identifiers that merely start with a provider prefix are redacted even
// though they aren't secrets — e.g. `sb_secret_key_rotation_handler`, or
// mid-word inside a longer identifier like `libsbp_something_long`. This is
// mid-word inside a longer identifier like
// `libsbp_something_long_enough_value`. This is
// accepted: over-redaction is the safe direction here (see
// TestString_SupabaseProviderTokenLongIdentifierOverRedaction), and adding
// anchors or capping the body length to eliminate it would reopen the
Mredact/providers.go+15/-6
345 unmodified lines
346 347 348 349 350 351 352 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 44 unmodified lines
419 420 421 409 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486
345 unmodified lines
// TestString_SupabaseProviderTokens covers issue #1716: Supabase sb_secret_ // API keys and sbp_ personal access tokens are low-entropy and, captured in // isolation, are missed by the entropy layer (threshold 4.5) and by the // betterleaks Supabase rule (a composite rule that only fires when a // *.supabase.co URL is co-present). The deterministic provider-prefix layer // must catch them regardless of entropy or the surrounding variable name. // isolation, are missed by the entropy layer (threshold 4.5). betterleaks // coverage differs per prefix: its sb_secret_ rule is a composite rule that // only fires when a *.supabase.co URL is co-present, so a bare sb_secret_ // value never reaches its filter at all; its sbp_ rule fires standalone but // requires an exact 40-character lowercase body, so bodies of another length // (like the probe values below) never match its regex regardless of entropy. // The deterministic provider-prefix layer must catch both regardless of // entropy, body length, or the surrounding variable name. func TestString_SupabaseProviderTokens(t *testing.T) {
t.Parallel()
secret := supabaseSecretPrefix() + "probe_20260710_7f91c2d8e4a6b3f0" // entropy 4.199 realSecret := supabaseSecretPrefix() + "9uM4GhB0STF5R4K3HxQtlg_bzWW6DRj" sbpToken := supabasePersonalPrefix() + "test_probe_20260710_test_probe_2026071" // Real Supabase key bodies are base64url, which includes '-'. No other // fixture in this test contains a hyphen, so the charset's '-' member is // otherwise unpinned: narrowing [A-Za-z0-9_-] / [a-z0-9_-] to drop the // hyphen would still pass every other case here while silently truncating // (not merely shrinking) the match at the first hyphen in a real key, // leaking the remainder raw — the #1716 failure mode recurring via an // innocent charset "tidy-up". secretWithHyphen := supabaseSecretPrefix() + "probe-20260710-7f91c2d8e4a6b3f0" sbpTokenWithHyphen := supabasePersonalPrefix() + "probe-20260710-7f91c2d8e4a6b3f0"
// Both probe values sit below the entropy threshold, proving entropy-only // detection would miss them (the issue reports entropy 4.199 for sb_secret_).
want: SUPABASE_SERVICE_ROLE_KEY="REDACTED",
},
{
name: "sbp_ personal access token (low entropy, betterleaks misses)",
name: "sbp_ personal access token (38-char body, betterleaks' rule requires exactly 40)",
input: "SUPABASE_ACCESS_TOKEN=" + sbpToken,
want: "SUPABASE_ACCESS_TOKEN=REDACTED",
},
{
name: "sb_secret_ body with an early hyphen (real base64url shape)",
input: secretWithHyphen,
want: "REDACTED",
},
{
name: "sbp_ body with an early hyphen (real base64url shape)",
input: sbpTokenWithHyphen,
want: "REDACTED",
},
})
}
// TestString_SupabaseProviderTokenLengthBoundaries pins the {20,} body-length // floor shared by both provider patterns as an explicit boundary rather than // an emergent property of an unrelated fixture: a body of exactly 20 chars // must redact, and a body of exactly 19 chars must be preserved. Before this // test, the floor was pinned only accidentally — via key_rotation_handler // (sb_secret_) happening to have a 20-char body, with no equivalent coverage // for sbp_ at all. Each case fails if either pattern's minimum is tightened // to {21,}. func TestString_SupabaseProviderTokenLengthBoundaries(t *testing.T) {
t.Parallel()
const ( body20 = "boundary_probe_2026x" // exactly 20 chars body19 = "boundary_probe_2026" // exactly 19 chars ) if len(body20) != 20 || len(body19) != 19 { t.Fatalf("fixture bodies are %d/%d chars, want 20/19", len(body20), len(body19)) }
secret20 := supabaseSecretPrefix() + body20 secret19 := supabaseSecretPrefix() + body19 sbp20 := supabasePersonalPrefix() + body20 sbp19 := supabasePersonalPrefix() + body19
assertStringRedactionCases(t, []stringRedactionCase{ { name: "sb_secret_ with exactly 20-char body redacts", input: secret20, want: "REDACTED", }, { name: "sb_secret_ with exactly 19-char body is preserved", input: secret19, want: secret19, }, { name: "sbp_ with exactly 20-char body redacts", input: sbp20, want: "REDACTED", }, { name: "sbp_ with exactly 19-char body is preserved", input: sbp19, want: sbp19, }, }) }