fix(scan): tune js secret rules for false positives and missed formats (#344)

* 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.

* 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.
This commit is contained in:
Tigah
2026-07-22 12:55:17 -07:00
committed by GitHub
parent a44cfb230f
commit d93fcbcd46
2 changed files with 215 additions and 18 deletions
+85 -16
View File
@@ -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,89 @@ var secretRules = []struct {
minEntropy: noEntropyGate,
},
{
// slack bot/user/app/legacy tokens.
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 xapp 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; same
// trailing-\b issue as above (dash-ending keys).
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,
},
{
// 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 = "<value>" 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 +181,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 +208,15 @@ func secretValue(groups []string) string {
return strings.TrimSpace(groups[wholeMatchIndex])
}
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 {