mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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:
+85
-16
@@ -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 {
|
||||
|
||||
@@ -14,6 +14,7 @@ package js
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -26,12 +27,33 @@ 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] + "-"
|
||||
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -57,7 +79,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 +113,112 @@ 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",
|
||||
},
|
||||
{
|
||||
// same as above, dash-ending case.
|
||||
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: "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; }`,
|
||||
|
||||
Reference in New Issue
Block a user