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..4c8b73b2 --- /dev/null +++ b/internal/modules/dns.go @@ -0,0 +1,285 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · 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 + +// 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"} + +// 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 given pool (falling back to the +// 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, + 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.Resolvers, 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..4a1cb4fa --- /dev/null +++ b/internal/modules/dns_test.go @@ -0,0 +1,474 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "reflect" + "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([]string, 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} +} + +// 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} +} + +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([]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")}}) + 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(nil, 2*time.Second) + if err != nil { + t.Fatalf("newDNSResolver: %v", err) + } + if r == nil { + 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") + } +} 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/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/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: 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 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 {