From d3a0cf9a04bb913a70886cee702e2241095daec6 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:00:14 -0700 Subject: [PATCH 1/4] feat(modules): add dns module executor DNS modules were a stub that returned the unsupported sentinel. Implement the executor: resolve the configured name and record type, then run the module's matchers and extractors against the answer. A matcher targets a part, either the record set (answer), the response status (rcode), or the full response (default), so a status like NXDOMAIN is matchable directly. A name's {{FQDN}} resolves to the target host. The record type and matcher types are validated at parse time so a bad module fails to load rather than silently matching nothing. Promotes miekg/dns to a direct dependency for the record type codes. --- docs/modules.md | 37 ++- go.mod | 2 +- internal/modules/dns.go | 271 ++++++++++++++++++++ internal/modules/dns_test.go | 398 ++++++++++++++++++++++++++++++ internal/modules/executor.go | 7 - internal/modules/executor_test.go | 30 +-- internal/modules/yaml.go | 5 + 7 files changed, 722 insertions(+), 28 deletions(-) create mode 100644 internal/modules/dns.go create mode 100644 internal/modules/dns_test.go diff --git a/docs/modules.md b/docs/modules.md index 52af7473..147874df 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -65,7 +65,7 @@ info: ### type (required) -module type. currently only `http` is supported. +module type. `http` and `dns` are supported. ```yaml type: http @@ -168,6 +168,41 @@ http: threads: 5 ``` +### dns + +dns lookup configuration. the module resolves one name and record type, then +runs its matchers and extractors against the answer. + +```yaml +type: dns + +dns: + type: txt + name: "{{FQDN}}" +``` + +#### type + +record type to query: `a` (default), `aaaa`, `cname`, `mx`, `ns`, `txt`, `soa`, +`srv`, `caa`, `ptr`, or `any`. + +#### name + +name to resolve. `{{FQDN}}` is replaced with the target, and an empty name uses +the target host. a target given as a url is reduced to its hostname. + +#### dns matcher and extractor parts + +dns matchers and extractors take a `part`: + +- `answer` - the resource records +- `rcode` - the response status, e.g. `NOERROR` or `NXDOMAIN` +- `all` (default) - the full response text + +the `status` matcher type is http only and is rejected on a dns module; match a +response code with a word or regex matcher on part `rcode`. extractors are regex +only on dns. + ## matchers matchers determine if a response indicates a finding. diff --git a/go.mod b/go.mod index 2e936f5b..34e9e153 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/log v1.0.0 github.com/gocolly/colly/v2 v2.3.0 github.com/likexian/whois v1.15.7 + github.com/miekg/dns v1.1.68 github.com/projectdiscovery/goflags v0.1.74 github.com/projectdiscovery/nuclei/v3 v3.9.0 github.com/projectdiscovery/retryabledns v1.0.115 @@ -240,7 +241,6 @@ require ( github.com/mholt/archives v0.1.5 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microsoft/go-mssqldb v1.9.2 // indirect - github.com/miekg/dns v1.1.68 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 // indirect diff --git a/internal/modules/dns.go b/internal/modules/dns.go new file mode 100644 index 00000000..3f7ea4db --- /dev/null +++ b/internal/modules/dns.go @@ -0,0 +1,271 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strings" + "time" + + "github.com/miekg/dns" + retryabledns "github.com/projectdiscovery/retryabledns" +) + +// dnsMaxRetries is how many times the resolver rotates through the pool on a +// timeout before giving up. +const dnsMaxRetries = 3 + +// defaultDNSResolvers is the bundled pool: fast public anycast servers. +var defaultDNSResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"} + +// dnsRequestType maps a module's record-type string to its dns type code. An +// empty type defaults to A; ANY is deliberately not the default (RFC 8482 +// discourages relying on ANY against the public resolvers). +var dnsRequestType = map[string]uint16{ + "": dns.TypeA, + "a": dns.TypeA, + "aaaa": dns.TypeAAAA, + "cname": dns.TypeCNAME, + "mx": dns.TypeMX, + "ns": dns.TypeNS, + "txt": dns.TypeTXT, + "soa": dns.TypeSOA, + "srv": dns.TypeSRV, + "caa": dns.TypeCAA, + "ptr": dns.TypePTR, + "any": dns.TypeANY, +} + +// dnsResolver is the slice of the retryabledns client the executor needs; tests +// inject a fake through newDNSResolver. +type dnsResolver interface { + Query(host string, requestType uint16) (*retryabledns.DNSData, error) +} + +// newDNSResolver builds a resolver over the bundled pool with the given timeout. +// It is a package var so tests can supply a fake without touching the network. +var newDNSResolver = func(timeout time.Duration) (dnsResolver, error) { + opts := retryabledns.Options{ + BaseResolvers: defaultDNSResolvers, + MaxRetries: dnsMaxRetries, + } + if timeout > 0 { + opts.Timeout = timeout + } + client, err := retryabledns.NewWithOptions(opts) + if err != nil { + return nil, fmt.Errorf("build dns resolver: %w", err) + } + client.TCPFallback = true + return client, nil +} + +// dnsResponse holds the parts of a resolved answer a matcher can target. +type dnsResponse struct { + answer []string // the resource records, one per line + rcode string // the response status, e.g. NOERROR or NXDOMAIN + raw string // the full text of the response message +} + +// validateDNS rejects, at load time, a dns config the executor cannot run: an +// unknown record type, or a matcher type other than word or regex (status is +// http only). +func validateDNS(cfg *DNSConfig) error { + if _, ok := dnsRequestType[strings.ToLower(cfg.Type)]; !ok { + return fmt.Errorf("unsupported dns record type %q", cfg.Type) + } + for i := range cfg.Matchers { + switch cfg.Matchers[i].Type { + case "word", "regex": + default: + return fmt.Errorf("dns matcher type %q is not supported (use word or regex)", cfg.Matchers[i].Type) + } + } + return nil +} + +// ExecuteDNSModule resolves the configured name and record type, then applies +// the module's matchers and extractors to the answer. +func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { + if def.DNS == nil { + return nil, fmt.Errorf("no DNS configuration") + } + cfg := def.DNS + result := &Result{ + ModuleID: def.ID, + Target: target, + Findings: make([]Finding, 0), + } + + qtype, ok := dnsRequestType[strings.ToLower(cfg.Type)] + if !ok { + return nil, fmt.Errorf("unsupported dns record type %q", cfg.Type) + } + + resolver, err := newDNSResolver(opts.Timeout) + if err != nil { + return nil, err + } + + // retryabledns has no context hook, so honor cancellation before the lookup. + if err := ctx.Err(); err != nil { + return result, err + } + + name := dnsName(cfg.Name, target) + data, err := resolver.Query(name, qtype) + if err != nil { + return nil, fmt.Errorf("dns query %q: %w", name, err) + } + + resp := newDNSResponse(data) + if !checkDNSMatchers(cfg.Matchers, resp) { + return result, nil + } + + result.Findings = append(result.Findings, Finding{ + Severity: def.Info.Severity, + Evidence: truncateEvidence(resp.raw), + Extracted: runDNSExtractors(cfg.Extractors, resp), + }) + return result, nil +} + +// newDNSResponse extracts the matchable parts from a resolved answer. The raw +// text comes from RawResp (the single final message) rather than data.Raw, which +// the resolver's retry loop concatenates across attempts. +func newDNSResponse(data *retryabledns.DNSData) dnsResponse { + if data == nil { + return dnsResponse{} + } + raw := data.Raw + if data.RawResp != nil { + raw = data.RawResp.String() + } + return dnsResponse{ + answer: data.AllRecords, + rcode: data.StatusCode, + raw: raw, + } +} + +// getDNSPart returns the slice of the response a matcher or extractor targets. +// The default (and the explicit "all"/"body") is the full response text; +// "answer" is the record set; "rcode" is the response status. +func getDNSPart(part string, resp dnsResponse) string { + switch strings.ToLower(part) { + case "answer": + return strings.Join(resp.answer, "\n") + case "rcode": + return resp.rcode + default: + return resp.raw + } +} + +// checkDNSMatchers evaluates all matchers against the response with AND logic. +func checkDNSMatchers(matchers []Matcher, resp dnsResponse) bool { + if len(matchers) == 0 { + return false + } + + for i := range matchers { + matched := checkDNSMatcher(&matchers[i], resp) + if matchers[i].Negative { + matched = !matched + } + if !matched { + return false // AND logic + } + } + + return true +} + +// checkDNSMatcher evaluates a single matcher. The status matcher type is HTTP +// only; match a response code with a word or regex matcher on part "rcode". +func checkDNSMatcher(m *Matcher, resp dnsResponse) bool { + part := getDNSPart(m.Part, resp) + + switch m.Type { + case "word": + return checkWords(part, m.Words, m.Condition) + case "regex": + return checkRegex(part, m.Regex, m.Condition) + default: + return false + } +} + +// runDNSExtractors pulls regex captures from the response. DNS answers are text, +// so regex is the available extractor; other types are skipped. +func runDNSExtractors(extractors []Extractor, resp dnsResponse) map[string]string { + if len(extractors) == 0 { + return nil + } + + result := make(map[string]string) + for _, e := range extractors { + if e.Type != "regex" { + continue + } + part := getDNSPart(e.Part, resp) + for _, pattern := range e.Regex { + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + matches := re.FindStringSubmatch(part) + if len(matches) > e.Group { + result[e.Name] = matches[e.Group] + break + } + } + } + + return result +} + +// dnsName resolves the lookup name: the module's name with {{FQDN}} replaced by +// the target host, or the bare target host when no name is set. +func dnsName(name, target string) string { + host := dnsHost(target) + if name == "" { + return host + } + name = strings.ReplaceAll(name, "{{FQDN}}", host) + name = strings.ReplaceAll(name, "{{fqdn}}", host) + return name +} + +// dnsHost reduces target to its hostname, stripping any scheme, port, path, or +// userinfo. A bare host is returned unchanged. +func dnsHost(target string) string { + target = strings.TrimSpace(target) + if target == "" { + return target + } + // url.Parse only populates Host when a scheme is present; add one for a bare + // host or host:port so the same parse handles every form. + parse := target + if !strings.Contains(parse, "://") { + parse = "//" + parse + } + if u, err := url.Parse(parse); err == nil && u.Hostname() != "" { + return u.Hostname() + } + return target +} diff --git a/internal/modules/dns_test.go b/internal/modules/dns_test.go new file mode 100644 index 00000000..40a531e9 --- /dev/null +++ b/internal/modules/dns_test.go @@ -0,0 +1,398 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/miekg/dns" + retryabledns "github.com/projectdiscovery/retryabledns" +) + +// fakeDNSResolver answers from a fixture and records what it was asked. +type fakeDNSResolver struct { + data *retryabledns.DNSData + err error + gotName string + gotType uint16 +} + +func (f *fakeDNSResolver) Query(host string, requestType uint16) (*retryabledns.DNSData, error) { + f.gotName = host + f.gotType = requestType + return f.data, f.err +} + +// withFakeDNS installs a fake resolver for the test and restores the real one +// after; it returns the fake so a test can read back the query it received. +func withFakeDNS(t *testing.T, data *retryabledns.DNSData, queryErr error) *fakeDNSResolver { + t.Helper() + f := &fakeDNSResolver{data: data, err: queryErr} + orig := newDNSResolver + newDNSResolver = func(time.Duration) (dnsResolver, error) { return f, nil } + t.Cleanup(func() { newDNSResolver = orig }) + return f +} + +func wordMatcher(part string, words ...string) Matcher { + return Matcher{Type: "word", Part: part, Words: words} +} + +func dnsDef(cfg *DNSConfig) *YAMLModule { + return &YAMLModule{ID: "dns-test", Type: TypeDNS, Info: YAMLModuleInfo{Severity: "info"}, DNS: cfg} +} + +func TestExecuteDNSModuleMatchAndExtract(t *testing.T) { + withFakeDNS(t, &retryabledns.DNSData{ + AllRecords: []string{"example.com. 300 IN A 93.184.216.34"}, + StatusCode: "NOERROR", + Raw: "example.com. 300 IN A 93.184.216.34", + }, nil) + + def := dnsDef(&DNSConfig{ + Type: "a", + Matchers: []Matcher{wordMatcher("answer", "93.184.216.34")}, + Extractors: []Extractor{ + {Type: "regex", Name: "ip", Part: "answer", Regex: []string{`A (\d+\.\d+\.\d+\.\d+)`}, Group: 1}, + }, + }) + + res, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}) + if err != nil { + t.Fatalf("ExecuteDNSModule: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("got %d findings, want 1", len(res.Findings)) + } + if got := res.Findings[0].Extracted["ip"]; got != "93.184.216.34" { + t.Errorf("extracted ip = %q, want 93.184.216.34", got) + } + if res.Findings[0].Evidence == "" { + t.Error("evidence is empty") + } +} + +func TestExecuteDNSModuleNoMatch(t *testing.T) { + withFakeDNS(t, &retryabledns.DNSData{AllRecords: []string{"a"}, Raw: "a", StatusCode: "NOERROR"}, nil) + def := dnsDef(&DNSConfig{Type: "a", Matchers: []Matcher{wordMatcher("answer", "absent")}}) + + res, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}) + if err != nil { + t.Fatalf("ExecuteDNSModule: %v", err) + } + if len(res.Findings) != 0 { + t.Fatalf("got %d findings, want 0", len(res.Findings)) + } +} + +// TestExecuteDNSModuleParts pins the part pivot: a matcher must see only the +// slice of the response it asked for, not the whole thing. +func TestExecuteDNSModuleParts(t *testing.T) { + data := &retryabledns.DNSData{ + AllRecords: []string{"answer-only-token"}, + StatusCode: "SERVFAIL", + Raw: "raw-only-token answer-only-token", + } + + tests := []struct { + name string + part string + word string + match bool + }{ + {"default sees raw", "", "raw-only-token", true}, + {"all sees raw", "all", "raw-only-token", true}, + {"answer sees records", "answer", "answer-only-token", true}, + {"answer excludes raw-only", "answer", "raw-only-token", false}, + {"rcode sees status", "rcode", "SERVFAIL", true}, + {"rcode excludes raw-only", "rcode", "raw-only-token", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withFakeDNS(t, data, nil) + def := dnsDef(&DNSConfig{Type: "a", Matchers: []Matcher{wordMatcher(tt.part, tt.word)}}) + res, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}) + if err != nil { + t.Fatalf("ExecuteDNSModule: %v", err) + } + if got := len(res.Findings) == 1; got != tt.match { + t.Errorf("part %q word %q matched=%v, want %v", tt.part, tt.word, got, tt.match) + } + }) + } +} + +func TestExecuteDNSModuleTypeDispatch(t *testing.T) { + match := []Matcher{wordMatcher("rcode", "NOERROR")} + tests := []struct { + typ string + want uint16 + }{ + {"", dns.TypeA}, + {"a", dns.TypeA}, + {"AAAA", dns.TypeAAAA}, + {"mx", dns.TypeMX}, + {"txt", dns.TypeTXT}, + {"any", dns.TypeANY}, + } + for _, tt := range tests { + t.Run("type "+tt.typ, func(t *testing.T) { + f := withFakeDNS(t, &retryabledns.DNSData{StatusCode: "NOERROR", Raw: "NOERROR"}, nil) + def := dnsDef(&DNSConfig{Type: tt.typ, Matchers: match}) + if _, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}); err != nil { + t.Fatalf("ExecuteDNSModule: %v", err) + } + if f.gotType != tt.want { + t.Errorf("type %q dispatched code %d, want %d", tt.typ, f.gotType, tt.want) + } + }) + } +} + +func TestExecuteDNSModuleUnsupportedType(t *testing.T) { + withFakeDNS(t, &retryabledns.DNSData{}, nil) + def := dnsDef(&DNSConfig{Type: "zzz", Matchers: []Matcher{wordMatcher("", "x")}}) + if _, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}); err == nil { + t.Fatal("expected error for unsupported record type") + } +} + +func TestExecuteDNSModuleName(t *testing.T) { + tests := []struct { + name string + cfgName string + target string + wantName string + }{ + {"fqdn substitution", "_dmarc.{{FQDN}}", "example.com", "_dmarc.example.com"}, + {"empty name uses target", "", "example.com", "example.com"}, + {"url target reduced to host", "", "https://example.com:8443/p?q=1", "example.com"}, + {"explicit name kept", "fixed.example.net", "other.com", "fixed.example.net"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := withFakeDNS(t, &retryabledns.DNSData{StatusCode: "NOERROR", Raw: "NOERROR"}, nil) + def := dnsDef(&DNSConfig{Type: "a", Name: tt.cfgName, Matchers: []Matcher{wordMatcher("rcode", "NOERROR")}}) + if _, err := ExecuteDNSModule(context.Background(), tt.target, def, Options{}); err != nil { + t.Fatalf("ExecuteDNSModule: %v", err) + } + if f.gotName != tt.wantName { + t.Errorf("resolved name = %q, want %q", f.gotName, tt.wantName) + } + }) + } +} + +func TestCheckDNSMatchers(t *testing.T) { + resp := dnsResponse{answer: []string{"v=spf1 include:_spf.example.com"}, rcode: "NXDOMAIN", raw: "v=spf1 include:_spf.example.com"} + + tests := []struct { + name string + matchers []Matcher + want bool + }{ + {"no matchers is false", nil, false}, + {"single word hit", []Matcher{wordMatcher("answer", "v=spf1")}, true}, + {"single word miss", []Matcher{wordMatcher("answer", "v=dkim")}, false}, + {"and across matchers all hit", []Matcher{wordMatcher("answer", "v=spf1"), wordMatcher("rcode", "NXDOMAIN")}, true}, + {"and across matchers one miss", []Matcher{wordMatcher("answer", "v=spf1"), wordMatcher("rcode", "NOERROR")}, false}, + {"negative inverts a miss to a hit", []Matcher{{Type: "word", Part: "answer", Words: []string{"v=dkim"}, Negative: true}}, true}, + {"negative inverts a hit to a miss", []Matcher{{Type: "word", Part: "answer", Words: []string{"v=spf1"}, Negative: true}}, false}, + {"status type never matches in dns", []Matcher{{Type: "status", Status: []int{0}}}, false}, + {"regex hit", []Matcher{{Type: "regex", Part: "answer", Regex: []string{`spf\d`}}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkDNSMatchers(tt.matchers, resp); got != tt.want { + t.Errorf("checkDNSMatchers = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRunDNSExtractors(t *testing.T) { + resp := dnsResponse{answer: []string{"example.com. 300 IN A 93.184.216.34"}, raw: "raw", rcode: "NOERROR"} + + t.Run("regex group 1", func(t *testing.T) { + ex := []Extractor{{Type: "regex", Name: "ip", Part: "answer", Regex: []string{`A (\d+\.\d+\.\d+\.\d+)`}, Group: 1}} + if got := runDNSExtractors(ex, resp)["ip"]; got != "93.184.216.34" { + t.Errorf("group 1 = %q, want 93.184.216.34", got) + } + }) + t.Run("group 0 full match", func(t *testing.T) { + ex := []Extractor{{Type: "regex", Name: "rec", Part: "answer", Regex: []string{`IN A [\d.]+`}, Group: 0}} + if got := runDNSExtractors(ex, resp)["rec"]; got != "IN A 93.184.216.34" { + t.Errorf("group 0 = %q", got) + } + }) + t.Run("miss sets nothing", func(t *testing.T) { + ex := []Extractor{{Type: "regex", Name: "x", Part: "answer", Regex: []string{`nope(\d+)`}, Group: 1}} + if _, ok := runDNSExtractors(ex, resp)["x"]; ok { + t.Error("a non-matching extractor set a value") + } + }) + t.Run("non-regex type skipped", func(t *testing.T) { + ex := []Extractor{{Type: "kv", Name: "k", Part: "answer"}} + if _, ok := runDNSExtractors(ex, resp)["k"]; ok { + t.Error("a non-regex extractor produced a value") + } + }) + t.Run("uncompilable pattern skipped", func(t *testing.T) { + // the bad pattern is skipped, the next one still matches. + ex := []Extractor{{Type: "regex", Name: "x", Part: "answer", Regex: []string{"[", `(A)`}, Group: 1}} + if got := runDNSExtractors(ex, resp)["x"]; got != "A" { + t.Errorf("after skipping an invalid regex, got %q, want A", got) + } + }) + t.Run("no extractors is nil", func(t *testing.T) { + if runDNSExtractors(nil, resp) != nil { + t.Error("want nil for no extractors") + } + }) +} + +func TestExecuteDNSModuleContextCancel(t *testing.T) { + withFakeDNS(t, &retryabledns.DNSData{StatusCode: "NOERROR", Raw: "NOERROR"}, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + def := dnsDef(&DNSConfig{Type: "a", Matchers: []Matcher{wordMatcher("rcode", "NOERROR")}}) + res, err := ExecuteDNSModule(ctx, "example.com", def, Options{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if len(res.Findings) != 0 { + t.Errorf("got %d findings on cancel, want 0", len(res.Findings)) + } +} + +func TestExecuteDNSModuleResolverError(t *testing.T) { + withFakeDNS(t, nil, fmt.Errorf("server failure")) + def := dnsDef(&DNSConfig{Type: "a", Matchers: []Matcher{wordMatcher("rcode", "x")}}) + if _, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}); err == nil { + t.Fatal("expected error when the query fails") + } +} + +func TestExecuteDNSModuleResolverBuildError(t *testing.T) { + orig := newDNSResolver + newDNSResolver = func(time.Duration) (dnsResolver, error) { return nil, fmt.Errorf("build failed") } + t.Cleanup(func() { newDNSResolver = orig }) + + def := dnsDef(&DNSConfig{Type: "a", Matchers: []Matcher{wordMatcher("rcode", "x")}}) + if _, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}); err == nil { + t.Fatal("expected error when the resolver cannot be built") + } +} + +func TestExecuteDNSModuleNoConfig(t *testing.T) { + def := &YAMLModule{ID: "x", Type: TypeDNS} + if _, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}); err == nil { + t.Fatal("expected error when DNS config is nil") + } +} + +func TestNewDNSResponseRawResp(t *testing.T) { + // RawResp wins over Raw, which the resolver concatenates across retries. + msg := new(dns.Msg) + msg.SetQuestion("example.com.", dns.TypeA) + got := newDNSResponse(&retryabledns.DNSData{Raw: "concatenated", RawResp: msg}) + if got.raw == "concatenated" || got.raw == "" { + t.Errorf("raw = %q, want the RawResp text", got.raw) + } + if newDNSResponse(nil).raw != "" { + t.Error("nil data should yield an empty response") + } +} + +func TestDNSHost(t *testing.T) { + cases := map[string]string{ + "example.com": "example.com", + "https://example.com:8443/path?q=1": "example.com", + "http://user:pass@host.tld": "host.tld", + "1.2.3.4:53": "1.2.3.4", + "[2606:4700::1111]:53": "2606:4700::1111", + "/justpath": "/justpath", + "": "", + } + for in, want := range cases { + if got := dnsHost(in); got != want { + t.Errorf("dnsHost(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateDNS(t *testing.T) { + tests := []struct { + name string + cfg *DNSConfig + ok bool + }{ + {"empty type defaults to a", &DNSConfig{}, true}, + {"known type and matchers", &DNSConfig{Type: "TXT", Matchers: []Matcher{{Type: "word"}, {Type: "regex"}}}, true}, + {"no matchers is allowed", &DNSConfig{Type: "a"}, true}, + {"unknown record type", &DNSConfig{Type: "zzz"}, false}, + {"status matcher rejected", &DNSConfig{Type: "a", Matchers: []Matcher{{Type: "status"}}}, false}, + {"unknown matcher type rejected", &DNSConfig{Type: "a", Matchers: []Matcher{{Type: "word"}, {Type: "size"}}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDNS(tt.cfg) + if (err == nil) != tt.ok { + t.Errorf("validateDNS = %v, want ok=%v", err, tt.ok) + } + }) + } +} + +func TestParseDNSValidation(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + + good := write("good.yaml", "id: ok\ntype: dns\ndns:\n type: txt\n matchers:\n - type: word\n words: [x]\n") + if _, err := ParseYAMLModule(good); err != nil { + t.Fatalf("valid dns module rejected: %v", err) + } + + badType := write("badtype.yaml", "id: bt\ntype: dns\ndns:\n type: zzz\n") + if _, err := ParseYAMLModule(badType); err == nil { + t.Fatal("unknown record type accepted") + } + + badMatcher := write("badmatcher.yaml", "id: bm\ntype: dns\ndns:\n type: a\n matchers:\n - type: status\n status: [0]\n") + if _, err := ParseYAMLModule(badMatcher); err == nil { + t.Fatal("status matcher on dns accepted") + } +} + +func TestNewDNSResolverBuildsClient(t *testing.T) { + r, err := newDNSResolver(2 * time.Second) + if err != nil { + t.Fatalf("newDNSResolver: %v", err) + } + if r == nil { + t.Fatal("newDNSResolver returned a nil resolver") + } +} diff --git a/internal/modules/executor.go b/internal/modules/executor.go index f969581a..3a8e7291 100644 --- a/internal/modules/executor.go +++ b/internal/modules/executor.go @@ -525,13 +525,6 @@ func truncateEvidence(s string) string { return s } -// 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 (not yet implemented). // returns ErrUnsupportedModuleType so the caller logs a clear failure rather // than reporting an empty (but successful-looking) result. diff --git a/internal/modules/executor_test.go b/internal/modules/executor_test.go index dae23da3..e97b3db3 100644 --- a/internal/modules/executor_test.go +++ b/internal/modules/executor_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + retryabledns "github.com/projectdiscovery/retryabledns" "github.com/vmfunc/sif/internal/httpx" ) @@ -263,20 +264,6 @@ func TestExecuteHTTPModuleContextCancel(t *testing.T) { } } -// 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{}) @@ -289,13 +276,18 @@ func TestExecuteTCPModuleUnsupported(t *testing.T) { } // TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each -// type to the right executor and propagates the unsupported-type sentinel. +// type to the right executor: dns to the dns executor, tcp to the unsupported +// sentinel, and a missing config to a clear error. func TestWrapperExecuteRoutesByType(t *testing.T) { - t.Run("dns routes to unsupported", func(t *testing.T) { - def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{}} + t.Run("dns routes to executor", func(t *testing.T) { + withFakeDNS(t, &retryabledns.DNSData{StatusCode: "NOERROR", Raw: "NOERROR"}, nil) + def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{ + Type: "a", + Matchers: []Matcher{{Type: "word", Part: "rcode", Words: []string{"NOERROR"}}}, + }} w := newYAMLModuleWrapper(def, "d.yaml") - if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) { - t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + if _, err := w.Execute(context.Background(), "example.com", Options{}); err != nil { + t.Fatalf("dns execute: %v", err) } }) diff --git a/internal/modules/yaml.go b/internal/modules/yaml.go index d4042392..21463236 100644 --- a/internal/modules/yaml.go +++ b/internal/modules/yaml.go @@ -110,6 +110,11 @@ func ParseYAMLModule(path string) (*YAMLModule, error) { return nil, fmt.Errorf("module %q: %w", ym.ID, err) } } + if ym.DNS != nil { + if err := validateDNS(ym.DNS); err != nil { + return nil, fmt.Errorf("module %q: %w", ym.ID, err) + } + } var matchers []Matcher switch { case ym.HTTP != nil: From dd612181a626d32381f36cc682d291110045ffeb Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:34:13 -0700 Subject: [PATCH 2/4] feat(modules): honour -resolvers in dns module executor the dns executor built its resolver over a hardcoded public pool and ignored the -resolvers flag, so dns modules could never target an internal or authoritative resolver and could not be exercised without external egress. thread the parsed resolver list through Options into newDNSResolver, falling back to the bundled pool when none is given. --- internal/modules/dns.go | 15 ++++++++++----- internal/modules/dns_test.go | 29 ++++++++++++++++++++++++++--- internal/modules/module.go | 9 +++++---- sif.go | 7 ++++--- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/internal/modules/dns.go b/internal/modules/dns.go index 3f7ea4db..276af029 100644 --- a/internal/modules/dns.go +++ b/internal/modules/dns.go @@ -55,11 +55,16 @@ type dnsResolver interface { Query(host string, requestType uint16) (*retryabledns.DNSData, error) } -// newDNSResolver builds a resolver over the bundled pool with the given timeout. -// It is a package var so tests can supply a fake without touching the network. -var newDNSResolver = func(timeout time.Duration) (dnsResolver, error) { +// newDNSResolver builds a resolver over the given pool (falling back to the +// bundled default when it is empty) with the given timeout. It is a package var +// so tests can supply a fake without touching the network. +var newDNSResolver = func(resolvers []string, timeout time.Duration) (dnsResolver, error) { + pool := resolvers + if len(pool) == 0 { + pool = defaultDNSResolvers + } opts := retryabledns.Options{ - BaseResolvers: defaultDNSResolvers, + BaseResolvers: pool, MaxRetries: dnsMaxRetries, } if timeout > 0 { @@ -115,7 +120,7 @@ func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts return nil, fmt.Errorf("unsupported dns record type %q", cfg.Type) } - resolver, err := newDNSResolver(opts.Timeout) + resolver, err := newDNSResolver(opts.Resolvers, opts.Timeout) if err != nil { return nil, err } diff --git a/internal/modules/dns_test.go b/internal/modules/dns_test.go index 40a531e9..9289dd20 100644 --- a/internal/modules/dns_test.go +++ b/internal/modules/dns_test.go @@ -18,6 +18,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "testing" "time" @@ -45,7 +46,7 @@ func withFakeDNS(t *testing.T, data *retryabledns.DNSData, queryErr error) *fake t.Helper() f := &fakeDNSResolver{data: data, err: queryErr} orig := newDNSResolver - newDNSResolver = func(time.Duration) (dnsResolver, error) { return f, nil } + newDNSResolver = func([]string, time.Duration) (dnsResolver, error) { return f, nil } t.Cleanup(func() { newDNSResolver = orig }) return f } @@ -54,6 +55,28 @@ func wordMatcher(part string, words ...string) Matcher { return Matcher{Type: "word", Part: part, Words: words} } +// TestExecuteDNSModulePassesResolvers proves the executor hands the caller's +// resolver pool (from -resolvers via Options) to the resolver builder, rather +// than silently using the bundled public pool. +func TestExecuteDNSModulePassesResolvers(t *testing.T) { + var gotResolvers []string + orig := newDNSResolver + newDNSResolver = func(resolvers []string, _ time.Duration) (dnsResolver, error) { + gotResolvers = resolvers + return &fakeDNSResolver{data: &retryabledns.DNSData{StatusCode: "NOERROR"}}, nil + } + t.Cleanup(func() { newDNSResolver = orig }) + + def := dnsDef(&DNSConfig{Type: "a", Matchers: []Matcher{wordMatcher("rcode", "NOERROR")}}) + want := []string{"127.0.0.1:5353", "10.0.0.53:53"} + if _, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{Resolvers: want}); err != nil { + t.Fatalf("ExecuteDNSModule: %v", err) + } + if !reflect.DeepEqual(gotResolvers, want) { + t.Errorf("resolver pool = %v, want %v", gotResolvers, want) + } +} + func dnsDef(cfg *DNSConfig) *YAMLModule { return &YAMLModule{ID: "dns-test", Type: TypeDNS, Info: YAMLModuleInfo{Severity: "info"}, DNS: cfg} } @@ -292,7 +315,7 @@ func TestExecuteDNSModuleResolverError(t *testing.T) { func TestExecuteDNSModuleResolverBuildError(t *testing.T) { orig := newDNSResolver - newDNSResolver = func(time.Duration) (dnsResolver, error) { return nil, fmt.Errorf("build failed") } + newDNSResolver = func([]string, time.Duration) (dnsResolver, error) { return nil, fmt.Errorf("build failed") } t.Cleanup(func() { newDNSResolver = orig }) def := dnsDef(&DNSConfig{Type: "a", Matchers: []Matcher{wordMatcher("rcode", "x")}}) @@ -388,7 +411,7 @@ func TestParseDNSValidation(t *testing.T) { } func TestNewDNSResolverBuildsClient(t *testing.T) { - r, err := newDNSResolver(2 * time.Second) + r, err := newDNSResolver(nil, 2*time.Second) if err != nil { t.Fatalf("newDNSResolver: %v", err) } diff --git a/internal/modules/module.go b/internal/modules/module.go index 1a45e5c4..92ff527c 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -57,10 +57,11 @@ type Info struct { // Options for module execution. type Options struct { - Timeout time.Duration - Threads int - LogDir string - Client *http.Client + Timeout time.Duration + Threads int + LogDir string + Client *http.Client + Resolvers []string // dns modules: overrides the bundled resolver pool (-resolvers) } // Result from module execution. diff --git a/sif.go b/sif.go index c81bbe6f..32b5a47b 100644 --- a/sif.go +++ b/sif.go @@ -652,9 +652,10 @@ func (app *App) Run() error { // Execute modules opts := modules.Options{ - Timeout: app.settings.Timeout, - Threads: app.settings.Threads, - LogDir: app.settings.LogDir, + Timeout: app.settings.Timeout, + Threads: app.settings.Threads, + LogDir: app.settings.LogDir, + Resolvers: dnsx.ParseResolvers(app.settings.Resolvers), } for _, m := range toRun { From e3e174341321aec90da5e969f9760831c0a1b8d6 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:34:13 -0700 Subject: [PATCH 3/4] feat(modules): add dns-spf-record recon module first shipped type: dns module: resolves the target's txt records, reports its spf policy and extracts the sender set and enforcement qualifier. --- modules/recon/dns-spf-record.yaml | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 modules/recon/dns-spf-record.yaml diff --git a/modules/recon/dns-spf-record.yaml b/modules/recon/dns-spf-record.yaml new file mode 100644 index 00000000..63375d44 --- /dev/null +++ b/modules/recon/dns-spf-record.yaml @@ -0,0 +1,35 @@ +# DNS SPF Record Recon Module + +id: dns-spf-record +info: + name: DNS SPF Record + author: sif + severity: info + description: Resolves the domain's TXT records and reports its SPF policy, surfacing the sender hosts and the enforcement qualifier (~all/-all/?all) used for email authentication + tags: [dns, txt, spf, email, recon] + +type: dns + +dns: + type: txt + # name defaults to the target host; the apex TXT set carries the SPF record. + + matchers: + - type: word + part: answer + words: + - "v=spf1" + + extractors: + - type: regex + name: spf_policy + part: answer + regex: + - "v=spf1[^\"]*" + group: 0 + - type: regex + name: spf_qualifier + part: answer + regex: + - "([-~?+]all)" + group: 1 From 1cff52bbba665ddf648c734d1ae2f50b9b550dc2 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:22:21 -0700 Subject: [PATCH 4/4] fix(dns): floor the resolver timeout to stop indefinite hangs opts.Timeout <= 0 reached retryabledns as a literal zero, and retryabledns applies no default of its own, so a black-hole or non-responsive resolver blocked the query forever. floor it to defaultDNSTimeout before building the client, mirroring the tcp executor's existing floor. --- internal/modules/dns.go | 19 +++++++++---- internal/modules/dns_test.go | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/internal/modules/dns.go b/internal/modules/dns.go index 276af029..4c8b73b2 100644 --- a/internal/modules/dns.go +++ b/internal/modules/dns.go @@ -28,6 +28,12 @@ import ( // timeout before giving up. const dnsMaxRetries = 3 +// defaultDNSTimeout bounds a query when the caller passes no timeout. +// retryabledns applies no default of its own: a zero Options.Timeout reaches +// the underlying dns.Client as a literal zero, which blocks forever against a +// non-responsive resolver. +const defaultDNSTimeout = 3 * time.Second + // defaultDNSResolvers is the bundled pool: fast public anycast servers. var defaultDNSResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"} @@ -56,19 +62,22 @@ type dnsResolver interface { } // newDNSResolver builds a resolver over the given pool (falling back to the -// bundled default when it is empty) with the given timeout. It is a package var -// so tests can supply a fake without touching the network. +// bundled default when it is empty) with the given timeout, flooring a +// non-positive timeout to the default so a caller can't request an +// effectively unbounded resolve. It is a package var so tests can supply a +// fake without touching the network. var newDNSResolver = func(resolvers []string, timeout time.Duration) (dnsResolver, error) { pool := resolvers if len(pool) == 0 { pool = defaultDNSResolvers } + if timeout <= 0 { + timeout = defaultDNSTimeout + } opts := retryabledns.Options{ BaseResolvers: pool, MaxRetries: dnsMaxRetries, - } - if timeout > 0 { - opts.Timeout = timeout + Timeout: timeout, } client, err := retryabledns.NewWithOptions(opts) if err != nil { diff --git a/internal/modules/dns_test.go b/internal/modules/dns_test.go index 9289dd20..4a1cb4fa 100644 --- a/internal/modules/dns_test.go +++ b/internal/modules/dns_test.go @@ -16,6 +16,7 @@ import ( "context" "errors" "fmt" + "net" "os" "path/filepath" "reflect" @@ -419,3 +420,55 @@ func TestNewDNSResolverBuildsClient(t *testing.T) { t.Fatal("newDNSResolver returned a nil resolver") } } + +// TestNewDNSResolverFloorsZeroTimeout pins the timeout floor: opts.Timeout <= 0 +// must not reach retryabledns as a literal zero, since retryabledns applies no +// default of its own and a zero-timeout dns.Client blocks forever on a +// non-responsive resolver. A black-hole UDP listener (accepts the query, +// never replies) stands in for that non-responsive resolver. +func TestNewDNSResolverFloorsZeroTimeout(t *testing.T) { + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer pc.Close() + go func() { + buf := make([]byte, 512) + for { + if _, _, err := pc.ReadFrom(buf); err != nil { + return + } + // never reply: this is the black hole. + } + }() + + origResolvers := defaultDNSResolvers + defaultDNSResolvers = []string{pc.LocalAddr().String()} + t.Cleanup(func() { defaultDNSResolvers = origResolvers }) + + r, err := newDNSResolver(nil, 0) + if err != nil { + t.Fatalf("newDNSResolver: %v", err) + } + + done := make(chan error, 1) + start := time.Now() + go func() { + _, err := r.Query("example.com", dns.TypeA) + done <- err + }() + + // the executor retries dnsMaxRetries times, each capped at the floored + // timeout, so the bound is a multiple of it, not the timeout itself. + bound := time.Duration(dnsMaxRetries) * defaultDNSTimeout + select { + case err := <-done: + elapsed := time.Since(start) + t.Logf("query against a black-hole resolver returned after %v: %v", elapsed, err) + if elapsed > bound+2*time.Second { + t.Errorf("query took %v, want it bounded by ~%v (floored timeout x retries)", elapsed, bound) + } + case <-time.After(bound + 5*time.Second): + t.Fatal("query against a black-hole resolver did not return in bounded time; a zero timeout is hanging forever") + } +}