Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 85 additions & 16 deletions internal/scan/js/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
},
{
Expand All @@ -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,
},
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
132 changes: 130 additions & 2 deletions internal/scan/js/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ package js

import (
"fmt"
"strings"
"testing"
)

Expand All @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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; }`,
Expand Down
Loading