|
| 1 | +/* |
| 2 | +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· |
| 3 | +: : |
| 4 | +: █▀ █ █▀▀ · Blazing-fast pentesting suite : |
| 5 | +: ▄█ █ █▀ · BSD 3-Clause License : |
| 6 | +: : |
| 7 | +: (c) 2022-2026 vmfunc, xyzeva, : |
| 8 | +: lunchcat alumni & contributors : |
| 9 | +: : |
| 10 | +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· |
| 11 | +*/ |
| 12 | + |
| 13 | +package js |
| 14 | + |
| 15 | +import ( |
| 16 | + "fmt" |
| 17 | + "strings" |
| 18 | + "testing" |
| 19 | +) |
| 20 | + |
| 21 | +// split into fragments so the file itself never carries a contiguous token a |
| 22 | +// secret scanner would flag. |
| 23 | +var ( |
| 24 | + provPyPI = "pypi-AgEIcHlwaS5vcmc" + strings.Repeat("1jKlMn0p", 7) |
| 25 | + provOpenAILeg = "sk-" + "aB3dEfGh1jKlMn0pQrSt" + "T3BlbkFJ" + "uVwXyZ012345abcdefgh" |
| 26 | + provOpenAIProj = "sk-proj-" + "aB3dEfGh1jKlMn0pQrStUvWxYz012345abcd" |
| 27 | + provSquare = "sq0atp-" + "aB3dEfGh1jKlMn0pQrSt-U" |
| 28 | + provMailgun = "key-" + "0123456789abcdef0123456789abcdef" |
| 29 | + provDiscordBot = "M" + "TIzNDU2Nzg5MDEyMzQ1Njc4" + "." + "GaBcDe" + "." + "aB3dEfGh1jKlMn0pQrStUvWxYz012345abcdef" |
| 30 | + provDiscordHook = "discord.com/api/webhooks/" + "123456789012345678" + "/" + strings.Repeat("aB3dEfGh1j", 6) + "abcdefgh" |
| 31 | + provNewRelic = "NRAK-" + "AB3DEFGH1JKLMN0PQRSTUVWXYZZ" |
| 32 | + provCloudinary = "cloudinary://" + "123456789012345" + ":" + "aB3dEfGh1jKlMn0pQrStUvWxYz" + "@my-cloud" |
| 33 | + provMongoURI = "mongodb+srv://" + "dbadmin" + ":" + "tR7q!zK2vLp9xC" + "@cluster0.example.mongodb.net/prod" |
| 34 | + provMongoPlace = "mongodb://" + "user" + ":" + "password" + "@localhost:27017/app" |
| 35 | + |
| 36 | + // a real jwt (rfc 7519 example header/payload), signature is dummy. |
| 37 | + provJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + |
| 38 | + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0" + |
| 39 | + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" |
| 40 | +) |
| 41 | + |
| 42 | +// each provider rule added on top of the existing bank, plus the two cases the |
| 43 | +// shape alone cannot decide: a dotted base64url blob that is not a jwt, and a |
| 44 | +// connection string whose password is a documentation placeholder. |
| 45 | +func TestScanSecretsProviderRules(t *testing.T) { |
| 46 | + tests := []struct { |
| 47 | + name string |
| 48 | + content string |
| 49 | + wantRule string // "" means the content must produce no match |
| 50 | + }{ |
| 51 | + { |
| 52 | + name: "pypi api token", |
| 53 | + content: fmt.Sprintf(`password = %q`, provPyPI), |
| 54 | + wantRule: "pypi api token", |
| 55 | + }, |
| 56 | + { |
| 57 | + name: "openai legacy api key", |
| 58 | + content: fmt.Sprintf(`OPENAI_API_KEY=%q`, provOpenAILeg), |
| 59 | + wantRule: "openai api key", |
| 60 | + }, |
| 61 | + { |
| 62 | + name: "openai project api key", |
| 63 | + content: fmt.Sprintf(`OPENAI_API_KEY=%q`, provOpenAIProj), |
| 64 | + wantRule: "openai project api key", |
| 65 | + }, |
| 66 | + { |
| 67 | + name: "square access token", |
| 68 | + content: fmt.Sprintf(`squareToken = %q`, provSquare), |
| 69 | + wantRule: "square access token", |
| 70 | + }, |
| 71 | + { |
| 72 | + name: "mailgun api key", |
| 73 | + content: fmt.Sprintf(`MAILGUN_KEY=%q`, provMailgun), |
| 74 | + wantRule: "mailgun api key", |
| 75 | + }, |
| 76 | + { |
| 77 | + name: "discord bot token", |
| 78 | + content: fmt.Sprintf(`client.login(%q)`, provDiscordBot), |
| 79 | + wantRule: "discord bot token", |
| 80 | + }, |
| 81 | + { |
| 82 | + name: "discord webhook url", |
| 83 | + content: fmt.Sprintf(`fetch("https://%s")`, provDiscordHook), |
| 84 | + wantRule: "discord webhook url", |
| 85 | + }, |
| 86 | + { |
| 87 | + name: "new relic license key", |
| 88 | + content: fmt.Sprintf(`NEW_RELIC_LICENSE_KEY=%q`, provNewRelic), |
| 89 | + wantRule: "new relic license key", |
| 90 | + }, |
| 91 | + { |
| 92 | + name: "cloudinary url", |
| 93 | + content: fmt.Sprintf(`CLOUDINARY_URL=%q`, provCloudinary), |
| 94 | + wantRule: "cloudinary url", |
| 95 | + }, |
| 96 | + { |
| 97 | + name: "jwt with a valid header", |
| 98 | + content: fmt.Sprintf(`const token = %q;`, provJWT), |
| 99 | + wantRule: "jwt", |
| 100 | + }, |
| 101 | + { |
| 102 | + name: "three dotted base64url blobs without a jwt header", |
| 103 | + content: `const s = "eyJhYmNkZWZnaGlq.aGVsbG93b3JsZDEy.c2lnbmF0dXJlYmxvYmhlcmU";`, |
| 104 | + }, |
| 105 | + { |
| 106 | + name: "connection string with real credentials", |
| 107 | + content: fmt.Sprintf(`const uri = %q;`, provMongoURI), |
| 108 | + wantRule: "database connection string", |
| 109 | + }, |
| 110 | + { |
| 111 | + name: "connection string with a placeholder password", |
| 112 | + content: fmt.Sprintf(`// example: %s`, provMongoPlace), |
| 113 | + }, |
| 114 | + } |
| 115 | + |
| 116 | + for _, tt := range tests { |
| 117 | + t.Run(tt.name, func(t *testing.T) { |
| 118 | + got := ScanSecrets(tt.content, "https://example.test/app.js") |
| 119 | + |
| 120 | + if tt.wantRule == "" { |
| 121 | + if len(got) != 0 { |
| 122 | + t.Fatalf("got %d matches (%s), want none", len(got), got[0].Rule) |
| 123 | + } |
| 124 | + return |
| 125 | + } |
| 126 | + |
| 127 | + var rules []string |
| 128 | + for i := range got { |
| 129 | + rules = append(rules, got[i].Rule) |
| 130 | + if got[i].Rule == tt.wantRule { |
| 131 | + return |
| 132 | + } |
| 133 | + } |
| 134 | + t.Fatalf("rule %q did not fire, got %v", tt.wantRule, rules) |
| 135 | + }) |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +// the rules added here must not overlap each other or the provider-prefixed |
| 140 | +// rules already in the bank: one credential in one script is one finding, not |
| 141 | +// two. the generic assignment rule is excluded because it claims any quoted |
| 142 | +// high-entropy value behind a token/password/secret keyword, so it already |
| 143 | +// doubles up with every prefixed rule on main; that is pre-existing and not |
| 144 | +// something these rules introduce. |
| 145 | +func TestProviderRulesDoNotDuplicateExistingCoverage(t *testing.T) { |
| 146 | + content := strings.Join([]string{ |
| 147 | + fmt.Sprintf(`password = %q`, provPyPI), |
| 148 | + fmt.Sprintf(`OPENAI_API_KEY=%q`, provOpenAILeg), |
| 149 | + fmt.Sprintf(`squareToken = %q`, provSquare), |
| 150 | + fmt.Sprintf(`MAILGUN_KEY=%q`, provMailgun), |
| 151 | + fmt.Sprintf(`client.login(%q)`, provDiscordBot), |
| 152 | + fmt.Sprintf(`NEW_RELIC_LICENSE_KEY=%q`, provNewRelic), |
| 153 | + fmt.Sprintf(`CLOUDINARY_URL=%q`, provCloudinary), |
| 154 | + fmt.Sprintf(`const token = %q;`, provJWT), |
| 155 | + fmt.Sprintf(`const uri = %q;`, provMongoURI), |
| 156 | + }, "\n") |
| 157 | + |
| 158 | + seen := make(map[string]int) |
| 159 | + for _, m := range ScanSecrets(content, "https://example.test/app.js") { |
| 160 | + if m.Rule == "generic secret assignment" { |
| 161 | + continue |
| 162 | + } |
| 163 | + seen[m.Match]++ |
| 164 | + } |
| 165 | + |
| 166 | + for value, n := range seen { |
| 167 | + if n > 1 { |
| 168 | + t.Errorf("value %q reported %d times, want 1", value, n) |
| 169 | + } |
| 170 | + } |
| 171 | +} |
0 commit comments