/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · Blazing-fast pentesting suite : : ▄█ █ █▀ · BSD 3-Clause License : : : : (c) 2022-2026 vmfunc, xyzeva, : : lunchcat alumni & contributors : : : ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· */ package scan import ( "net/http" "net/http/httptest" "strings" "testing" "time" ) // sample feed payloads. crt.sh packs several names per name_value (newline // separated) and emits wildcards; certspotter returns expanded dns_names. const ( crtshFixture = `[ {"name_value": "www.example.com\n*.example.com"}, {"name_value": "api.example.com"}, {"name_value": "WWW.example.com"} ]` certspotterFixture = `[ {"dns_names": ["mail.example.com", "api.example.com"]}, {"dns_names": ["*.example.com"]} ]` waybackFixture = "http://example.com/\n" + "http://example.com/login\n" + "http://example.com/login\n" + "\n" + "http://example.com/admin\n" ) // fixtureServer serves each passive source on its own path and repoints the // package base-url vars at it. the vars are restored on cleanup. func fixtureServer(t *testing.T, crtsh, certspotter, wayback string) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/crtsh", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(crtsh)) }) mux.HandleFunc("/certspotter", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(certspotter)) }) mux.HandleFunc("/wayback", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(wayback)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) origCrtsh, origCertspotter, origWayback := crtshBaseURL, certspotterBaseURL, waybackBaseURL // %s still consumes the domain so the production formatting path is exercised. crtshBaseURL = srv.URL + "/crtsh?q=%s" certspotterBaseURL = srv.URL + "/certspotter?domain=%s" waybackBaseURL = srv.URL + "/wayback?url=%s" t.Cleanup(func() { crtshBaseURL, certspotterBaseURL, waybackBaseURL = origCrtsh, origCertspotter, origWayback }) return srv } func TestPassive_ParsesAndDedupes(t *testing.T) { fixtureServer(t, crtshFixture, certspotterFixture, waybackFixture) result, err := Passive("https://example.com", 5*time.Second, "") if err != nil { t.Fatalf("Passive: %v", err) } // wildcards stripped, case-folded, and merged across both ct feeds. wantSubs := map[string]bool{ "www.example.com": false, "api.example.com": false, "mail.example.com": false, "example.com": false, // from "*.example.com" } for _, s := range result.Subdomains { if _, ok := wantSubs[s]; !ok { t.Errorf("unexpected subdomain %q", s) continue } wantSubs[s] = true } for s, seen := range wantSubs { if !seen { t.Errorf("missing subdomain %q in %v", s, result.Subdomains) } } if len(result.Subdomains) != len(wantSubs) { t.Errorf("expected %d deduped subdomains, got %d: %v", len(wantSubs), len(result.Subdomains), result.Subdomains) } // wayback: blank line dropped, duplicate /login collapsed. wantURLs := map[string]bool{ "http://example.com/": false, "http://example.com/login": false, "http://example.com/admin": false, } for _, u := range result.URLs { if _, ok := wantURLs[u]; !ok { t.Errorf("unexpected url %q", u) continue } wantURLs[u] = true } if len(result.URLs) != len(wantURLs) { t.Errorf("expected %d deduped urls, got %d: %v", len(wantURLs), len(result.URLs), result.URLs) } } func TestPassive_SourceFailureIsIsolated(t *testing.T) { // crt.sh serves garbage that fails to parse; the other feeds must still // produce results. fixtureServer(t, "not json", certspotterFixture, waybackFixture) result, err := Passive("https://example.com", 5*time.Second, "") if err != nil { t.Fatalf("Passive should not fail when one source is down: %v", err) } if len(result.Subdomains) == 0 { t.Error("expected certspotter subdomains despite crt.sh failure") } if len(result.URLs) == 0 { t.Error("expected wayback urls despite crt.sh failure") } if urlsContain(result.Subdomains, "www.example.com") { t.Error("crt.sh-only subdomain leaked despite parse failure") } } func TestPassive_ResultType(t *testing.T) { r := &PassiveResult{} if r.ResultType() != "passive" { t.Errorf("ResultType = %q, want passive", r.ResultType()) } } func TestPassive_ScopesSubdomainsToTarget(t *testing.T) { // notexample.com guards the suffix-match trap: not a subdomain of example.com. const sharedCert = `[ {"name_value": "www.example.com\nshared.othersite.com"}, {"name_value": "notexample.com\n*.example.com"} ]` fixtureServer(t, sharedCert, "[]", "") result, err := Passive("https://example.com", 5*time.Second, "") if err != nil { t.Fatalf("Passive: %v", err) } for _, off := range []string{"shared.othersite.com", "notexample.com"} { if urlsContain(result.Subdomains, off) { t.Errorf("off-scope name %q leaked as a subdomain: %v", off, result.Subdomains) } } if !urlsContain(result.Subdomains, "www.example.com") { t.Errorf("expected the in-scope subdomain to remain: %v", result.Subdomains) } } func TestPassive_SourcesUseTLS(t *testing.T) { // all three passive feeds must be fetched over https: an on-path attacker // able to tamper with a plain-http response could inject or drop // subdomains/urls in the reported results without detection. sources := map[string]string{ "crtsh": crtshBaseURL, "certspotter": certspotterBaseURL, "wayback": waybackBaseURL, } for name, base := range sources { if !strings.HasPrefix(base, "https://") { t.Errorf("%s base url is not https: %q", name, base) } } } func TestPassive_WaybackLongLineKeepsFeed(t *testing.T) { // a single archived url with a huge query string (data:/base64 blobs do // occur) must not discard every other harvested url. longURL := "http://example.com/?blob=" + strings.Repeat("a", 2*1024*1024) wayback := longURL + "\n" + "http://example.com/keep-one\n" + "http://example.com/keep-two\n" fixtureServer(t, "[]", "[]", wayback) result, err := Passive("https://example.com", 5*time.Second, "") if err != nil { t.Fatalf("Passive: %v", err) } for _, want := range []string{"http://example.com/keep-one", "http://example.com/keep-two"} { if !urlsContain(result.URLs, want) { t.Errorf("over-long wayback line dropped the feed; missing %q, got %d urls", want, len(result.URLs)) } } if !urlsContain(result.URLs, longURL) { t.Errorf("the over-long url itself was dropped, got %d urls", len(result.URLs)) } } func TestNormalizeHost(t *testing.T) { tests := []struct { in string want string }{ {"www.example.com", "www.example.com"}, {"*.example.com", "example.com"}, {"www.example.com.", "www.example.com"}, {" WWW.Example.COM ", "www.example.com"}, {"", ""}, } for _, tt := range tests { if got := normalizeHost(tt.in); got != tt.want { t.Errorf("normalizeHost(%q) = %q, want %q", tt.in, got, tt.want) } } }