yellow~ opinionated web scanning pipeline for pentesters in a hurry |
| git clone https://github.com/djnnvx/yellow.git |
| log | files | refs | archive | README | LICENSE |
secrets_test.go (6681B)
1 package scan 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "net/http" 7 "net/http/httptest" 8 "os" 9 "testing" 10 11 "evil.djnn.sh/djnn/yellow/core" 12 helper "evil.djnn.sh/djnn/yellow/helpers" 13 ) 14 15 // Fabricated, non-functional credentials, assembled at runtime so no 16 // secret-shaped literal sits in the source and trips push protection. 17 // AWS key ids are base32, hence the restricted charset there. 18 const ( 19 charsAlnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 20 charsDigits = "0123456789" 21 charsBase32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" 22 ) 23 24 func mkFake(prefix, charset string, n int) string { 25 b := make([]byte, n) 26 for i := range b { 27 b[i] = charset[(i*7+3)%len(charset)] 28 } 29 return prefix + string(b) 30 } 31 32 var ( 33 fakeSlackToken = mkFake("xoxb-", charsDigits, 12) + "-" + mkFake("", charsDigits, 13) 34 fakeGithubPAT = mkFake("ghp_", charsAlnum, 36) 35 fakeAWSKeyID = mkFake("AKIA", charsBase32, 16) 36 malformedAWSID = mkFake("AKIA", charsBase32, 15) + "9" // 9 is not base32 37 ) 38 39 func TestShouldScanBody(t *testing.T) { 40 tests := []struct { 41 ct string 42 want bool 43 }{ 44 {"application/javascript", true}, 45 {"text/javascript; charset=utf-8", true}, 46 {"application/json", true}, 47 {"text/html; charset=utf-8", true}, 48 {"text/css", true}, 49 {"application/xml", true}, 50 {"text/plain", true}, 51 {"image/png", false}, 52 {"font/woff2", false}, 53 {"video/mp4", false}, 54 {"application/octet-stream", false}, 55 {"", false}, 56 } 57 58 for _, tt := range tests { 59 if got := shouldScanBody(tt.ct); got != tt.want { 60 t.Errorf("shouldScanBody(%q) = %v, want %v", tt.ct, got, tt.want) 61 } 62 } 63 } 64 65 func TestFetchScannableSkipsBinary(t *testing.T) { 66 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 67 w.Header().Set("Content-Type", "image/png") 68 _, _ = w.Write([]byte("token = \"" + fakeSlackToken + "\"")) 69 })) 70 defer srv.Close() 71 72 if _, ok := fetchScannable(helper.GetHttpClient(true), srv.URL); ok { 73 t.Error("binary content-type must not be fetched for scanning") 74 } 75 } 76 77 func TestSecretsDetectsPlantedCredentials(t *testing.T) { 78 body := "var cfg={\n slack:\"" + fakeSlackToken + "\",\n" + 79 " github:\"" + fakeGithubPAT + "\",\n" + 80 " awsKeyId:\"" + fakeAWSKeyID + "\"\n};\n" 81 82 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 w.Header().Set("Content-Type", "application/javascript") 84 _, _ = w.Write([]byte(body)) 85 })) 86 defer srv.Close() 87 88 dir := t.TempDir() 89 ctx := &core.Context{Domain: srv.URL, ScanPath: dir, URLs: []string{srv.URL + "/app.js"}} 90 91 if err := (&Secrets{MaxURLs: 10}).Run(ctx); err != nil { 92 t.Fatalf("Run returned %v", err) 93 } 94 95 byRule := map[string]SecretFinding{} 96 for _, f := range readSecrets(t, dir) { 97 byRule[f.RuleID] = f 98 } 99 100 want := map[string]string{ 101 "slack-bot-token": fakeSlackToken, 102 "github-pat": fakeGithubPAT, 103 "aws-access-token": fakeAWSKeyID, 104 } 105 for rule, secret := range want { 106 got, ok := byRule[rule] 107 if !ok { 108 t.Errorf("rule %s did not fire; got rules %v", rule, keysOf(byRule)) 109 continue 110 } 111 if got.Secret != secret { 112 t.Errorf("%s: secret = %q, want %q", rule, got.Secret, secret) 113 } 114 if got.URL != srv.URL+"/app.js" { 115 t.Errorf("%s: url = %q, want the source URL", rule, got.URL) 116 } 117 } 118 } 119 120 // a key id with a char outside base32 is not a real AWS key and must not match 121 func TestSecretsIgnoresMalformedAWSKey(t *testing.T) { 122 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 123 w.Header().Set("Content-Type", "application/javascript") 124 _, _ = w.Write([]byte("var k=\"" + malformedAWSID + "\";\n")) 125 })) 126 defer srv.Close() 127 128 dir := t.TempDir() 129 ctx := &core.Context{Domain: srv.URL, ScanPath: dir, URLs: []string{srv.URL + "/bad.js"}} 130 131 if err := (&Secrets{MaxURLs: 10}).Run(ctx); err != nil { 132 t.Fatalf("Run returned %v", err) 133 } 134 for _, f := range readSecrets(t, dir) { 135 if f.RuleID == "aws-access-token" { 136 t.Errorf("matched a non-base32 key id: %q", f.Secret) 137 } 138 } 139 } 140 141 func keysOf(m map[string]SecretFinding) []string { 142 out := []string{} 143 for k := range m { 144 out = append(out, k) 145 } 146 return out 147 } 148 149 func TestSecretsDedupesSameSecretAcrossURLs(t *testing.T) { 150 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 w.Header().Set("Content-Type", "application/javascript") 152 _, _ = w.Write([]byte("var t=\"" + fakeGithubPAT + "\";\n")) 153 })) 154 defer srv.Close() 155 156 dir := t.TempDir() 157 ctx := &core.Context{ 158 Domain: srv.URL, 159 ScanPath: dir, 160 URLs: []string{srv.URL + "/a.js", srv.URL + "/b.js", srv.URL + "/c.js"}, 161 } 162 163 if err := (&Secrets{MaxURLs: 10}).Run(ctx); err != nil { 164 t.Fatalf("Run returned %v", err) 165 } 166 167 findings := readSecrets(t, dir) 168 if len(findings) != 1 { 169 t.Errorf("same secret on 3 URLs produced %d records, want 1 deduped", len(findings)) 170 } 171 } 172 173 func TestSecretsCleanBodyYieldsNoFindings(t *testing.T) { 174 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 175 w.Header().Set("Content-Type", "application/javascript") 176 _, _ = w.Write([]byte("function add(a,b){return a+b}\n")) 177 })) 178 defer srv.Close() 179 180 dir := t.TempDir() 181 ctx := &core.Context{Domain: srv.URL, ScanPath: dir, URLs: []string{srv.URL + "/clean.js"}} 182 183 if err := (&Secrets{MaxURLs: 10}).Run(ctx); err != nil { 184 t.Fatalf("Run returned %v", err) 185 } 186 if findings := readSecrets(t, dir); len(findings) != 0 { 187 t.Errorf("clean body produced %d findings: %+v", len(findings), findings) 188 } 189 } 190 191 // a secret past the old 10MB cap: buffering missed it, streaming finds it 192 func TestSecretsFindsCredentialPastTheOldCap(t *testing.T) { 193 filler := bytes.Repeat([]byte("// padding\n"), (12<<20)/11) 194 195 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 196 w.Header().Set("Content-Type", "application/javascript") 197 _, _ = w.Write(filler) 198 _, _ = w.Write([]byte("\nvar t=\"" + fakeGithubPAT + "\";\n")) 199 })) 200 defer srv.Close() 201 202 dir := t.TempDir() 203 ctx := &core.Context{Domain: srv.URL, ScanPath: dir, URLs: []string{srv.URL + "/bundle.js"}} 204 205 if err := (&Secrets{MaxURLs: 10}).Run(ctx); err != nil { 206 t.Fatalf("Run returned %v", err) 207 } 208 209 for _, f := range readSecrets(t, dir) { 210 if f.RuleID == "github-pat" && f.Secret == fakeGithubPAT { 211 return 212 } 213 } 214 t.Errorf("secret at offset %d MB was not found", len(filler)>>20) 215 } 216 217 func readSecrets(t *testing.T, dir string) []SecretFinding { 218 t.Helper() 219 data, err := os.ReadFile(dir + "/secrets.json") 220 if err != nil { 221 t.Fatalf("secrets.json not written: %v", err) 222 } 223 var findings []SecretFinding 224 if err := json.Unmarshal(data, &findings); err != nil { 225 t.Fatalf("secrets.json is not valid json: %v", err) 226 } 227 return findings 228 }