yellow.git


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 }