Repository navigation
fix(secrets): match Stripe, GitLab and Slack tokens whole, from one provider grammar table (#769) - #789
Conversation
|
@rustic this one is stacked on #786 (same |
🔍 Ferret Scan Results✅ No sensitive data detected in the changed files. 🤖 This comment was automatically generated by Ferret Scan |
… run, from one provider grammar table
The validator matched these three families as fixed-length windows with no
boundary (`sk_live_[0-9a-zA-Z]{24}`, `glpat-[a-zA-Z0-9_-]{20}`, Slack tails of
exactly 24 or 32) and typed them with predicates locked to the same lengths.
No vendor publishes those lengths. A longer token was reported as its first N
bytes and redacted as its first N bytes, the rest written back as text: on
v2.5.4 a 107-byte Stripe key kept 75 bytes in the clear in 9 of 11 realistic
shapes under every strategy, exit 0; a 51-byte GitLab routable token kept 25;
a Slack token with a 34-byte tail kept 2. On a keyword line a second, generic
finding over the whole value hid the leak. Five identifiers such as
task_live_DashboardWidgetRefreshInterval were STRIPE_API_KEY at 70-100.
One table now owns every provider literal (provider_rules.go): the regexes in
compileKeywordPatterns, the anchored predicates behind getSecretType and
CalculateConfidence, the base confidence, the containsSecretIndicators admits
and the GetCheckInfo text are all derived from it, and a guard test fails on a
literal written anywhere else. Rows with a vendor-fixed length -- the AWS key
ID, gh?_, AIza, dckr_pat_ -- stay modeExact: the window is the key, which is
what the documentation-placeholder ceiling relies on (its comment said the
predicate rejected a 27-byte key; the window had already cut it to 20 -- the
reason is corrected in the comment, the README and CONFIDENCE_CONTRACT 7c, and
the window is pinned through ValidateContent). Rows without a vendor length --
Stripe, GitLab, Slack -- are modeAtLeast and found by a hand-rolled run scan:
- the body runs to the end of its character run, at the old minimums, so
short placeholders stay unreported; GitLab also takes the routable
`.vv.lliiiiiii` suffix;
- straddle cut: a provider prefix beginning in the last len(prefix)-1 bytes
of a run and continuing past it starts the next token, and the scan
resumes at the cut, so glued tokens are each whole and provider spans
never partly overlap;
- Stripe left-glue: a run whose prefix is glued to a word byte on its left
and whose body has no digit is an identifier and is dropped; an escape
tail (JSON \n, \t, \u0022; URL %22) is not glue; the same shape with a
digit in the body is reported and pinned as a stated cost.
Measured: all 13 shapes of the issue's reproduction report one whole-run
provider finding, 0 bytes left in the clear; the five decoys report nothing;
the golden corpus moves no existing finding (0 added, 0 lost, 0 confidence
changes; the metadata key census gains the two new types); make score is
unchanged; rules generated by the v2.5.4 binary for exact-length tokens of all
15 patterns carry identical hashes and suppress with this build, over-long
tokens resurface whole; N/2N/4N timing is unchanged (2.0x per doubling) and a
new complexity target drives glued over-long tokens of all three families
through the run scan at 4.05x for 4x input.
One tightening as a consequence of derivation: the AWS key-ID row now requires
its 16 body bytes to be [0-9A-Z], as the regex always did; no corpus finding
moved.
Tests: per-row grammar (every row, 4 body lengths, 11 contexts: one finding of
the row's type with the promised text, nothing inside its span); the
derivation guard; the hazard pins; the key-ID window pins; the marker-rule
guard; a redaction test in goldencorpus asserting no 8-byte substring of any
over-long body survives either deterministic strategy; two corpus cases with
redaction snapshots. The hazard pins fail on main for every leak and decoy.
Stacked on the #785 commits (PR #786); rebases to its own diff once that lands.
Closes #769
a04d1b2 to
366bc34
Compare
|
@rustic #786 is in, so this is rebased onto |
🔍 Ferret Scan Results✅ No sensitive data detected in the changed files. 🤖 This comment was automatically generated by Ferret Scan |
Catches up with #792, #789 and #790. CHANGELOG.md merged without conflict through the merge=union attribute (#781), but union put the result in the wrong place: this branch adds a new ### Features subsection after Bug Fixes, and union emits this side's lines before main's, so #790's and #789's bug-fix bullets landed under Features. [Unreleased] is rebuilt by hand: Bug Fixes holds main's three bullets in main's order and then this branch's, and Features holds this branch's one. Every bullet is moved byte-for-byte; the section's multiset of non-blank lines is unchanged.
Closes #769.
Rebased onto
mainafter #786 merged (squasha84b533). This PR was stacked on #786 because both editinternal/validators/secrets/validator.go; the branch is now the single commit366bc34on top ofmain, and its diff is byte-identical to the originala04d1b2(samegit patch-id). The conflicts GitHub showed before the rebase were ininternal/goldencorpus/corpus.goandmetadata-key-census.txt, where this branch carried #786's additions as its own commits whilemaincarried them squashed, so both sides inserted at the same anchor; the code did not conflict.What was wrong
Stripe (
sk_/pk_×live_/test_), GitLabglpat-and Slackxoxb-/xoxp-tokens were matched as fixed-length windows with no boundary (sk_live_[0-9a-zA-Z]{24},glpat-[a-zA-Z0-9_-]{20}, Slack tails of exactly 24/32) and typed by predicates locked to the same lengths (len == 32,== 26). No vendor publishes those lengths: Stripe documents prefixes only (its own CLI checks the prefix and "at least 12 characters"); a GitLab PAT is a routable token with a 27–300-character payload plus.vv.and 9 more; Slack says "the final section is the secret". So a longer token was reported as its first N bytes and redacted as its first N bytes, the rest written back as text.Measured on
v2.5.4with the issue'srepro.py: a 107-byte Stripe key kept 75 bytes in the clear in 9 of 11 realistic shapes, under every strategy, exit 0, empty stderr; a 51-byte GitLab routable token kept 25; a Slack token with a 34-byte tail kept 2. On a keyword line a second, generic finding over the whole value hid the leak. Separately,task_live_DashboardWidgetRefreshIntervaland four identifiers like it wereSTRIPE_API_KEYat 70–100.The same facts were hand-copied into five places (regexes, predicates,
CalculateConfidence,containsSecretIndicators,GetCheckInfo), which is how they drifted.What changed
One table.
providerRulesininternal/validators/secrets/provider_rules.go— type, prefixes, head (Slack's digit groups), body charset, minimum, mode, trailer, base confidence, help text — and everything else is derived: the regexescompileKeywordPatternsappends, the anchored predicate behindgetSecretTypeandCalculateConfidence(providerTypeOf), thecontainsSecretIndicatorsadmits, the--helplines.TestProviderRulesAreTheOnlySourcefails on a keyword regex carrying a provider prefix that the table did not generate, a row missing from the compiled set, a prefix the pre-check does not admit, a described row absent fromGetCheckInfo, or a predicate disagreeing with its row's mode.Two modes, decided by the vendor.
modeExactfor AWSAKIA(16), GitHubgh?_(36), GoogleAIza(35), Dockerdckr_pat_(36): the window is the key, the regex engine's fixed window is exactly that grammar, unchanged.modeAtLeastfor Stripe (≥24), GitLab (≥20, then the optional routable suffix\.[0-9a-z]{2}\.[0-9a-z]{9}), Slack (tail ≥24 / ≥32): found byfindProviderRuns, a hand-rolled scan, because two rules a regex cannot express are needed:sk_live_<99>pk_live_<99>is one run of body bytes as far as the charset knows (_stops it two bytes late); a regex would consumepkinto the first key and never seepk_live_. After a run is taken, its lastlen(prefix)-1bytes are checked for the start of any provider prefix that continues past the end; if one is there and the shortened token still meets its minimum, the run is cut there and the scan resumes at the cut. Only the tail is searched: a prefix wholly inside a run (sk_live_<20>AKIA<16>) is body bytes. Provider spans therefore never partly overlap.\n,\t,\u0022, URL%22— is not glue, and neither is the end of another provider token (two glued no-digit keys both report). Stripe-only, because the otherAtLeastprefixes contain-, which no identifier is spelt with. The issue measured the alternatives: widening only the regex silenced 18 of 84 over-long cases, consuming a boundary silenced 33 of 180.Minimums did not move, so
sk_test_1234567890abcdefandsk_test_abc123def456stay unreported. No row has a maximum: one may annotate a finding, never shorten it.One consequence of derivation, stated:
AWS_ACCESS_KEY's predicate now requires the 16 body bytes to be[0-9A-Z], as its regex always did (isAWSAccessKeywas prefix +len == 20with no class). A 20-byte entropy candidate with a lowercase letter afterAKIAis typed generic instead of AWS. No corpus finding moved.Docs corrected. The
awsDocPlaceholderCapcomment, the secrets README andCONFIDENCE_CONTRACT.md§7c all said the EXAMPLE rule is safe because the predicate "rejects" a 27-byte key. It never sees one: the window has already cut it to 20, and the key reports at full confidence, uncapped (TestAKIAWindowPins). The conclusion was right — appended bytes are never in the match — and now the reason is too, plus the precondition it depends on:TestProviderMarkerRuleOnlyOnExactRowsfails if a type the rule names is ever moved offmodeExact. The README's pattern list now says which rows have a vendor length. The "Too long AKIA prefix" unit test is annotated as exercising the predicate only.The issue's reproduction,
main@e8c6fe2vs this PRrepro.pyfrom #769, verbatim (13 files,--enable-redaction, format_preserving); "left" is the token tail still in the redacted copy.curl -u, log line, markdown, CSV,STRIPE_SK=,stripe_sk:(7)STRIPE_SECRET_KEY=<sk>stripe.api_key = "<sk>"export STRIPE_API_KEY=,stripe.api_key = '…'Hazard matrix (CLI, both binaries):
task_live_…,helpdesk_live_…,risk_live_…,disk_test_…,kiosk_test_…)task_live_RefreshIntervalMs500Default(digit in body)x+key; key after JSON\n,\u0022,\t; after%22,, space, or nothing betweensk_live_<24>sk_live_<24>glued, no digitsglpat-<20>glpat-<20>glued-and letters, so two glued tokens are one run by the grammar; every byte is covered, none left in the clear (pinned as the stated shape)xoxb-…-<24>xoxb-…-<24>gluedAKIA<16>XYZ,AIza<35>_backup, doc ID + 1 (@15),AKIA<16>EXAMPLE(@94)Golden corpus
UPDATE_GOLDEN=1changed no existing snapshot except the metadata key census (which gains theSTRIPE_API_KEY,GITLAB_TOKEN,SLACK_TOKENtypes, 13 keys each, band 1): 0 findings added, 0 lost, 0 confidence changes. The corpus had no Stripe/GitLab/Slack case, which the issue noted; two are added:secrets_provider_token_lengths— 13 lines: the 107-byte key in the five un-saved shapes, the keyword line (now one finding), a quoted key,\u0022,%22and glued-xescapes, two glued keys, a GitLab routable token, a 34-tail Slack token. 14 findings, every one whole; both redaction snapshots show no body byte. The Stripe values carry the publishablepk_live_prefix: GitHub push protection refuses anysk_live_/sk_test_+ 24-alphanumeric value in a committed file, snapshots included (the first push was blocked on exactly those), and a publishable key is designed to be public. The four prefixes are one table row with the same charset, minimum, run scan, straddle cut and left-glue rule, so the golden pins the row; the three secret prefixes, test mode and the minimum lengths are exercised by the unit tests, whose values are built at run time and never written to disk.secrets_provider_prefix_decoys— the five decoys (empty), the digit residual (reported), both short placeholders (empty), AWS/Google windows with glued bytes, the doc ID + 1 at the 15 ceiling.make scorePASS, identical to
main: recall_all 1.0000, recall_hm 0.9906, prec_hm 0.9589, whole_leak 0.Suppression (dimension 5), parent binary vs this one
Rules generated by
v2.5.4for exact-length tokens of all 15 patterns: identical hashes on both binaries (15/15), suppress 15→0 in all four generate/apply directions. Rules for over-long Stripe/GitLab/Slack tokens: generated bymain, applied by this build, 3→3 — the findings resurface, whole, because the matched text changed; this is the intended, disclosed hash change. The CHANGELOG says to regenerate those rules.Timing (dimension 6)
N lines of distinct 107-byte Stripe keys in prose, findings = N at every size: 5k 2.66/2.69s, 10k 5.31/5.36s, 20k 10.65/10.67s (main/PR), 2.0x per doubling, unchanged. One line of K glued distinct keys (the straddle path): 2k 0.61/0.60s, 4k 1.19/1.20s, 8k 2.35/2.39s, linear. New complexity target
secrets_provider_runs(distinct glued over-long tokens of all three families, ≥1,500 findings at both sizes): 4.05x at 4x input, margin 1.98x against the 8.0 bound.Tests, and that they fail on
mainmainTestProviderGrammarTestProviderRulesAreTheOnlySourceTestProviderRunHazardsTestAKIAWindowPinsTestProviderMarkerRuleOnlyOnExactRowsmodeExactrowsTestProviderTokensRedactWholeSuite: 88 packages ok; the one red is
TestGrowthRatioMissesASparseQuadratic, the perfguard host flake (fails on unchangedmainon this host 2/5; CI reads 4.6–5.0x).-raceonsecrets,goldencorpus,pkg/redact,cmd: ok. gofmt/vet/build clean; 7 formats + 2 redaction strategies snapshotted for both new cases.Not in this PR (sibling audit items, as #769 scopes them)
More rows (
rk_live_,ghs_stateless format,ASIA, Slack digit-group relaxations), the header grammar, and the escape-tail catalogue beyond the three shapes measured here.Test plan