Skip to content

Commit d3a0cf9

Browse files
committed
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.
1 parent 7ea1cd2 commit d3a0cf9

7 files changed

Lines changed: 722 additions & 28 deletions

File tree

docs/modules.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ info:
6565

6666
### type (required)
6767

68-
module type. currently only `http` is supported.
68+
module type. `http` and `dns` are supported.
6969

7070
```yaml
7171
type: http
@@ -168,6 +168,41 @@ http:
168168
threads: 5
169169
```
170170

171+
### dns
172+
173+
dns lookup configuration. the module resolves one name and record type, then
174+
runs its matchers and extractors against the answer.
175+
176+
```yaml
177+
type: dns
178+
179+
dns:
180+
type: txt
181+
name: "{{FQDN}}"
182+
```
183+
184+
#### type
185+
186+
record type to query: `a` (default), `aaaa`, `cname`, `mx`, `ns`, `txt`, `soa`,
187+
`srv`, `caa`, `ptr`, or `any`.
188+
189+
#### name
190+
191+
name to resolve. `{{FQDN}}` is replaced with the target, and an empty name uses
192+
the target host. a target given as a url is reduced to its hostname.
193+
194+
#### dns matcher and extractor parts
195+
196+
dns matchers and extractors take a `part`:
197+
198+
- `answer` - the resource records
199+
- `rcode` - the response status, e.g. `NOERROR` or `NXDOMAIN`
200+
- `all` (default) - the full response text
201+
202+
the `status` matcher type is http only and is rejected on a dns module; match a
203+
response code with a word or regex matcher on part `rcode`. extractors are regex
204+
only on dns.
205+
171206
## matchers
172207

