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 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.
289 lines
9.6 KiB
Go
289 lines
9.6 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package js
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// the fake tokens below are assembled from two fragments on purpose: a contiguous
|
|
// provider token literal in a committed file trips github push-protection (and
|
|
// every other secret scanner) even though it's a test fixture. splitting it
|
|
// keeps the literal out of source while ScanSecrets still sees the joined value.
|
|
const (
|
|
fakeAWSKey = "AKIA" + "IOSFODNN7EXAMPLE"
|
|
fakeAWSSecret = "wJalrXUtnFEMI/K7MDENG/" + "bPxRfiCYEXAMPLEKEY"
|
|
fakeGitHub = "ghp_" + "aB3dEfGh1jKlMn0pQrStUvWxYz012345abcd"
|
|
fakeSlack = "xoxb-" + "123456789012-abcdefABCDEF1234567890ab"
|
|
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
|
|
content string
|
|
wantRule string // rule expected on the first match, "" means no match
|
|
wantNone bool
|
|
}{
|
|
{
|
|
name: "aws access key id",
|
|
content: fmt.Sprintf(`const k = %q;`, fakeAWSKey),
|
|
wantRule: "aws access key id",
|
|
},
|
|
{
|
|
name: "github personal token",
|
|
content: fmt.Sprintf(`token: %q`, fakeGitHub),
|
|
wantRule: "github token",
|
|
},
|
|
{
|
|
name: "slack bot token",
|
|
content: fmt.Sprintf(`slack=%q`, fakeSlack),
|
|
wantRule: "slack token",
|
|
},
|
|
{
|
|
name: "stripe live secret key",
|
|
content: fmt.Sprintf(`var sk = %q;`, fakeStripe),
|
|
wantRule: "stripe secret key",
|
|
},
|
|
{
|
|
name: "google api key",
|
|
content: fmt.Sprintf(`apiKey: %q`, fakeGoogle),
|
|
wantRule: "google api key",
|
|
},
|
|
{
|
|
name: "pem private key header",
|
|
content: fakePEM,
|
|
wantRule: "private key",
|
|
},
|
|
{
|
|
name: "generic high-entropy api key assignment",
|
|
content: fmt.Sprintf(`apikey = %q`, fakeGeneric),
|
|
wantRule: "generic secret assignment",
|
|
},
|
|
{
|
|
name: "aws secret with entropy",
|
|
content: fmt.Sprintf(`aws_secret_access_key=%q`, fakeAWSSecret),
|
|
wantRule: "aws secret access key",
|
|
},
|
|
{
|
|
// low-entropy assignment is a placeholder, not a real secret.
|
|
name: "low entropy generic assignment not flagged",
|
|
content: `password = "aaaaaaaaaaaaaaaaaaaaaaaa"`,
|
|
wantNone: true,
|
|
},
|
|
{
|
|
// a repetitive placeholder is low-entropy and must not trip the gate.
|
|
name: "low entropy repeated pattern not flagged",
|
|
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; }`,
|
|
wantNone: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ScanSecrets(tt.content, "https://example.com/app.js")
|
|
|
|
if tt.wantNone {
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected no matches, got %+v", got)
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(got) == 0 {
|
|
t.Fatalf("expected a %q match, got none", tt.wantRule)
|
|
}
|
|
if got[0].Rule != tt.wantRule {
|
|
t.Errorf("rule = %q, want %q", got[0].Rule, tt.wantRule)
|
|
}
|
|
if got[0].Match == "" {
|
|
t.Error("match value is empty")
|
|
}
|
|
if got[0].Source != "https://example.com/app.js" {
|
|
t.Errorf("source = %q, want the passed url", got[0].Source)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestScanSecretsDedupesWithinSource(t *testing.T) {
|
|
// the same key referenced twice in one file is one finding.
|
|
content := fmt.Sprintf(`a = %q; b = %q;`, fakeAWSKey, fakeAWSKey)
|
|
got := ScanSecrets(content, "https://example.com/app.js")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 deduped match, got %d: %+v", len(got), got)
|
|
}
|
|
}
|
|
|
|
func TestShannonEntropy(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
// random-ish strings clear the generic gate, repetitive ones don't.
|
|
wantHigh bool
|
|
}{
|
|
{name: "empty is zero", input: "", wantHigh: false},
|
|
{name: "repeated char is low", input: "aaaaaaaaaaaaaaaa", wantHigh: false},
|
|
{name: "random blob is high", input: fakeGeneric, wantHigh: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := shannonEntropy(tt.input)
|
|
if tt.wantHigh && got < genericMinEntropy {
|
|
t.Errorf("entropy %f below generic gate %f", got, genericMinEntropy)
|
|
}
|
|
if !tt.wantHigh && got >= genericMinEntropy {
|
|
t.Errorf("entropy %f unexpectedly cleared generic gate %f", got, genericMinEntropy)
|
|
}
|
|
})
|
|
}
|
|
}
|