# test(redact): pin Supabase provider-token length and hyphen boundaries

`0f6e21b`→[main](/content/gh/entireio/cli/commits/main/index.html)·

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,
		},
	})
}

```