173208
matchers determine if a response indicates a finding.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/charmbracelet/log v1.0.0
1010
github.com/gocolly/colly/v2 v2.3.0
1111
github.com/likexian/whois v1.15.7
12+
github.com/miekg/dns v1.1.68
1213
github.com/projectdiscovery/goflags v0.1.74
1314
github.com/projectdiscovery/nuclei/v3 v3.9.0
1415
github.com/projectdiscovery/retryabledns v1.0.115
@@ -240,7 +241,6 @@ require (
240241
github.com/mholt/archives v0.1.5 // indirect
241242
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
242243
github.com/microsoft/go-mssqldb v1.9.2 // indirect
243-
github.com/miekg/dns v1.1.68 // indirect
244244
github.com/mikelolasagasti/xz v1.0.1 // indirect
245245
github.com/minio/minlz v1.0.1 // indirect
246246
github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 // indirect

internal/modules/dns.go

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2026 vmfunc, xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package modules
14+
15+
import (
16+
"context"
17+
"fmt"
18+
"net/url"
19+
"regexp"
20+
"strings"
21+
"time"
22+
23+
"github.com/miekg/dns"
24+
retryabledns "github.com/projectdiscovery/retryabledns"
25+
)
26+
27+
// dnsMaxRetries is how many times the resolver rotates through the pool on a
28+
// timeout before giving up.
29+
const dnsMaxRetries = 3
30+
31+
// defaultDNSResolvers is the bundled pool: fast public anycast servers.
32+
var defaultDNSResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
33+
34+
// dnsRequestType maps a module's record-type string to its dns type code. An
35+
// empty type defaults to A; ANY is deliberately not the default (RFC 8482
36+
// discourages relying on ANY against the public resolvers).
37+
var dnsRequestType = map[string]uint16{
38+
"": dns.TypeA,
39+
"a": dns.TypeA,
40+
"aaaa": dns.TypeAAAA,
41+
"cname": dns.TypeCNAME,
42+
"mx": dns.TypeMX,
43+
"ns": dns.TypeNS,
44+
"txt": dns.TypeTXT,
45+
"soa": dns.TypeSOA,
46+
"srv": dns.TypeSRV,
47+
"caa": dns.TypeCAA,
48+
"ptr": dns.TypePTR,
49+
"any": dns.TypeANY,
50+
}
51+
52+
// dnsResolver is the slice of the retryabledns client the executor needs; tests
53+
// inject a fake through newDNSResolver.
54+
type dnsResolver interface {
55+
Query(host string, requestType uint16) (*retryabledns.DNSData, error)
56+
}
57+
58+
// newDNSResolver builds a resolver over the bundled pool with the given timeout.
59+
// It is a package var so tests can supply a fake without touching the network.
60+
var newDNSResolver = func(timeout time.Duration) (dnsResolver, error) {
61+
opts := retryabledns.Options{
62+
BaseResolvers: defaultDNSResolvers,
63+
MaxRetries: dnsMaxRetries,
64+
}
65+
if timeout > 0 {
66+
opts.Timeout = timeout
67+
}
68+
client, err := retryabledns.NewWithOptions(opts)
69+
if err != nil {
70+
return nil, fmt.Errorf("build dns resolver: %w", err)
71+
}
72+
client.TCPFallback = true
73+
return client, nil
74+
}
75+
76+
// dnsResponse holds the parts of a resolved answer a matcher can target.
77+
type dnsResponse struct {
78+
answer []string // the resource records, one per line
79+
rcode string // the response status, e.g. NOERROR or NXDOMAIN
80+
raw string // the full text of the response message
81+
}
82+
83+
// validateDNS rejects, at load time, a dns config the executor cannot run: an
84+
// unknown record type, or a matcher type other than word or regex (status is
85+
// http only).
86+
func validateDNS(cfg *DNSConfig) error {
87+
if _, ok := dnsRequestType[strings.ToLower(cfg.Type)]; !ok {
88+
return fmt.Errorf("unsupported dns record type %q", cfg.Type)
89+
}
90+
for i := range cfg.Matchers {
91+
switch cfg.Matchers[i].Type {
92+
case "word", "regex":
93+
default:
94+
return fmt.Errorf("dns matcher type %q is not supported (use word or regex)", cfg.Matchers[i].Type)
95+
}
96+
}
97+
return nil
98+
}
99+
100+
// ExecuteDNSModule resolves the configured name and record type, then applies
101+
// the module's matchers and extractors to the answer.
102+
func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
103+
if def.DNS == nil {
104+
return nil, fmt.Errorf("no DNS configuration")
105+
}
106+
cfg := def.DNS
107+
result := &Result{
108+
ModuleID: def.ID,
109+
Target: target,
110+
Findings: make([]Finding, 0),
111+
}
112+
113+
qtype, ok := dnsRequestType[strings.ToLower(cfg.Type)]
114+
if !ok {
115+
return nil, fmt.Errorf("unsupported dns record type %q", cfg.Type)
116+
}
117+
118+
resolver, err := newDNSResolver(opts.Timeout)
119+
if err != nil {
120+
return nil, err
121+
}
122+
123+
// retryabledns has no context hook, so honor cancellation before the lookup.
124+
if err := ctx.Err(); err != nil {
125+
return result, err
126+
}
127+
128+
name := dnsName(cfg.Name, target)
129+
data, err := resolver.Query(name, qtype)
130+
if err != nil {
131+
return nil, fmt.Errorf("dns query %q: %w", name, err)
132+
}
133+
134+
resp := newDNSResponse(data)
135+
if !checkDNSMatchers(cfg.Matchers, resp) {
136+
return result, nil
137+
}
138+
139+
result.Findings = append(result.Findings, Finding{
140+
Severity: def.Info.Severity,
141+
Evidence: truncateEvidence(resp.raw),
142+
Extracted: runDNSExtractors(cfg.Extractors, resp),
143+
})
144+
return result, nil
145+
}
146+
147+
// newDNSResponse extracts the matchable parts from a resolved answer. The raw
148+
// text comes from RawResp (the single final message) rather than data.Raw, which
149+
// the resolver's retry loop concatenates across attempts.
150+
func newDNSResponse(data *retryabledns.DNSData) dnsResponse {
151+
if data == nil {
152+
return dnsResponse{}
153+
}
154+
raw := data.Raw
155+
if data.RawResp != nil {
156+
raw = data.RawResp.String()
157+
}
158+
return dnsResponse{
159+
answer: data.AllRecords,
160+
rcode: data.StatusCode,
161+
raw: raw,
162+
}
163+
}
164+
165+
// getDNSPart returns the slice of the response a matcher or extractor targets.
166+
// The default (and the explicit "all"/"body") is the full response text;
167+
// "answer" is the record set; "rcode" is the response status.
168+
func getDNSPart(part string, resp dnsResponse) string {
169+
switch strings.ToLower(part) {
170+
case "answer":
171+
return strings.Join(resp.answer, "\n")
172+
case "rcode":
173+
return resp.rcode
174+
default:
175+
return resp.raw
176+
}
177+
}
178+
179+
// checkDNSMatchers evaluates all matchers against the response with AND logic.
180+
func checkDNSMatchers(matchers []Matcher, resp dnsResponse) bool {
181+
if len(matchers) == 0 {
182+
return false
183+
}
184+
185+
for i := range matchers {
186+
matched := checkDNSMatcher(&matchers[i], resp)
187+
if matchers[i].Negative {
188+
matched = !matched
189+
}
190+
if !matched {
191+
return false // AND logic
192+
}
193+
}
194+
195+
return true
196+
}
197+
198+
// checkDNSMatcher evaluates a single matcher. The status matcher type is HTTP
199+
// only; match a response code with a word or regex matcher on part "rcode".
200+
func checkDNSMatcher(m *Matcher, resp dnsResponse) bool {
201+
part := getDNSPart(m.Part, resp)
202+
203+
switch m.Type {
204+
case "word":
205+
return checkWords(part, m.Words, m.Condition)
206+
case "regex":
207+
return checkRegex(part, m.Regex, m.Condition)
208+
default:
209+
return false
210+
}
211+
}
212+
213+
// runDNSExtractors pulls regex captures from the response. DNS answers are text,
214+
// so regex is the available extractor; other types are skipped.
215+
func runDNSExtractors(extractors []Extractor, resp dnsResponse) map[string]string {
216+
if len(extractors) == 0 {
217+
return nil
218+
}
219+
220+
result := make(map[string]string)
221+
for _, e := range extractors {
222+
if e.Type != "regex" {
223+
continue
224+
}
225+
part := getDNSPart(e.Part, resp)
226+
for _, pattern := range e.Regex {
227+
re, err := regexp.Compile(pattern)
228+
if err != nil {
229+
continue
230+
}
231+
matches := re.FindStringSubmatch(part)
232+
if len(matches) > e.Group {
233+
result[e.Name] = matches[e.Group]
234+
break
235+
}
236+
}
237+
}
238+
239+
return result
240+
}
241+
242+
// dnsName resolves the lookup name: the module's name with {{FQDN}} replaced by
243+
// the target host, or the bare target host when no name is set.
244+
func dnsName(name, target string) string {
245+
host := dnsHost(target)
246+
if name == "" {
247+
return host
248+
}
249+
name = strings.ReplaceAll(name, "{{FQDN}}", host)
250+
name = strings.ReplaceAll(name, "{{fqdn}}", host)
251+
return name
252+
}
253+
254+
// dnsHost reduces target to its hostname, stripping any scheme, port, path, or
255+
// userinfo. A bare host is returned unchanged.
256+
func dnsHost(target string) string {
257+
target = strings.TrimSpace(target)
258+
if target == "" {
259+
return target
260+
}
261+
// url.Parse only populates Host when a scheme is present; add one for a bare
262+
// host or host:port so the same parse handles every form.
263+
parse := target
264+
if !strings.Contains(parse, "://") {
265+
parse = "//" + parse
266+
}
267+
if u, err := url.Parse(parse); err == nil && u.Hostname() != "" {
268+
return u.Hostname()
269+
}
270+
return target
271+
}

0 commit comments

Comments
 (0)