From 839c0a779c93bbdc71968075ba1e7449933ae89c Mon Sep 17 00:00:00 2001 From: vmfunc Date: Wed, 10 Jun 2026 14:45:47 -0700 Subject: [PATCH 1/4] fix(scan): dnslist dedup, robots recursion bound, framework version lookup, takeover cname four recon-flagged bugs, each with a focused test: - dnslist fired both http and https per candidate and counted a "found" on any non-error response (incl 404 and wildcard catch-all redirects), so every host double-counted and a wildcard-dns host flooded results. probe http then https with per-subdomain dedupe, gate on a meaningful (2xx, non-redirect) status, and stop chasing redirects so a catch-all 301 reads as a redirect instead of a 200. - fetchRobotsTXT recursed on every 301 Location with no depth limit and no visited set, so an A->B->A loop blew the stack. bound it to a named hop cap and a visited set, iteratively. - framework cve lookup used best.version ("unknown" when the detector only fingerprints the framework) and threw away the version ExtractVersionOptimized dug out of the body, missing CVEs. reconcile via resolveVersion, preferring the extracted concrete version. - subdomain takeover flagged a dangling cname whenever a no-such-host coincided with ANY cname (LookupCNAME echoes the host back for plain A records). only flag when the cname points off-host at a known takeoverable provider. --- internal/scan/dnslist.go | 115 +++++++++++------- internal/scan/dnslist_test.go | 98 +++++++++++++++ internal/scan/frameworks/cve_internal_test.go | 53 +++++++- internal/scan/frameworks/detect.go | 33 ++++- internal/scan/scan.go | 50 +++++--- internal/scan/scan_test.go | 99 +++++++++++++++ internal/scan/subdomaintakeover.go | 64 +++++++++- 7 files changed, 446 insertions(+), 66 deletions(-) create mode 100644 internal/scan/dnslist_test.go diff --git a/internal/scan/dnslist.go b/internal/scan/dnslist.go index 7f7c592..2f88a6d 100644 --- a/internal/scan/dnslist.go +++ b/internal/scan/dnslist.go @@ -39,6 +39,23 @@ const ( dnsBigFile = "subdomains-10000.txt" ) +// dnsScheme labels which url won a subdomain so we don't probe the second +// scheme once the first already counted it. +type dnsScheme string + +const ( + dnsSchemeHTTP dnsScheme = "http" + dnsSchemeHTTPS dnsScheme = "https" +) + +// meaningfulStatus reports whether a probe response is a real "this host +// exists" signal rather than a 404 or a wildcard catch-all redirect. a +// wildcard-DNS host answers every candidate with the same redirect/404, so +// gating on a successful, non-redirect status keeps it from flooding results. +func meaningfulStatus(code int) bool { + return code >= http.StatusOK && code < http.StatusMultipleChoices +} + // Dnslist performs DNS subdomain enumeration on the target domain. func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) { log := output.Module("DNS") @@ -88,6 +105,12 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir if dnsTransport != nil { client.Transport = dnsTransport } + // don't chase redirects: a wildcard catch-all that 301s every candidate to + // the same landing page must read as a redirect status, not a 200, so it + // gets gated out instead of counting as a found host. + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } progress := output.NewProgress(len(dns), "enumerating") @@ -109,52 +132,25 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir charmlog.Debugf("Looking up: %s", domain) - // Check HTTP - httpReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "http://"+domain+"."+sanitizedURL, http.NoBody) - if err != nil { - charmlog.Debugf("Error %s: %s", domain, err) + // probe http first, then https - but a subdomain is recorded at + // most once. firing both schemes and appending on each is what + // double-counted every host on the old path. + host := domain + "." + sanitizedURL + foundURL, scheme := probeSubdomain(client, host) + if foundURL == "" { continue } - resp, err := client.Do(httpReq) - if err != nil { - charmlog.Debugf("Error %s: %s", domain, err) - } else { - mu.Lock() - urls = append(urls, resp.Request.URL.String()) - mu.Unlock() - resp.Body.Close() - progress.Pause() - log.Success("found: %s.%s [http]", output.Highlight.Render(domain), sanitizedURL) - progress.Resume() + mu.Lock() + urls = append(urls, foundURL) + mu.Unlock() - if logdir != "" { - logger.Write(sanitizedURL, logdir, fmt.Sprintf("[http] %s.%s\n", domain, sanitizedURL)) - } - } + progress.Pause() + log.Success("found: %s [%s]", output.Highlight.Render(host), scheme) + progress.Resume() - // Check HTTPS - httpsReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "https://"+domain+"."+sanitizedURL, http.NoBody) - if err != nil { - charmlog.Debugf("Error %s: %s", domain, err) - continue - } - resp, err = client.Do(httpsReq) - if err != nil { - charmlog.Debugf("Error %s: %s", domain, err) - } else { - mu.Lock() - urls = append(urls, resp.Request.URL.String()) - mu.Unlock() - resp.Body.Close() - - progress.Pause() - log.Success("found: %s.%s [https]", output.Highlight.Render(domain), sanitizedURL) - progress.Resume() - - if logdir != "" { - _ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("[https] %s.%s\n", domain, sanitizedURL)) - } + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("[%s] %s\n", scheme, host)) } } }(thread) @@ -166,3 +162,40 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir return urls, nil } + +// probeSubdomain tries http then https for one host and returns the resolved +// url + winning scheme on the first meaningful hit, or "" if neither scheme +// gave a real signal. trying https only when http didn't already count is the +// per-subdomain dedupe. +func probeSubdomain(client *http.Client, host string) (string, dnsScheme) { + schemes := []struct { + prefix string + label dnsScheme + }{ + {"http://", dnsSchemeHTTP}, + {"https://", dnsSchemeHTTPS}, + } + + for i := 0; i < len(schemes); i++ { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, schemes[i].prefix+host, http.NoBody) + if err != nil { + charmlog.Debugf("Error %s: %s", host, err) + continue + } + resp, err := client.Do(req) + if err != nil { + charmlog.Debugf("Error %s: %s", host, err) + continue + } + code := resp.StatusCode + resolved := resp.Request.URL.String() + resp.Body.Close() + + if meaningfulStatus(code) { + return resolved, schemes[i].label + } + charmlog.Debugf("skip %s [%s]: status %d", host, schemes[i].label, code) + } + + return "", "" +} diff --git a/internal/scan/dnslist_test.go b/internal/scan/dnslist_test.go new file mode 100644 index 0000000..4ac23e8 --- /dev/null +++ b/internal/scan/dnslist_test.go @@ -0,0 +1,98 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · 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" + "sync/atomic" + "testing" + "time" +) + +func TestMeaningfulStatus(t *testing.T) { + tests := []struct { + name string + code int + want bool + }{ + {"ok counts", http.StatusOK, true}, + {"204 counts", http.StatusNoContent, true}, + {"301 catch-all redirect dropped", http.StatusMovedPermanently, false}, + {"302 catch-all redirect dropped", http.StatusFound, false}, + {"404 dropped", http.StatusNotFound, false}, + {"500 dropped", http.StatusInternalServerError, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := meaningfulStatus(tt.code); got != tt.want { + t.Errorf("meaningfulStatus(%d) = %v, want %v", tt.code, got, tt.want) + } + }) + } +} + +// a host that answers 200 over http should count exactly once, not once per +// scheme - the old path appended on both http and https. +func TestProbeSubdomain_DedupesAcrossSchemes(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "http://") + client := &http.Client{Timeout: 5 * time.Second} + + url, scheme := probeSubdomain(client, host) + if url == "" { + t.Fatal("expected http probe to count the host") + } + if scheme != dnsSchemeHTTP { + t.Errorf("expected http scheme to win, got %q", scheme) + } + // http already counted, so https must not be tried - one request total. + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("expected exactly 1 probe request, got %d", got) + } +} + +// a wildcard catch-all that 404s (or 301s) every candidate must not be reported +// as found - that's the flood the gating closes. +func TestProbeSubdomain_WildcardCatchAllNotFound(t *testing.T) { + for _, code := range []int{http.StatusNotFound, http.StatusMovedPermanently} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if code == http.StatusMovedPermanently { + w.Header().Set("Location", "https://catch-all.example/") + } + w.WriteHeader(code) + })) + + host := strings.TrimPrefix(srv.URL, "http://") + client := &http.Client{ + Timeout: 5 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + url, _ := probeSubdomain(client, host) + if url != "" { + t.Errorf("status %d should not count as found, got %q", code, url) + } + srv.Close() + } +} diff --git a/internal/scan/frameworks/cve_internal_test.go b/internal/scan/frameworks/cve_internal_test.go index 1a5d896..a36a0df 100644 --- a/internal/scan/frameworks/cve_internal_test.go +++ b/internal/scan/frameworks/cve_internal_test.go @@ -14,6 +14,57 @@ package frameworks import "testing" +// the detector usually reports "unknown"; the version dug out of the body must +// win so the cve lookup runs against a concrete version instead of "unknown". +func TestResolveVersion(t *testing.T) { + tests := []struct { + name string + detector string + extracted string + want string + }{ + {"detector concrete wins", "9.0.0", "8.4.1", "9.0.0"}, + {"unknown detector falls back to extracted", "unknown", "8.4.1", "8.4.1"}, + {"empty detector falls back to extracted", "", "8.4.1", "8.4.1"}, + {"both unknown stays unknown", "unknown", "unknown", "unknown"}, + {"both empty/unknown stays unknown", "", "", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveVersion(tt.detector, tt.extracted); got != tt.want { + t.Errorf("resolveVersion(%q, %q) = %q, want %q", tt.detector, tt.extracted, got, tt.want) + } + }) + } +} + +// the regression itself: with the detector reporting "unknown" but a real +// version extractable from the body, the cve lookup must use the extracted +// version and surface the matching CVE - the old path looked up "unknown" and +// missed it. +func TestResolveVersionFeedsCVELookup(t *testing.T) { + const body = "Laravel 8.4.1" + + // extractor pulls the concrete version out of the body... + extracted := ExtractVersionOptimized(body, "Laravel").Version + if extracted != "8.4.1" { + t.Fatalf("expected extracted version 8.4.1, got %q", extracted) + } + + // ...and looking "unknown" up finds nothing, proving the old behavior missed it. + if cves, _ := getVulnerabilities("Laravel", "unknown"); len(cves) != 0 { + t.Fatalf("expected no CVEs for unknown version, got %v", cves) + } + + // the reconciled version feeds the lookup and the CVE shows up. + version := resolveVersion("unknown", extracted) + cves, _ := getVulnerabilities("Laravel", version) + if len(cves) == 0 { + t.Errorf("expected Laravel %s to surface a CVE, got none", version) + } +} + func TestVersionAffected(t *testing.T) { tests := []struct { version string @@ -23,7 +74,7 @@ func TestVersionAffected(t *testing.T) { {"4.2", "4.2", true}, {"4.2.1", "4.2", true}, {"4.2.13", "4.2", true}, - {"4.20", "4.2", false}, // the boundary bug: 4.20 is not a 4.2.x release + {"4.20", "4.2", false}, // the boundary bug: 4.20 is not a 4.2.x release {"4.20.0", "4.2", false}, {"5.0", "4.2", false}, } diff --git a/internal/scan/frameworks/detect.go b/internal/scan/frameworks/detect.go index 487b244..dd08edd 100644 --- a/internal/scan/frameworks/detect.go +++ b/internal/scan/frameworks/detect.go @@ -118,17 +118,22 @@ func DetectFramework(url string, timeout time.Duration, logdir string) (*Framewo return nil, nil //nolint:nilnil // no framework detected is not an error } - // Get version match details + // Get version match details. the detector's own best.version is often + // "unknown" (it only fingerprints the framework, not always the version), + // while ExtractVersionOptimized digs the real version out of the body. prefer + // that for both the reported version and the cve lookup, otherwise CVEs that + // only match a concrete version are silently missed. versionMatch := ExtractVersionOptimized(bodyStr, best.name) - cves, suggestions := getVulnerabilities(best.name, best.version) + version := resolveVersion(best.version, versionMatch.Version) + cves, suggestions := getVulnerabilities(best.name, version) - result := NewFrameworkResult(best.name, best.version, best.confidence, versionMatch.Confidence) + result := NewFrameworkResult(best.name, version, best.confidence, versionMatch.Confidence) result.WithVulnerabilities(cves, suggestions) // Log results if logdir != "" { logEntry := fmt.Sprintf("Detected framework: %s (version: %s, confidence: %.2f, version_confidence: %.2f)\n", - best.name, best.version, best.confidence, versionMatch.Confidence) + best.name, version, best.confidence, versionMatch.Confidence) if len(cves) > 0 { logEntry += fmt.Sprintf(" Risk Level: %s\n", result.RiskLevel) logEntry += fmt.Sprintf(" CVEs: %v\n", cves) @@ -138,7 +143,7 @@ func DetectFramework(url string, timeout time.Duration, logdir string) (*Framewo } log.Success("Detected %s framework (version: %s, confidence: %.2f)", - output.Highlight.Render(best.name), best.version, best.confidence) + output.Highlight.Render(best.name), version, best.confidence) if versionMatch.Confidence > 0 { charmlog.Debugf("Version detected from: %s (confidence: %.2f)", @@ -160,6 +165,24 @@ func DetectFramework(url string, timeout time.Duration, logdir string) (*Framewo return result, nil } +// unknownVersion is the sentinel both detectors and the version extractor emit +// when no concrete version could be read from the response. +const unknownVersion = "unknown" + +// resolveVersion picks the version to report and look CVEs up against. the +// detector's own value wins when it's concrete; otherwise we fall back to the +// version dug out of the body by ExtractVersionOptimized. either being +// "unknown"/empty means "no info", not a real version. +func resolveVersion(detectorVersion, extractedVersion string) string { + if detectorVersion != "" && detectorVersion != unknownVersion { + return detectorVersion + } + if extractedVersion != "" && extractedVersion != unknownVersion { + return extractedVersion + } + return unknownVersion +} + // getVulnerabilities returns CVEs and recommendations for a framework version. func getVulnerabilities(framework, version string) ([]string, []string) { entries, exists := knownCVEs[framework] diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 91e3aab..487dcaa 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -41,29 +41,49 @@ func stripScheme(url string) string { return url } -func fetchRobotsTXT(url string, client *http.Client) *http.Response { - req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody) - if err != nil { - log.Debugf("Error creating request for robots.txt: %s", err) - return nil - } - resp, err := client.Do(req) - if err != nil { - log.Debugf("Error fetching robots.txt: %s", err) - return nil - } +// maxRobotsRedirects caps how many 301 hops fetchRobotsTXT will chase. without +// a bound an A->B->A redirect loop recursed forever and blew the stack. +const maxRobotsRedirects = 10 + +// fetchRobotsTXT follows 301s to robots.txt iteratively, bounded by both a hop +// cap and a visited set so a redirect cycle terminates instead of recursing +// without end. +func fetchRobotsTXT(url string, client *http.Client) *http.Response { + visited := make(map[string]bool, maxRobotsRedirects) + + for hop := 0; hop < maxRobotsRedirects; hop++ { + if visited[url] { + log.Debugf("redirect loop hit at %s, stopping", url) + return nil + } + visited[url] = true + + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody) + if err != nil { + log.Debugf("Error creating request for robots.txt: %s", err) + return nil + } + resp, err := client.Do(req) + if err != nil { + log.Debugf("Error fetching robots.txt: %s", err) + return nil + } + + if resp.StatusCode != http.StatusMovedPermanently { + return resp + } - if resp.StatusCode == http.StatusMovedPermanently { redirectURL := resp.Header.Get("Location") + resp.Body.Close() if redirectURL == "" { log.Debugf("Redirect location is empty for %s", url) return nil } - resp.Body.Close() - return fetchRobotsTXT(redirectURL, client) + url = redirectURL } - return resp + log.Debugf("robots.txt redirect depth exceeded (%d hops)", maxRobotsRedirects) + return nil } // Scan performs a basic URL scan, including checks for robots.txt and other common endpoints. diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go index dc4ded8..876e56e 100644 --- a/internal/scan/scan_test.go +++ b/internal/scan/scan_test.go @@ -3,7 +3,9 @@ package scan import ( "net/http" "net/http/httptest" + "strconv" "strings" + "sync/atomic" "testing" "time" ) @@ -155,6 +157,103 @@ func TestFetchRobotsTXT_Redirect(t *testing.T) { } } +// an A->B->A redirect loop must terminate (return nil) instead of recursing +// forever and blowing the stack. +func TestFetchRobotsTXT_RedirectLoop(t *testing.T) { + var serverA, serverB *httptest.Server + + serverA = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", serverB.URL+"/robots.txt") + w.WriteHeader(http.StatusMovedPermanently) + })) + defer serverA.Close() + + serverB = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", serverA.URL+"/robots.txt") + w.WriteHeader(http.StatusMovedPermanently) + })) + defer serverB.Close() + + client := &http.Client{ + Timeout: 5 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // the hop cap + visited set guarantee termination; a regression that drops + // either would spin forever and the test harness timeout would catch it. + resp := fetchRobotsTXT(serverA.URL+"/robots.txt", client) + if resp != nil { + resp.Body.Close() + t.Errorf("expected nil on redirect loop, got status %d", resp.StatusCode) + } +} + +// a redirect chain longer than the hop cap stops at the bound rather than +// following indefinitely. +func TestFetchRobotsTXT_DepthCap(t *testing.T) { + var hops int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // each hop points at a fresh path so the visited set never trips; only + // the depth cap can stop this. + n := atomic.AddInt32(&hops, 1) + w.Header().Set("Location", "/r"+strconv.Itoa(int(n))) + w.WriteHeader(http.StatusMovedPermanently) + })) + defer srv.Close() + + client := &http.Client{ + Timeout: 5 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp := fetchRobotsTXT(srv.URL+"/robots.txt", client) + if resp != nil { + resp.Body.Close() + t.Errorf("expected nil once depth cap exceeded, got status %d", resp.StatusCode) + } + if got := atomic.LoadInt32(&hops); got > maxRobotsRedirects { + t.Errorf("followed %d hops, expected at most %d", got, maxRobotsRedirects) + } +} + +// the old code flagged a dangling cname on ANY cname, including LookupCNAME +// echoing the host back for a plain A record. only an off-host cname into a +// known takeoverable provider should count. +func TestDanglingProvider(t *testing.T) { + tests := []struct { + name string + subdomain string + cname string + wantService string + wantOK bool + }{ + {"github pages dangling", "blog.example.com", "example.github.io.", "GitHub Pages", true}, + {"heroku dangling", "app.example.com", "example.herokuapp.com.", "Heroku", true}, + {"s3 dangling", "files.example.com", "bucket.s3.amazonaws.com.", "Amazon S3", true}, + {"self-reference is not dangling", "www.example.com", "www.example.com.", "", false}, + {"on-domain cname is not dangling", "www.example.com", "lb.example.com.", "", false}, + {"unknown provider is not dangling", "x.example.com", "host.notaprovider.net.", "", false}, + {"empty cname is not dangling", "x.example.com", "", "", false}, + {"case-insensitive match", "x.example.com", "X.GitHub.IO.", "GitHub Pages", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, ok := danglingProvider(tt.subdomain, tt.cname) + if ok != tt.wantOK { + t.Errorf("danglingProvider(%q, %q) ok = %v, want %v", tt.subdomain, tt.cname, ok, tt.wantOK) + } + if service != tt.wantService { + t.Errorf("danglingProvider(%q, %q) service = %q, want %q", tt.subdomain, tt.cname, service, tt.wantService) + } + }) + } +} + func TestSubdomainTakeoverResult(t *testing.T) { result := SubdomainTakeoverResult{ Subdomain: "test.example.com", diff --git a/internal/scan/subdomaintakeover.go b/internal/scan/subdomaintakeover.go index 1d0cfbd..d1389f4 100644 --- a/internal/scan/subdomaintakeover.go +++ b/internal/scan/subdomaintakeover.go @@ -37,6 +37,36 @@ type SubdomainTakeoverResult struct { Service string `json:"service,omitempty"` } +// takeoverProviders maps a takeoverable third-party's cname apex to its service +// name. a "no such host" on a subdomain only counts as a dangling-cname takeover +// when the cname points at one of these and the target is unclaimed - a cname +// to anything else (or to the host itself) is a normal record, not a finding. +var takeoverProviders = map[string]string{ + "github.io": "GitHub Pages", + "herokuapp.com": "Heroku", + "herokudns.com": "Heroku", + "myshopify.com": "Shopify", + "wordpress.com": "WordPress", + "s3.amazonaws.com": "Amazon S3", + "ghost.io": "Ghost", + "pantheonsite.io": "Pantheon", + "zendesk.com": "Zendesk", + "surge.sh": "Surge", + "bitbucket.io": "Bitbucket", + "fastly.net": "Fastly", + "helpscoutdocs.com": "Helpscout", + "cargocollective.com": "Cargo", + "uservoice.com": "Uservoice", + "webflow.io": "Webflow", + "readthedocs.io": "ReadTheDocs", + "azurewebsites.net": "Azure", + "cloudapp.net": "Azure", + "trafficmanager.net": "Azure", + "blob.core.windows.net": "Azure", + "netlify.app": "Netlify", + "netlify.com": "Netlify", +} + // SubdomainTakeover checks dnsResults for dangling subdomains pointing at // unclaimed third-party services. func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, threads int, logdir string) ([]SubdomainTakeoverResult, error) { @@ -104,6 +134,27 @@ func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, t return results, nil } +// danglingProvider reports whether cname points off-host at a known +// takeoverable provider. a self-referential cname (LookupCNAME echoing an A +// record back as the host) is rejected, since that's a live host, not a +// dangling pointer. +func danglingProvider(subdomain, cname string) (string, bool) { + // LookupCNAME returns a fqdn with a trailing dot; strip it so suffix and + // self-reference checks compare like-for-like. + target := strings.ToLower(strings.TrimSuffix(cname, ".")) + host := strings.ToLower(strings.TrimSuffix(subdomain, ".")) + if target == "" || target == host { + return "", false + } + + for apex, service := range takeoverProviders { + if target == apex || strings.HasSuffix(target, "."+apex) { + return service, true + } + } + return "", false +} + func checkSubdomainTakeover(subdomain string, client *http.Client) (bool, string) { req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "http://"+subdomain, http.NoBody) if err != nil { @@ -111,11 +162,16 @@ func checkSubdomainTakeover(subdomain string, client *http.Client) (bool, string } resp, err := client.Do(req) if err != nil { + // a dead host only matters if its cname still points at an unclaimed + // third-party service. LookupCNAME echoes the host back for plain A + // records, so "any cname" is not a signal - the cname must resolve to a + // known takeoverable provider and not be the host itself. if strings.Contains(err.Error(), "no such host") { - // Check if CNAME exists - cname, err := net.DefaultResolver.LookupCNAME(context.TODO(), subdomain) - if err == nil && cname != "" { - return true, "Dangling CNAME" + cname, lookupErr := net.DefaultResolver.LookupCNAME(context.TODO(), subdomain) + if lookupErr == nil { + if service, ok := danglingProvider(subdomain, cname); ok { + return true, service + " (Dangling CNAME)" + } } } return false, "" From 320fc3d4e7ad74b5cd7fd09458721ad9aeb8668d Mon Sep 17 00:00:00 2001 From: vmfunc Date: Wed, 10 Jun 2026 14:45:21 -0700 Subject: [PATCH 2/4] test(modules): cover matchers, extractors, loader and executor the yaml module engine (the user-facing extensibility surface) had 0% test coverage. add table-driven tests for the matcher types (status/word/regex + and/or + negative), checkWords/checkRegex (incl invalid-pattern fail-closed under AND, skip under OR), runExtractors (regex capture groups, group-index bounds, part selection), substituteVariables and generateHTTPRequests (path x payload expansion), and ParseYAMLModule on valid + malformed yaml. drive ExecuteHTTPModule end-to-end against an httptest server through the shared httpx client so matcher hits and extractor captures are exercised for real. coverage 0% -> 93.7%. also: ExecuteDNSModule/ExecuteTCPModule were stubs returning an empty result with nil error, so a type:dns/type:tcp module silently reported "0 findings" - indistinguishable from a real clean scan. make them return ErrUnsupportedModuleType (sentinel, wrapped with the module id) so the existing caller logs a clear failure instead. a test pins the new behavior. bodyclose is excluded for test files in .golangci.yml: the synthetic *http.Response fixtures carry no socket, mirroring the existing _test.go slack for errcheck/noctx/gosec. --- .golangci.yml | 1 + internal/modules/executor.go | 32 +- internal/modules/executor_test.go | 270 +++++++++++++++++ internal/modules/loader_test.go | 269 +++++++++++++++++ internal/modules/matchers_test.go | 465 ++++++++++++++++++++++++++++++ 5 files changed, 1021 insertions(+), 16 deletions(-) create mode 100644 internal/modules/executor_test.go create mode 100644 internal/modules/loader_test.go create mode 100644 internal/modules/matchers_test.go diff --git a/.golangci.yml b/.golangci.yml index 08583ea..f256c34 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -89,6 +89,7 @@ linters: - errcheck - noctx - gosec # fake credentials in secret-scanner fixtures are not real keys + - bodyclose # synthetic *http.Response fixtures carry no socket to close issues: max-issues-per-linter: 50 diff --git a/internal/modules/executor.go b/internal/modules/executor.go index eafba93..e08aeeb 100644 --- a/internal/modules/executor.go +++ b/internal/modules/executor.go @@ -14,6 +14,7 @@ package modules import ( "context" + "errors" "fmt" "io" "net/http" @@ -26,6 +27,11 @@ import ( // MaxBodySize limits response body to prevent memory exhaustion. const MaxBodySize = 5 * 1024 * 1024 +// ErrUnsupportedModuleType signals an executor for a module type that is not +// yet implemented. Returning it (rather than an empty result) keeps callers +// from mistaking "not implemented" for "scanned, found nothing". +var ErrUnsupportedModuleType = errors.New("unsupported module type") + // httpRequest represents a generated HTTP request. type httpRequest struct { Method string @@ -379,22 +385,16 @@ func truncateEvidence(s string) string { return s } -// ExecuteDNSModule runs a DNS-based module (stub for now). -func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { - // TODO: Implement DNS module execution - return &Result{ - ModuleID: def.ID, - Target: target, - Findings: []Finding{}, - }, nil +// ExecuteDNSModule runs a DNS-based module (not yet implemented). +// returns ErrUnsupportedModuleType so the caller logs a clear failure rather +// than reporting an empty (but successful-looking) result. +func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) { + return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType) } -// ExecuteTCPModule runs a TCP-based module (stub for now). -func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { - // TODO: Implement TCP module execution - return &Result{ - ModuleID: def.ID, - Target: target, - Findings: []Finding{}, - }, nil +// ExecuteTCPModule runs a TCP-based module (not yet implemented). +// returns ErrUnsupportedModuleType so the caller logs a clear failure rather +// than reporting an empty (but successful-looking) result. +func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) { + return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType) } diff --git a/internal/modules/executor_test.go b/internal/modules/executor_test.go new file mode 100644 index 0000000..e1fcd56 --- /dev/null +++ b/internal/modules/executor_test.go @@ -0,0 +1,270 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dropalldatabases/sif/internal/httpx" +) + +const testTimeout = 5 * time.Second + +// TestExecuteHTTPModuleMatchAndExtract drives the full executor against a live +// httptest server: a request hits a path, a matcher fires, an extractor captures. +func TestExecuteHTTPModuleMatchAndExtract(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/admin" { + w.Header().Set("X-App", "demo") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`flag{found-it} session=sess-4242`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + def := &YAMLModule{ + ID: "test-http-hit", + Type: TypeHTTP, + Info: YAMLModuleInfo{Severity: "high"}, + HTTP: &HTTPConfig{ + Method: "GET", + Paths: []string{"{{BaseURL}}/admin", "{{BaseURL}}/missing"}, + Matchers: []Matcher{ + {Type: "status", Status: []int{200}}, + {Type: "word", Part: "body", Words: []string{"flag{found-it}"}}, + }, + Extractors: []Extractor{ + {Type: "regex", Name: "session", Part: "body", Regex: []string{`session=(\S+)`}, Group: 1}, + }, + }, + } + + // route through the shared httpx client so proxy/-H/-rate-limit would apply. + opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)} + + result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts) + if err != nil { + t.Fatalf("ExecuteHTTPModule: %v", err) + } + + // only /admin satisfies status+word, /missing returns 404. + if len(result.Findings) != 1 { + t.Fatalf("got %d findings, want 1", len(result.Findings)) + } + f := result.Findings[0] + if f.Severity != "high" { + t.Errorf("severity = %q, want high (carried from Info)", f.Severity) + } + if f.Extracted["session"] != "sess-4242" { + t.Errorf("extracted session = %q, want sess-4242", f.Extracted["session"]) + } + if f.URL != srv.URL+"/admin" { + t.Errorf("finding url = %q, want %q", f.URL, srv.URL+"/admin") + } +} + +// TestExecuteHTTPModuleNoMatch confirms a module that matches nothing reports +// zero findings without erroring. +func TestExecuteHTTPModuleNoMatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("nothing interesting")) + })) + defer srv.Close() + + def := &YAMLModule{ + ID: "test-http-miss", + Type: TypeHTTP, + HTTP: &HTTPConfig{ + Paths: []string{"{{BaseURL}}/"}, + Matchers: []Matcher{ + {Type: "word", Part: "body", Words: []string{"never-present"}}, + }, + }, + } + + result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}) + if err != nil { + t.Fatalf("ExecuteHTTPModule: %v", err) + } + if len(result.Findings) != 0 { + t.Fatalf("got %d findings, want 0", len(result.Findings)) + } +} + +// TestExecuteHTTPModulePayloadExpansion verifies payload templates reach the +// server and the matching response is captured. +func TestExecuteHTTPModulePayloadExpansion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // only the "boom" payload triggers the vulnerable branch. + if r.URL.Query().Get("q") == "boom" { + _, _ = w.Write([]byte("error: sql syntax near boom")) + return + } + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + def := &YAMLModule{ + ID: "test-http-payload", + Type: TypeHTTP, + HTTP: &HTTPConfig{ + Paths: []string{"{{BaseURL}}/search?q={{payload}}"}, + Payloads: []string{"safe", "boom"}, + Matchers: []Matcher{ + {Type: "word", Part: "body", Words: []string{"sql syntax"}}, + }, + }, + } + + result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}) + if err != nil { + t.Fatalf("ExecuteHTTPModule: %v", err) + } + if len(result.Findings) != 1 { + t.Fatalf("got %d findings, want 1 (only boom payload)", len(result.Findings)) + } +} + +func TestExecuteHTTPModuleNoConfig(t *testing.T) { + def := &YAMLModule{ID: "x", Type: TypeHTTP} + if _, err := ExecuteHTTPModule(context.Background(), "http://h", def, Options{}); err == nil { + t.Fatal("expected error when HTTP config is nil") + } +} + +// TestExecuteHTTPModuleContextCancel pins the cancellation path. The dispatch +// loop selects between ctx.Done() and the concurrency semaphore, so a cancelled +// context can either short-circuit with ctx.Err() or let the in-flight request +// fail on the dead context. Both are correct: the contract is "never hang, never +// invent a finding", which is what we assert here rather than forcing one race +// winner (that made this test flaky under -count). +func TestExecuteHTTPModuleContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + def := &YAMLModule{ + ID: "test-http-cancel", + Type: TypeHTTP, + HTTP: &HTTPConfig{ + Paths: []string{"{{BaseURL}}/a"}, + Matchers: []Matcher{{Type: "status", Status: []int{200}}}, + }, + } + + result, err := ExecuteHTTPModule(ctx, srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}) + if err != nil { + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled or nil", err) + } + return + } + // no error means the request was dispatched but failed on the dead context; + // either way a cancelled scan must not surface findings. + if len(result.Findings) != 0 { + t.Fatalf("cancelled scan produced %d findings, want 0", len(result.Findings)) + } +} + +// TestExecuteDNSModuleUnsupported pins the current behavior: DNS execution is +// not implemented and must signal it via ErrUnsupportedModuleType, not by +// quietly returning an empty (successful-looking) result. +func TestExecuteDNSModuleUnsupported(t *testing.T) { + def := &YAMLModule{ID: "dns-mod", Type: TypeDNS, DNS: &DNSConfig{Type: "A"}} + result, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}) + if result != nil { + t.Errorf("result = %v, want nil for unsupported type", result) + } + if !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } +} + +func TestExecuteTCPModuleUnsupported(t *testing.T) { + def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}} + result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}) + if result != nil { + t.Errorf("result = %v, want nil for unsupported type", result) + } + if !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } +} + +// TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each +// type to the right executor and propagates the unsupported-type sentinel. +func TestWrapperExecuteRoutesByType(t *testing.T) { + t.Run("dns routes to unsupported", func(t *testing.T) { + def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{}} + w := newYAMLModuleWrapper(def, "d.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } + }) + + t.Run("tcp routes to unsupported", func(t *testing.T) { + def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}} + w := newYAMLModuleWrapper(def, "t.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } + }) + + t.Run("missing http config errors", func(t *testing.T) { + def := &YAMLModule{ID: "h", Type: TypeHTTP} + w := newYAMLModuleWrapper(def, "h.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); err == nil { + t.Fatal("expected error for missing http config") + } + }) + + t.Run("unknown type errors", func(t *testing.T) { + def := &YAMLModule{ID: "z", Type: ModuleType("bogus")} + w := newYAMLModuleWrapper(def, "z.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); err == nil { + t.Fatal("expected error for unknown module type") + } + }) +} + +func TestTruncateEvidence(t *testing.T) { + short := "short evidence" + if got := truncateEvidence(short); got != short { + t.Errorf("short evidence changed: %q", got) + } + + long := make([]byte, 600) + for i := range long { + long[i] = 'a' + } + got := truncateEvidence(string(long)) + // 500 chars of content plus the ellipsis marker. + if len(got) != 503 { + t.Errorf("truncated len = %d, want 503", len(got)) + } + if got[len(got)-3:] != "..." { + t.Errorf("truncated evidence missing ellipsis: %q", got[len(got)-3:]) + } +} diff --git a/internal/modules/loader_test.go b/internal/modules/loader_test.go new file mode 100644 index 0000000..1356a36 --- /dev/null +++ b/internal/modules/loader_test.go @@ -0,0 +1,269 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "os" + "path/filepath" + "testing" +) + +// writeModule drops a yaml file into a temp dir and returns its path. +func writeModule(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write module: %v", err) + } + return path +} + +func TestParseYAMLModuleValid(t *testing.T) { + const doc = `id: example-http +type: http +info: + name: Example + author: azzie + severity: medium + description: a test module + tags: [test, demo] +http: + method: GET + paths: + - "{{BaseURL}}/admin" + matchers: + - type: status + status: [200] + - type: word + part: body + words: ["admin"] + condition: and + extractors: + - type: regex + name: token + part: body + regex: ["token=(\\w+)"] + group: 1 +` + dir := t.TempDir() + path := writeModule(t, dir, "ok.yaml", doc) + + def, err := ParseYAMLModule(path) + if err != nil { + t.Fatalf("ParseYAMLModule: %v", err) + } + if def.ID != "example-http" { + t.Errorf("id = %q, want example-http", def.ID) + } + if def.Type != TypeHTTP { + t.Errorf("type = %q, want http", def.Type) + } + if def.Info.Severity != "medium" { + t.Errorf("severity = %q, want medium", def.Info.Severity) + } + if def.HTTP == nil { + t.Fatal("http config not parsed") + } + if len(def.HTTP.Matchers) != 2 { + t.Errorf("got %d matchers, want 2", len(def.HTTP.Matchers)) + } + if len(def.HTTP.Extractors) != 1 || def.HTTP.Extractors[0].Group != 1 { + t.Errorf("extractor not parsed correctly: %+v", def.HTTP.Extractors) + } + if len(def.Info.Tags) != 2 { + t.Errorf("got %d tags, want 2", len(def.Info.Tags)) + } +} + +func TestParseYAMLModuleErrors(t *testing.T) { + dir := t.TempDir() + + tests := []struct { + name string + content string + }{ + { + name: "missing id", + content: "type: http\nhttp:\n paths: [\"/\"]\n", + }, + { + name: "missing type", + content: "id: no-type\nhttp:\n paths: [\"/\"]\n", + }, + { + name: "malformed yaml", + content: "id: bad\ntype: http\n paths: [unbalanced\n : nope\n", + }, + { + // a scalar where a mapping is expected must fail to unmarshal. + name: "type mismatch", + content: "id: bad-shape\ntype: http\nhttp: \"should-be-a-map\"\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeModule(t, dir, tt.name+".yaml", tt.content) + if _, err := ParseYAMLModule(path); err == nil { + t.Fatalf("expected error for %s", tt.name) + } + }) + } +} + +func TestParseYAMLModuleMissingFile(t *testing.T) { + if _, err := ParseYAMLModule(filepath.Join(t.TempDir(), "does-not-exist.yaml")); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestYAMLModuleWrapperInfoAndType(t *testing.T) { + def := &YAMLModule{ + ID: "wrap-test", + Type: TypeHTTP, + Info: YAMLModuleInfo{ + Name: "Wrapped", + Author: "azzie", + Severity: "low", + Description: "desc", + Tags: []string{"a", "b"}, + }, + } + w := newYAMLModuleWrapper(def, "wrap.yaml") + + if w.Type() != TypeHTTP { + t.Errorf("Type() = %q, want http", w.Type()) + } + info := w.Info() + if info.ID != "wrap-test" || info.Name != "Wrapped" || info.Severity != "low" { + t.Errorf("Info() mismatch: %+v", info) + } + if len(info.Tags) != 2 { + t.Errorf("Info().Tags = %v, want 2 entries", info.Tags) + } +} + +// TestLoaderLoadAll exercises the directory walk: a valid module registers, a +// malformed one is skipped without aborting the walk. +func TestLoaderLoadAll(t *testing.T) { + Clear() + t.Cleanup(Clear) + + dir := t.TempDir() + writeModule(t, dir, "good.yaml", "id: good-mod\ntype: http\nhttp:\n paths: [\"{{BaseURL}}/\"]\n matchers:\n - type: status\n status: [200]\n") + writeModule(t, dir, "bad.yml", "id: bad-mod\n") // missing type -> skipped + writeModule(t, dir, "ignore.txt", "not a module") + + l := &Loader{builtinDir: dir, userDir: filepath.Join(dir, "nonexistent-user")} + if err := l.LoadAll(); err != nil { + t.Fatalf("LoadAll: %v", err) + } + + // only the good module loads; the malformed one is logged and skipped. + if l.Loaded() != 1 { + t.Errorf("Loaded() = %d, want 1", l.Loaded()) + } + if _, ok := Get("good-mod"); !ok { + t.Error("good-mod not registered") + } + if _, ok := Get("bad-mod"); ok { + t.Error("bad-mod should not have registered") + } +} + +func TestNewLoaderDirs(t *testing.T) { + l, err := NewLoader() + if err != nil { + t.Fatalf("NewLoader: %v", err) + } + if l.BuiltinDir() == "" { + t.Error("BuiltinDir is empty") + } + if l.UserDir() == "" { + t.Error("UserDir is empty") + } +} + +// TestRegistry exercises the package-level registry: register, get, dedupe by +// id, filter by tag and type, count and clear. +func TestRegistry(t *testing.T) { + Clear() + t.Cleanup(Clear) + + http1 := newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web", "cve"}}}, "h1") + http2 := newYAMLModuleWrapper(&YAMLModule{ID: "h2", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web"}}}, "h2") + dns1 := newYAMLModuleWrapper(&YAMLModule{ID: "d1", Type: TypeDNS, Info: YAMLModuleInfo{Tags: []string{"dns"}}}, "d1") + + Register(http1) + Register(http2) + Register(dns1) + + if Count() != 3 { + t.Fatalf("Count() = %d, want 3", Count()) + } + + got, ok := Get("h1") + if !ok || got.Info().ID != "h1" { + t.Errorf("Get(h1) = %v, %v", got, ok) + } + if _, ok := Get("missing"); ok { + t.Error("Get(missing) should report not found") + } + + if n := len(ByType(TypeHTTP)); n != 2 { + t.Errorf("ByType(http) = %d, want 2", n) + } + if n := len(ByType(TypeDNS)); n != 1 { + t.Errorf("ByType(dns) = %d, want 1", n) + } + if n := len(ByTag("web")); n != 2 { + t.Errorf("ByTag(web) = %d, want 2", n) + } + if n := len(ByTag("cve")); n != 1 { + t.Errorf("ByTag(cve) = %d, want 1", n) + } + if n := len(ByTag("none")); n != 0 { + t.Errorf("ByTag(none) = %d, want 0", n) + } + if n := len(All()); n != 3 { + t.Errorf("All() = %d, want 3", n) + } + + // re-registering the same id overwrites rather than duplicating. + Register(newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP}, "h1-v2")) + if Count() != 3 { + t.Errorf("Count() after re-register = %d, want 3", Count()) + } + + Clear() + if Count() != 0 { + t.Errorf("Count() after Clear = %d, want 0", Count()) + } +} + +// TestResultType pins the ScanResult interface bridge. +func TestResultType(t *testing.T) { + r := &Result{ModuleID: "abc"} + if r.ResultType() != "abc" { + t.Errorf("ResultType() = %q, want abc", r.ResultType()) + } +} + +// TestLoaderScriptStubNoop confirms the go-script loader is currently a no-op +// that registers nothing and returns no error. +func TestLoaderScriptStubNoop(t *testing.T) { + l := &Loader{} + if err := l.loadScript("anything.go"); err != nil { + t.Errorf("loadScript stub returned error: %v", err) + } +} diff --git a/internal/modules/matchers_test.go b/internal/modules/matchers_test.go new file mode 100644 index 0000000..ccdace5 --- /dev/null +++ b/internal/modules/matchers_test.go @@ -0,0 +1,465 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "net/http" + "strings" + "testing" +) + +// fakeResponse builds a minimal *http.Response for matcher/extractor tests. +// it carries no real socket (Body is http.NoBody), so there is nothing to +// close; bodyclose is excluded for test files in .golangci.yml. header drives +// the header/all parts without a live server; matchers read the body string +// argument, not resp.Body. +func fakeResponse(t *testing.T, status int, header http.Header) *http.Response { + t.Helper() + if header == nil { + header = http.Header{} + } + return &http.Response{StatusCode: status, Header: header, Body: http.NoBody} +} + +func TestCheckMatcherStatus(t *testing.T) { + tests := []struct { + name string + status int + want []int + expect bool + }{ + {name: "single match", status: 200, want: []int{200}, expect: true}, + {name: "one of many", status: 404, want: []int{200, 301, 404}, expect: true}, + {name: "no match", status: 500, want: []int{200, 404}, expect: false}, + {name: "empty status list", status: 200, want: nil, expect: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Matcher{Type: "status", Status: tt.want} + resp := fakeResponse(t, tt.status, nil) + if got := checkMatcher(m, resp, ""); got != tt.expect { + t.Errorf("checkMatcher status = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestCheckMatcherWord(t *testing.T) { + const body = "welcome admin dashboard" + + tests := []struct { + name string + words []string + condition string + expect bool + }{ + {name: "and all present", words: []string{"admin", "dashboard"}, condition: "and", expect: true}, + {name: "and one missing", words: []string{"admin", "missing"}, condition: "and", expect: false}, + {name: "default is and", words: []string{"admin", "missing"}, condition: "", expect: false}, + {name: "or one present", words: []string{"missing", "admin"}, condition: "or", expect: true}, + {name: "or none present", words: []string{"missing", "absent"}, condition: "or", expect: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Matcher{Type: "word", Part: "body", Words: tt.words, Condition: tt.condition} + resp := fakeResponse(t, 200, nil) + if got := checkMatcher(m, resp, body); got != tt.expect { + t.Errorf("checkMatcher word = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestCheckMatcherRegex(t *testing.T) { + const body = "version 1.2.3 build 99" + + tests := []struct { + name string + patterns []string + condition string + expect bool + }{ + {name: "and all match", patterns: []string{`version \d`, `build \d+`}, condition: "and", expect: true}, + {name: "and one fails", patterns: []string{`version \d`, `nope\d`}, condition: "and", expect: false}, + {name: "or one matches", patterns: []string{`nope`, `build \d+`}, condition: "or", expect: true}, + {name: "or none match", patterns: []string{`nope`, `zilch`}, condition: "or", expect: false}, + // an invalid pattern under AND must fail closed, not panic. + {name: "and invalid pattern fails closed", patterns: []string{`version \d`, `(`}, condition: "and", expect: false}, + // under OR an invalid pattern is skipped, a later valid one can still hit. + {name: "or invalid pattern skipped", patterns: []string{`(`, `build \d+`}, condition: "or", expect: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Matcher{Type: "regex", Part: "body", Regex: tt.patterns, Condition: tt.condition} + resp := fakeResponse(t, 200, nil) + if got := checkMatcher(m, resp, body); got != tt.expect { + t.Errorf("checkMatcher regex = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestCheckMatcherHeaderPart(t *testing.T) { + header := http.Header{"X-Powered-By": []string{"PHP/8.1"}} + resp := fakeResponse(t, 200, header) + + m := &Matcher{Type: "word", Part: "header", Words: []string{"PHP/8.1"}} + if !checkMatcher(m, resp, "body-content") { + t.Error("expected header-part word matcher to hit on header value") + } + + // the same word lives only in the header, so a body-part matcher must miss. + mBody := &Matcher{Type: "word", Part: "body", Words: []string{"PHP/8.1"}} + if checkMatcher(mBody, resp, "body-content") { + t.Error("body-part matcher should not see header-only value") + } +} + +func TestCheckMatcherUnknownType(t *testing.T) { + m := &Matcher{Type: "size", Part: "body"} + resp := fakeResponse(t, 200, nil) + if checkMatcher(m, resp, "anything") { + t.Error("unknown matcher type should not match") + } +} + +func TestCheckMatchers(t *testing.T) { + resp := fakeResponse(t, 200, http.Header{"Server": []string{"nginx"}}) + const body = "secret token here" + + tests := []struct { + name string + matchers []Matcher + expect bool + }{ + { + name: "empty matchers never match", + matchers: nil, + expect: false, + }, + { + name: "all matchers pass (AND across matchers)", + matchers: []Matcher{ + {Type: "status", Status: []int{200}}, + {Type: "word", Part: "body", Words: []string{"secret"}}, + }, + expect: true, + }, + { + name: "one matcher fails breaks AND", + matchers: []Matcher{ + {Type: "status", Status: []int{200}}, + {Type: "word", Part: "body", Words: []string{"absent"}}, + }, + expect: false, + }, + { + name: "negative inverts a non-match into a pass", + matchers: []Matcher{ + {Type: "word", Part: "body", Words: []string{"absent"}, Negative: true}, + }, + expect: true, + }, + { + name: "negative inverts a match into a fail", + matchers: []Matcher{ + {Type: "word", Part: "body", Words: []string{"secret"}, Negative: true}, + }, + expect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkMatchers(tt.matchers, resp, body); got != tt.expect { + t.Errorf("checkMatchers = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestCheckWords(t *testing.T) { + const content = "alpha beta gamma" + + tests := []struct { + name string + words []string + condition string + expect bool + }{ + {name: "and all present", words: []string{"alpha", "gamma"}, condition: "and", expect: true}, + {name: "and missing", words: []string{"alpha", "delta"}, condition: "and", expect: false}, + {name: "or present", words: []string{"delta", "beta"}, condition: "or", expect: true}, + {name: "or absent", words: []string{"delta", "epsilon"}, condition: "or", expect: false}, + {name: "empty under and matches vacuously", words: nil, condition: "and", expect: true}, + {name: "empty under or matches nothing", words: nil, condition: "or", expect: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkWords(content, tt.words, tt.condition); got != tt.expect { + t.Errorf("checkWords = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestCheckRegex(t *testing.T) { + const content = "id=42 name=root" + + tests := []struct { + name string + patterns []string + condition string + expect bool + }{ + {name: "and all match", patterns: []string{`id=\d+`, `name=\w+`}, condition: "and", expect: true}, + {name: "and one fails", patterns: []string{`id=\d+`, `zzz`}, condition: "and", expect: false}, + {name: "or first matches", patterns: []string{`id=\d+`, `zzz`}, condition: "or", expect: true}, + {name: "or none match", patterns: []string{`xxx`, `zzz`}, condition: "or", expect: false}, + {name: "and bad regex fails closed", patterns: []string{`(`}, condition: "and", expect: false}, + {name: "or bad regex skipped then match", patterns: []string{`(`, `name=\w+`}, condition: "or", expect: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkRegex(content, tt.patterns, tt.condition); got != tt.expect { + t.Errorf("checkRegex = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestGetPart(t *testing.T) { + header := http.Header{"Server": []string{"nginx"}} + resp := fakeResponse(t, 200, header) + const body = "page body" + + if got := getPart("body", resp, body); got != body { + t.Errorf("getPart body = %q, want %q", got, body) + } + + headerPart := getPart("header", resp, body) + if !strings.Contains(headerPart, "Server") || !strings.Contains(headerPart, "nginx") { + t.Errorf("getPart header = %q, want it to include the header", headerPart) + } + if strings.Contains(headerPart, body) { + t.Errorf("getPart header should not include body, got %q", headerPart) + } + + all := getPart("all", resp, body) + if !strings.Contains(all, "nginx") || !strings.Contains(all, body) { + t.Errorf("getPart all = %q, want both header and body", all) + } + + // an unrecognised part falls back to the body. + if got := getPart("weird", resp, body); got != body { + t.Errorf("getPart fallback = %q, want body %q", got, body) + } + + // empty part behaves like "all". + if got := getPart("", resp, body); !strings.Contains(got, "nginx") || !strings.Contains(got, body) { + t.Errorf("getPart empty = %q, want both header and body", got) + } +} + +func TestRunExtractors(t *testing.T) { + resp := fakeResponse(t, 200, http.Header{"X-Token": []string{"abc123"}}) + const body = `{"session":"sess-7788","role":"admin"}` + + tests := []struct { + name string + extractors []Extractor + wantKey string + wantVal string + wantNil bool + }{ + { + name: "no extractors yields nil", + extractors: nil, + wantNil: true, + }, + { + name: "regex capture group on body", + extractors: []Extractor{ + {Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1}, + }, + wantKey: "session", + wantVal: "sess-7788", + }, + { + name: "group zero is the whole match", + extractors: []Extractor{ + {Type: "regex", Name: "role", Part: "body", Regex: []string{`role":"admin`}, Group: 0}, + }, + wantKey: "role", + wantVal: `role":"admin`, + }, + { + name: "extract from header part", + extractors: []Extractor{ + {Type: "regex", Name: "token", Part: "header", Regex: []string{`X-Token: (\S+)`}, Group: 1}, + }, + wantKey: "token", + wantVal: "abc123", + }, + { + name: "first matching pattern wins", + extractors: []Extractor{ + {Type: "regex", Name: "session", Part: "body", Regex: []string{`nomatch(\d+)`, `"session":"([^"]+)"`}, Group: 1}, + }, + wantKey: "session", + wantVal: "sess-7788", + }, + { + name: "group index out of range is skipped", + extractors: []Extractor{ + {Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 5}, + }, + wantNil: true, + }, + { + name: "invalid pattern is skipped, no capture", + extractors: []Extractor{ + {Type: "regex", Name: "session", Part: "body", Regex: []string{`(`}, Group: 1}, + }, + wantNil: true, + }, + { + name: "non-regex extractor type is ignored", + extractors: []Extractor{ + {Type: "kval", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1}, + }, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := runExtractors(tt.extractors, resp, body) + if tt.wantNil { + if len(got) != 0 { + t.Errorf("runExtractors = %v, want empty", got) + } + return + } + if got[tt.wantKey] != tt.wantVal { + t.Errorf("runExtractors[%q] = %q, want %q", tt.wantKey, got[tt.wantKey], tt.wantVal) + } + }) + } +} + +func TestSubstituteVariables(t *testing.T) { + tests := []struct { + name string + template string + baseURL string + payload string + want string + }{ + { + name: "baseurl both cases", + template: "{{BaseURL}}/x and {{baseurl}}/y", + baseURL: "http://h", + want: "http://h/x and http://h/y", + }, + { + name: "payload both cases", + template: "q={{payload}}&r={{Payload}}", + payload: "