From e73fd8f847b53dcd8b1814b18efd590e93b26392 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:39:14 -0700 Subject: [PATCH 1/2] fix(scan): tune js secret rules for false positives and missed formats drop stripe pk_ publishable keys (public by design) and require a digit in the generic secret value so camelCase identifiers stop tripping the entropy gate. add stripe rk_ restricted keys, github fine-grained pat (github_pat_), encrypted pem headers and version-anchored slack xapp tokens; drop the trailing word boundary on the aws-secret and google rules so keys ending in / + or - still match. renames the "stripe live key" rule to "stripe secret key", which changes the rule label in the json findings output. --- internal/scan/js/secrets.go | 58 ++++++++++++++++++-------- internal/scan/js/secrets_test.go | 70 +++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 18 deletions(-) diff --git a/internal/scan/js/secrets.go b/internal/scan/js/secrets.go index 892b5fb2..c3cda2c3 100644 --- a/internal/scan/js/secrets.go +++ b/internal/scan/js/secrets.go @@ -41,9 +41,10 @@ const ( // match when there's no group) is what gets reported; minEntropy gates the // generic high-entropy rules so we don't flag every short literal. var secretRules = []struct { - name string - re *regexp.Regexp - minEntropy float64 + name string + re *regexp.Regexp + minEntropy float64 + requireDigit bool }{ { // aws access key ids are fixed-shape and unmistakable. @@ -54,8 +55,9 @@ var secretRules = []struct { { // aws secret keys are 40-char base64-ish blobs; gate on entropy since the // shape alone matches plenty of innocent strings. + // no trailing \b: keys ending in / or + have no word boundary there. name: "aws secret access key", - re: regexp.MustCompile(`\b((?:aws_secret_access_key|aws_secret|secret_key)["']?\s*[:=]\s*["']?)([A-Za-z0-9/+]{40})\b`), + re: regexp.MustCompile(`\b((?:aws_secret_access_key|aws_secret|secret_key)["']?\s*[:=]\s*["']?)([A-Za-z0-9/+]{40})`), minEntropy: awsSecretMinEntropy, }, { @@ -65,35 +67,45 @@ var secretRules = []struct { minEntropy: noEntropyGate, }, { - // slack bot/user/app/legacy tokens. + // github fine-grained personal access tokens: github_pat_ then 82 chars. + name: "github fine-grained pat", + re: regexp.MustCompile(`\b(github_pat_[0-9A-Za-z_]{82})\b`), + minEntropy: noEntropyGate, + }, + { + // slack bot/user/app/legacy tokens plus version-anchored xapp app tokens. name: "slack token", - re: regexp.MustCompile(`\b(xox[baprs]-[0-9A-Za-z-]{10,})\b`), + re: regexp.MustCompile(`\b(xox[baprs]-[0-9A-Za-z-]{10,}|xapp-[0-9]+-[0-9A-Za-z-]{10,})\b`), minEntropy: noEntropyGate, }, { - // stripe live secret and publishable keys (test keys are not findings). - name: "stripe live key", - re: regexp.MustCompile(`\b([sp]k_live_[0-9A-Za-z]{16,})\b`), + // stripe live secret and restricted keys; publishable pk_ keys are public + // by design and test keys are not findings. + name: "stripe secret key", + re: regexp.MustCompile(`\b((?:sk|rk)_live_[0-9A-Za-z]{16,})\b`), minEntropy: noEntropyGate, }, { - // google api keys are a fixed AIza-prefixed 39-char shape. + // google api keys are a fixed AIza-prefixed 39-char shape; no trailing \b + // since keys ending in - have no word boundary there. name: "google api key", - re: regexp.MustCompile(`\b(AIza[0-9A-Za-z_-]{35})\b`), + re: regexp.MustCompile(`\b(AIza[0-9A-Za-z_-]{35})`), minEntropy: noEntropyGate, }, { // pem private key blocks; the header alone is the smoking gun. name: "private key", - re: regexp.MustCompile(`-{5}BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-{5}`), + re: regexp.MustCompile(`-{5}BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-{5}`), minEntropy: noEntropyGate, }, { // generic apikey/secret/token = "" assignments; the value is in - // group 2 and only reported if it looks random (entropy gate). - name: "generic secret assignment", - re: regexp.MustCompile(`(?i)\b(api[_-]?key|secret|token|password|passwd|auth)["']?\s*[:=]\s*["']([0-9A-Za-z\-._~+/]{16,})["']`), - minEntropy: genericMinEntropy, + // group 2 and only reported if it looks random (entropy gate) and carries + // a digit, which weeds out camelCase identifiers sitting just over the gate. + name: "generic secret assignment", + re: regexp.MustCompile(`(?i)\b(api[_-]?key|secret|token|password|passwd|auth)["']?\s*[:=]\s*["']([0-9A-Za-z\-._~+/]{16,})["']`), + minEntropy: genericMinEntropy, + requireDigit: true, }, } @@ -125,6 +137,10 @@ func ScanSecrets(content, srcURL string) []SecretMatch { continue } + if rule.requireDigit && !hasDigit(value) { + continue + } + // dedupe per source so a key referenced twice is one finding. key := rule.name + "\x00" + value if _, ok := seen[key]; ok { @@ -148,6 +164,16 @@ func secretValue(groups []string) string { return strings.TrimSpace(groups[wholeMatchIndex]) } +// hasDigit reports whether s contains at least one ascii digit. +func hasDigit(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= '0' && s[i] <= '9' { + return true + } + } + return false +} + // shannonEntropy is the per-character shannon entropy (bits) of s, used to tell // random-looking secrets apart from plain words. empty input is zero entropy. func shannonEntropy(s string) float64 { diff --git a/internal/scan/js/secrets_test.go b/internal/scan/js/secrets_test.go index e4b7807b..abdf81fb 100644 --- a/internal/scan/js/secrets_test.go +++ b/internal/scan/js/secrets_test.go @@ -14,6 +14,7 @@ package js import ( "fmt" + "strings" "testing" ) @@ -26,12 +27,23 @@ const ( fakeAWSSecret = "wJalrXUtnFEMI/K7MDENG/" + "bPxRfiCYEXAMPLEKEY" fakeGitHub = "ghp_" + "aB3dEfGh1jKlMn0pQrStUvWxYz012345abcd" fakeSlack = "xoxb-" + "123456789012-abcdefABCDEF1234567890ab" - fakeStripe = "sk_live_" + "4eC39HqLyjWDarjtT1zdp7dc" + fakeStripe = "sk_live_" + "0000000000000000000000" fakeGoogle = "AIza" + "SyA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q" fakeGeneric = "x9Kq2Lm7Pz4Rt6Wv8Bn3Cd5Fg1Hj0As" fakePEM = "-----BEGIN RSA PRIVATE " + "KEY-----\nMIIEpAIB..." ) +// fakes for the rebuilt rules; the derived ones end in a non-word char to +// exercise the dropped trailing word boundaries. +var ( + fakeStripeRestricted = "rk_live_" + "0000000000000000000000" + fakeGitHubPAT = "github_pat_" + strings.Repeat("a1B2c3D4", 10) + "ab" + fakeSlackApp = "xapp-1-" + "A01B23C45D6-1234567890-abcdefABCDEF" + fakeEncryptedPEM = "-----BEGIN ENCRYPTED PRIVATE " + "KEY-----\nMIIFDjBA..." + fakeAWSSecretSlash = fakeAWSSecret[:len(fakeAWSSecret)-1] + "/" + fakeGoogleDash = fakeGoogle[:len(fakeGoogle)-1] + "-" +) + func TestScanSecrets(t *testing.T) { tests := []struct { name string @@ -57,7 +69,7 @@ func TestScanSecrets(t *testing.T) { { name: "stripe live secret key", content: fmt.Sprintf(`var sk = %q;`, fakeStripe), - wantRule: "stripe live key", + wantRule: "stripe secret key", }, { name: "google api key", @@ -91,6 +103,60 @@ func TestScanSecrets(t *testing.T) { content: `token = "abababababababababababab"`, wantNone: true, }, + { + name: "stripe restricted live key", + content: fmt.Sprintf(`var rk = %q;`, fakeStripeRestricted), + wantRule: "stripe secret key", + }, + { + name: "github fine-grained pat", + content: fmt.Sprintf(`pat: %q`, fakeGitHubPAT), + wantRule: "github fine-grained pat", + }, + { + name: "slack app-level token", + content: fmt.Sprintf(`slack=%q`, fakeSlackApp), + wantRule: "slack token", + }, + { + name: "encrypted pem private key header", + content: fakeEncryptedPEM, + wantRule: "private key", + }, + { + // value ends in / so the old trailing \b dropped the match. + name: "aws secret ending in slash", + content: fmt.Sprintf(`aws_secret_access_key=%q`, fakeAWSSecretSlash), + wantRule: "aws secret access key", + }, + { + // value ends in - so the old trailing \b dropped the match. + name: "google api key ending in dash", + content: fmt.Sprintf(`apiKey: %q`, fakeGoogleDash), + wantRule: "google api key", + }, + { + // publishable pk_ keys are public by design, not a finding. + name: "stripe publishable key not flagged", + content: `pub = "pk_live_0000000000000000000000"`, + wantNone: true, + }, + { + name: "stripe test key not flagged", + content: `k = "sk_test_0000000000000000000000"`, + wantNone: true, + }, + { + // the rk_live substring inside spark_live must not match (word boundary). + name: "spark_live substring not flagged", + content: `sparkCfg = "spark_live_aBcDeF1234567890XY"`, + wantNone: true, + }, + { + name: "digitless camelcase generic not flagged", + content: `token = "getUserAccountSettings"`, + wantNone: true, + }, { name: "no secrets in plain code", content: `function add(a, b) { return a + b; }`, From a15d68814e308877f6ced79e7a7e893af2755bb0 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:53:53 -0700 Subject: [PATCH 2/2] feat(scan): detect more provider keys in javascript add unique-prefix credential rules to the js secret bank: gitlab pat (glpat-), anthropic api/admin keys (sk-ant-), npm tokens (npm_), google oauth client secrets (GOCSPX-), stripe webhook secrets (whsec_), shopify app tokens (shp[at|ss|pa|ca]_), sendgrid keys (SG.) and slack incoming webhook urls. all ride the no-entropy slot since the prefix alone is proof, so they carry near-zero false-positive risk. --- internal/scan/js/secrets.go | 53 +++++++++++++++++++++++--- internal/scan/js/secrets_test.go | 64 +++++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/internal/scan/js/secrets.go b/internal/scan/js/secrets.go index c3cda2c3..13140088 100644 --- a/internal/scan/js/secrets.go +++ b/internal/scan/js/secrets.go @@ -67,13 +67,12 @@ var secretRules = []struct { minEntropy: noEntropyGate, }, { - // github fine-grained personal access tokens: github_pat_ then 82 chars. name: "github fine-grained pat", re: regexp.MustCompile(`\b(github_pat_[0-9A-Za-z_]{82})\b`), minEntropy: noEntropyGate, }, { - // slack bot/user/app/legacy tokens plus version-anchored xapp app tokens. + // slack bot/user/app/legacy tokens, plus xapp tokens. name: "slack token", re: regexp.MustCompile(`\b(xox[baprs]-[0-9A-Za-z-]{10,}|xapp-[0-9]+-[0-9A-Za-z-]{10,})\b`), minEntropy: noEntropyGate, @@ -86,8 +85,8 @@ var secretRules = []struct { minEntropy: noEntropyGate, }, { - // google api keys are a fixed AIza-prefixed 39-char shape; no trailing \b - // since keys ending in - have no word boundary there. + // google api keys are a fixed AIza-prefixed 39-char shape; same + // trailing-\b issue as above (dash-ending keys). name: "google api key", re: regexp.MustCompile(`\b(AIza[0-9A-Za-z_-]{35})`), minEntropy: noEntropyGate, @@ -98,6 +97,51 @@ var secretRules = []struct { re: regexp.MustCompile(`-{5}BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-{5}`), minEntropy: noEntropyGate, }, + { + // gitlab personal access tokens; the glpat- prefix is unmistakable and + // covers both the classic 20-char and longer routable tokens. + name: "gitlab personal access token", + re: regexp.MustCompile(`\b(glpat-[0-9A-Za-z_-]{20,})\b`), + minEntropy: noEntropyGate, + }, + { + // anthropic api and admin keys end in a fixed AA pad after 93 chars. + name: "anthropic api key", + re: regexp.MustCompile(`\b(sk-ant-(?:api03|admin01)-[0-9A-Za-z_-]{93}AA)\b`), + minEntropy: noEntropyGate, + }, + { + name: "npm access token", + re: regexp.MustCompile(`\b(npm_[0-9A-Za-z]{36})\b`), + minEntropy: noEntropyGate, + }, + { + name: "google oauth client secret", + re: regexp.MustCompile(`\b(GOCSPX-[0-9A-Za-z_-]{28})\b`), + minEntropy: noEntropyGate, + }, + { + name: "stripe webhook secret", + re: regexp.MustCompile(`\b(whsec_[0-9A-Za-z]{32,})\b`), + minEntropy: noEntropyGate, + }, + { + // shopify admin/shared/private/custom app tokens, 32 hex after the prefix. + name: "shopify access token", + re: regexp.MustCompile(`\b(shp(?:at|ss|pa|ca)_[0-9a-fA-F]{32})\b`), + minEntropy: noEntropyGate, + }, + { + name: "sendgrid api key", + re: regexp.MustCompile(`\b(SG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43})\b`), + minEntropy: noEntropyGate, + }, + { + // slack incoming-webhook urls embed the secret in the path. + name: "slack webhook url", + re: regexp.MustCompile(`\b(hooks\.slack\.com/services/T[0-9A-Za-z_]+/B[0-9A-Za-z_]+/[0-9A-Za-z]{24})\b`), + minEntropy: noEntropyGate, + }, { // generic apikey/secret/token = "" assignments; the value is in // group 2 and only reported if it looks random (entropy gate) and carries @@ -164,7 +208,6 @@ func secretValue(groups []string) string { return strings.TrimSpace(groups[wholeMatchIndex]) } -// hasDigit reports whether s contains at least one ascii digit. func hasDigit(s string) bool { for i := 0; i < len(s); i++ { if s[i] >= '0' && s[i] <= '9' { diff --git a/internal/scan/js/secrets_test.go b/internal/scan/js/secrets_test.go index abdf81fb..6211d9c5 100644 --- a/internal/scan/js/secrets_test.go +++ b/internal/scan/js/secrets_test.go @@ -42,6 +42,16 @@ var ( fakeEncryptedPEM = "-----BEGIN ENCRYPTED PRIVATE " + "KEY-----\nMIIFDjBA..." fakeAWSSecretSlash = fakeAWSSecret[:len(fakeAWSSecret)-1] + "/" fakeGoogleDash = fakeGoogle[:len(fakeGoogle)-1] + "-" + + fakeGitLabPAT = "glpat-" + "AbCdEf1234567890GhIj" + fakeAnthropic = "sk-ant-api03-" + strings.Repeat("aB3", 31) + "AA" + fakeNPM = "npm_" + strings.Repeat("a1B2c3", 6) + fakeGoogleOAuth = "GOCSPX-" + strings.Repeat("aB3d", 7) + fakeStripeWebhook = "whsec_" + strings.Repeat("aB3d", 8) + fakeShopify = "shpat_" + strings.Repeat("0a1b", 8) + fakeSendGrid = "SG." + strings.Repeat("aB3", 7) + "a." + strings.Repeat("aB3", 14) + "a" + fakeSlackHook = "hooks.slack.com/services/T00000000/B00000000/" + strings.Repeat("aB3", 8) + fakeAnthropicBad = "sk-ant-api03-" + strings.Repeat("aB3", 31) + "XX" ) func TestScanSecrets(t *testing.T) { @@ -130,7 +140,7 @@ func TestScanSecrets(t *testing.T) { wantRule: "aws secret access key", }, { - // value ends in - so the old trailing \b dropped the match. + // same as above, dash-ending case. name: "google api key ending in dash", content: fmt.Sprintf(`apiKey: %q`, fakeGoogleDash), wantRule: "google api key", @@ -157,6 +167,58 @@ func TestScanSecrets(t *testing.T) { content: `token = "getUserAccountSettings"`, wantNone: true, }, + { + name: "gitlab personal access token", + content: fmt.Sprintf(`token: %q`, fakeGitLabPAT), + wantRule: "gitlab personal access token", + }, + { + name: "anthropic api key", + content: fmt.Sprintf(`key = %q`, fakeAnthropic), + wantRule: "anthropic api key", + }, + { + name: "npm access token", + content: fmt.Sprintf(`_authToken=%q`, fakeNPM), + wantRule: "npm access token", + }, + { + name: "google oauth client secret", + content: fmt.Sprintf(`client_secret: %q`, fakeGoogleOAuth), + wantRule: "google oauth client secret", + }, + { + name: "stripe webhook secret", + content: fmt.Sprintf(`endpointSecret = %q`, fakeStripeWebhook), + wantRule: "stripe webhook secret", + }, + { + name: "shopify access token", + content: fmt.Sprintf(`shopify=%q`, fakeShopify), + wantRule: "shopify access token", + }, + { + name: "sendgrid api key", + content: fmt.Sprintf(`SENDGRID_API_KEY=%q`, fakeSendGrid), + wantRule: "sendgrid api key", + }, + { + name: "slack webhook url", + content: fmt.Sprintf(`url = "https://%s"`, fakeSlackHook), + wantRule: "slack webhook url", + }, + { + // classic glpat tokens are 20 chars; a short stub is not a finding. + name: "gitlab token too short not flagged", + content: `pub = "glpat-abc"`, + wantNone: true, + }, + { + // anthropic keys end in a fixed AA pad; a different tail is not a key. + name: "anthropic key wrong pad not flagged", + content: fmt.Sprintf(`k = %q`, fakeAnthropicBad), + wantNone: true, + }, { name: "no secrets in plain code", content: `function add(a, b) { return a + b; }`,