Skip to content

Commit 96ed45d

Browse files
PR4: -type per-record-type lookups and filtering
- -type A,AAAA,CNAME flag (default A,AAAA, preserving prior behavior) performs per-type DNS lookups and filters results to the requested types. The resolved record type is carried in the existing Record shape, so the JSON/CSV schema is unchanged. - CHANGELOG [Unreleased] and ARCHITECTURE 2.3 document ResolveTypes and -type. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 032dc90 commit 96ed45d

10 files changed

Lines changed: 177 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Resolved records are now captured during scans. `internal/dns` exposes `Resolve` and a `Record{Type, Value}` type; `scan.Event` carries `Records` for each resolved subdomain (A/AAAA today, extensible to CNAME and more).
1212
- `-format text|json|csv` flag (default `text`, byte-for-byte identical to prior output). JSON emits a buffered array of `{"subdomain", "records"}` objects; CSV streams `subdomain,type,value` rows with a header. The `-o` output file honors the selected format. Output formats are CLI-only for now (TUI-pending).
1313
- `-rate <qps>` flag (default 0 = unlimited) caps total DNS queries per second across the worker pool via a shared stdlib ticker gate inside `scan.Run`. The limiter respects context cancellation so `Ctrl+C` stays responsive.
14+
- `-type A,AAAA,CNAME` flag (default `A,AAAA`, preserving prior behavior) performs per-type DNS lookups and filters results to the requested types. The resolved record type is carried in the existing `Record` shape, so the JSON/CSV schema is unchanged.
1415

1516
## [0.5.1] - 2026-06-03
1617

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Or launch the interactive terminal UI with no flags:
7979
| Output Pipeline | Resolved domains to stdout (pipe-clean); progress and diagnostics to stderr |
8080
| Output Formats | Emit results as `text`, `json` (array of subdomain plus typed records), or `csv` via `-format` |
8181
| Rate Limiting | Cap total DNS queries per second across the worker pool with `-rate` (context-aware) |
82+
| Record Types | Look up and filter by `A`, `AAAA`, or `CNAME` records with `-type` |
8283
| Interactive TUI | Form-based config and live-scrolling results via `-tui`; session values persisted |
8384

8485
<br>
@@ -202,6 +203,7 @@ make help # list all targets
202203
| `-o <file>` | n/a | Write results to file in addition to stdout |
203204
| `-format <fmt>` | `text` | Output format: `text`, `json`, or `csv` |
204205
| `-rate <qps>` | `0` | Max DNS queries per second across all workers (0 = unlimited) |
206+
| `-type <list>` | `A,AAAA` | Comma-separated record types to look up: `A`, `AAAA`, `CNAME` |
205207
| `-v` | `false` | Verbose output: IPs, timings, per-query detail (stderr) |
206208
| `-progress` | `true` | Live progress line on stderr |
207209
| `-simulate` | `false` | Simulation mode: no real DNS queries |

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
6868

6969
* **Purpose**: This is the core component responsible for performing the actual DNS lookup for each constructed subdomain (e.g., `prefix.targetdomain.com`). It determines if a subdomain has a valid DNS record (typically A or CNAME, though the current implementation checks for any successful resolution). It also provides wildcard DNS detection.
7070
* **Implementation**:
71-
* Function: `dns.Resolve(ctx, domain, timeout, dnsServer) ([]Record, time.Duration, error)` - performs the lookup and returns typed `Record{Type, Value}` results (A/AAAA today, extensible to CNAME).
71+
* Function: `dns.Resolve(ctx, domain, timeout, dnsServer) ([]Record, time.Duration, error)` and `dns.ResolveTypes(..., types)` - perform the lookups and return typed `Record{Type, Value}` results. `ResolveTypes` issues per-type lookups (`LookupIP` ip4/ip6 for A/AAAA, `LookupCNAME` for CNAME) and filters to the requested types (default A,AAAA via `-type`).
7272
* Function: `dns.ResolveDomain(ctx, domain, timeout, dnsServer, verbose) bool` - convenience wrapper returning a boolean, used by wildcard detection.
73-
* Function: `dns.ResolveDomainWithRetry(ctx, domain, timeout, dnsServer, verbose, maxAttempts) ([]Record, bool)` - wraps the lookup with configurable retry logic and linear backoff between attempts, returning the resolved records.
73+
* Function: `dns.ResolveDomainWithRetry(ctx, domain, timeout, dnsServer, verbose, maxAttempts, types) ([]Record, bool)` - wraps the lookup with configurable retry logic and linear backoff between attempts, returning the resolved records.
7474
* Function: `dns.CheckWildcard(ctx, domain, timeout, dnsServer) (bool, error)` — resolves two random subdomains to detect wildcard DNS records.
7575
* `net.Resolver{}`: A custom DNS resolver is configured.
7676
* `PreferGo: true`: Instructs the resolver to use the pure Go DNS client.

examples/advanced_usage.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,22 @@ Simulation mode with verbose output shows fake IPs and timings:
151151
./subenum -simulate -hit-rate 25 -v -w examples/sample_wordlist.txt example.com
152152
```
153153

154+
## Record Types
155+
156+
By default `subenum` looks up `A` and `AAAA` records. Use `-type` to choose which record types to query and treat as a hit. A subdomain counts as found if any requested type resolves:
157+
158+
```bash
159+
./subenum -w wordlist.txt -type A,AAAA,CNAME example.com
160+
```
161+
162+
Find only subdomains that are CNAMEs (useful for spotting potential takeovers):
163+
164+
```bash
165+
./subenum -w wordlist.txt -type CNAME -format json example.com
166+
```
167+
168+
The record type is captured per result, so JSON and CSV output carry it in the `records` field / `type` column.
169+
154170
## Rate Limiting
155171

156172
Use `-rate` to cap the total number of DNS queries per second across the whole worker pool. This is useful against rate-limited resolvers or to stay under a target query budget. `0` (the default) means unlimited:

internal/dns/resolver.go

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,39 @@ type Record struct {
1717
Value string `json:"value"`
1818
}
1919

20+
// DefaultTypes is the record-type set used when none is specified; it preserves
21+
// the historical LookupHost behavior (A and AAAA).
22+
var DefaultTypes = []string{"A", "AAAA"}
23+
24+
var supportedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true}
25+
26+
// ParseTypes parses a comma-separated record-type list (for example
27+
// "A,AAAA,CNAME") into a normalized, de-duplicated, uppercase slice.
28+
func ParseTypes(s string) ([]string, error) {
29+
if strings.TrimSpace(s) == "" {
30+
return append([]string(nil), DefaultTypes...), nil
31+
}
32+
seen := map[string]bool{}
33+
var out []string
34+
for _, part := range strings.Split(s, ",") {
35+
t := strings.ToUpper(strings.TrimSpace(part))
36+
if t == "" {
37+
continue
38+
}
39+
if !supportedTypes[t] {
40+
return nil, fmt.Errorf("unsupported record type %q (want A, AAAA, or CNAME)", part)
41+
}
42+
if !seen[t] {
43+
seen[t] = true
44+
out = append(out, t)
45+
}
46+
}
47+
if len(out) == 0 {
48+
return append([]string(nil), DefaultTypes...), nil
49+
}
50+
return out, nil
51+
}
52+
2053
func newResolver(timeout time.Duration, dnsServer string) *net.Resolver {
2154
return &net.Resolver{
2255
PreferGo: true,
@@ -27,6 +60,55 @@ func newResolver(timeout time.Duration, dnsServer string) *net.Resolver {
2760
}
2861
}
2962

63+
// ResolveTypes performs per-type DNS lookups for the requested record types and
64+
// returns the matching records, the elapsed time, and the last lookup error (if
65+
// any). An empty types slice falls back to DefaultTypes.
66+
func ResolveTypes(ctx context.Context, domain string, timeout time.Duration, dnsServer string, types []string) ([]Record, time.Duration, error) {
67+
if len(types) == 0 {
68+
types = DefaultTypes
69+
}
70+
resolver := newResolver(timeout, dnsServer)
71+
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
72+
defer cancel()
73+
74+
start := time.Now()
75+
var records []Record
76+
var lastErr error
77+
for _, t := range types {
78+
switch t {
79+
case "A":
80+
ips, err := resolver.LookupIP(timeoutCtx, "ip4", domain)
81+
if err != nil {
82+
lastErr = err
83+
continue
84+
}
85+
for _, ip := range ips {
86+
records = append(records, Record{Type: "A", Value: ip.String()})
87+
}
88+
case "AAAA":
89+
ips, err := resolver.LookupIP(timeoutCtx, "ip6", domain)
90+
if err != nil {
91+
lastErr = err
92+
continue
93+
}
94+
for _, ip := range ips {
95+
records = append(records, Record{Type: "AAAA", Value: ip.String()})
96+
}
97+
case "CNAME":
98+
cname, err := resolver.LookupCNAME(timeoutCtx, domain)
99+
if err != nil {
100+
lastErr = err
101+
continue
102+
}
103+
// LookupCNAME returns the domain itself when there is no CNAME chain.
104+
if cname != "" && !strings.EqualFold(strings.TrimSuffix(cname, "."), strings.TrimSuffix(domain, ".")) {
105+
records = append(records, Record{Type: "CNAME", Value: strings.TrimSuffix(cname, ".")})
106+
}
107+
}
108+
}
109+
return records, time.Since(start), lastErr
110+
}
111+
30112
// Resolve performs a single host lookup and returns the resolved records (A and
31113
// AAAA), the elapsed time, and any error. It performs no logging.
32114
func Resolve(ctx context.Context, domain string, timeout time.Duration, dnsServer string) ([]Record, time.Duration, error) {
@@ -56,14 +138,14 @@ func Resolve(ctx context.Context, domain string, timeout time.Duration, dnsServe
56138
// ResolveDomain performs a single DNS lookup for the given domain using the
57139
// specified server and timeout. It returns true if the domain resolves (A/AAAA).
58140
func ResolveDomain(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool) bool {
59-
records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose)
141+
records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose, DefaultTypes)
60142
return len(records) > 0
61143
}
62144

63-
// ResolveWithLog wraps Resolve with the verbose stderr logging used by the CLI
64-
// and TUI, returning the resolved records.
65-
func ResolveWithLog(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool) ([]Record, time.Duration, error) {
66-
records, elapsed, err := Resolve(ctx, domain, timeout, dnsServer)
145+
// ResolveWithLog wraps ResolveTypes with the verbose stderr logging used by the
146+
// CLI and TUI, returning the resolved records for the requested types.
147+
func ResolveWithLog(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, types []string) ([]Record, time.Duration, error) {
148+
records, elapsed, err := ResolveTypes(ctx, domain, timeout, dnsServer, types)
67149
if verbose && len(records) > 0 {
68150
fmt.Fprintf(os.Stderr, "Resolved: %s (%s: %s) in %s\n", domain, records[0].Type, records[0].Value, elapsed)
69151
} else if verbose {
@@ -75,12 +157,12 @@ func ResolveWithLog(ctx context.Context, domain string, timeout time.Duration, d
75157
// ResolveDomainWithRetry calls ResolveWithLog up to maxAttempts times, respecting
76158
// ctx cancellation between attempts with a linear backoff delay. It returns the
77159
// resolved records and whether resolution succeeded.
78-
func ResolveDomainWithRetry(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, maxAttempts int) ([]Record, bool) {
160+
func ResolveDomainWithRetry(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, maxAttempts int, types []string) ([]Record, bool) {
79161
for attempt := 0; attempt < maxAttempts; attempt++ {
80162
if ctx.Err() != nil {
81163
return nil, false
82164
}
83-
if records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose); len(records) > 0 {
165+
if records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose, types); len(records) > 0 {
84166
return records, true
85167
}
86168
if attempt < maxAttempts-1 {

internal/dns/resolver_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,15 @@ func TestResolveDomainWithRetry(t *testing.T) {
100100

101101
timeout := time.Second * 2
102102

103-
records, result := ResolveDomainWithRetry(context.Background(), "google.com", timeout, "8.8.8.8:53", false, 3)
103+
records, result := ResolveDomainWithRetry(context.Background(), "google.com", timeout, "8.8.8.8:53", false, 3, DefaultTypes)
104104
if !result {
105105
t.Errorf("Expected google.com to resolve with retries, but it failed")
106106
}
107107
if len(records) == 0 {
108108
t.Errorf("Expected resolved records for google.com, got none")
109109
}
110110

111-
_, result = ResolveDomainWithRetry(context.Background(), "this-domain-should-not-exist-123456789.com", timeout, "8.8.8.8:53", false, 2)
111+
_, result = ResolveDomainWithRetry(context.Background(), "this-domain-should-not-exist-123456789.com", timeout, "8.8.8.8:53", false, 2, DefaultTypes)
112112
if result {
113113
t.Errorf("Expected non-existent domain to fail even with retries")
114114
}
@@ -124,7 +124,7 @@ func TestResolveDomainWithRetryContextCancellation(t *testing.T) {
124124

125125
timeout := time.Second * 2
126126
start := time.Now()
127-
_, result := ResolveDomainWithRetry(ctx, "google.com", timeout, "8.8.8.8:53", false, 5)
127+
_, result := ResolveDomainWithRetry(ctx, "google.com", timeout, "8.8.8.8:53", false, 5, DefaultTypes)
128128
elapsed := time.Since(start)
129129

130130
if result {

internal/dns/simulate.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import (
1212
// network I/O. Common subdomain prefixes resolve ~90% of the time; everything
1313
// else uses the supplied hitRate (0-100).
1414
func SimulateResolution(domain string, hitRate int, verbose bool) bool {
15-
_, ok := SimulateResolve(domain, hitRate, verbose)
15+
_, ok := SimulateResolve(domain, hitRate, verbose, DefaultTypes)
1616
return ok
1717
}
1818

19-
// SimulateResolve is like SimulateResolution but also returns synthetic A
20-
// records when the domain "resolves".
21-
func SimulateResolve(domain string, hitRate int, verbose bool) ([]Record, bool) {
19+
// SimulateResolve is like SimulateResolution but also returns synthetic records
20+
// for the requested types when the domain "resolves".
21+
func SimulateResolve(domain string, hitRate int, verbose bool, types []string) ([]Record, bool) {
2222
commonSubdomains := []string{
2323
"www", "mail", "ftp", "blog",
2424
"api", "dev", "staging", "test",
@@ -28,21 +28,35 @@ func SimulateResolve(domain string, hitRate int, verbose bool) ([]Record, bool)
2828
for _, sub := range commonSubdomains {
2929
if strings.HasPrefix(domain, sub+".") {
3030
if rand.IntN(100) < 90 {
31-
return synthResolved(domain, verbose)
31+
return synthResolved(domain, types, verbose)
3232
}
3333
return synthFailed(domain, verbose)
3434
}
3535
}
3636

3737
if rand.IntN(100) < hitRate {
38-
return synthResolved(domain, verbose)
38+
return synthResolved(domain, types, verbose)
3939
}
4040
return synthFailed(domain, verbose)
4141
}
4242

43-
func synthResolved(domain string, verbose bool) ([]Record, bool) {
44-
records := []Record{
45-
{Type: "A", Value: fmt.Sprintf("10.%d.%d.%d", rand.IntN(255), rand.IntN(255), 1+rand.IntN(254))},
43+
func synthResolved(domain string, types []string, verbose bool) ([]Record, bool) {
44+
if len(types) == 0 {
45+
types = DefaultTypes
46+
}
47+
var records []Record
48+
for _, t := range types {
49+
switch t {
50+
case "A":
51+
records = append(records, Record{Type: "A", Value: fmt.Sprintf("10.%d.%d.%d", rand.IntN(255), rand.IntN(255), 1+rand.IntN(254))})
52+
case "AAAA":
53+
records = append(records, Record{Type: "AAAA", Value: fmt.Sprintf("2001:db8::%x", rand.IntN(65535))})
54+
case "CNAME":
55+
records = append(records, Record{Type: "CNAME", Value: "target." + domain})
56+
}
57+
}
58+
if len(records) == 0 {
59+
return nil, false
4660
}
4761
if verbose {
4862
fakeTiming := time.Duration(50+rand.IntN(450)) * time.Millisecond

internal/dns/simulate_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,35 @@ func TestSimulateResolution(t *testing.T) {
2929
}
3030
}
3131

32+
func TestParseTypes(t *testing.T) {
33+
got, err := ParseTypes("a, cname ,A")
34+
if err != nil {
35+
t.Fatalf("ParseTypes error: %v", err)
36+
}
37+
if len(got) != 2 || got[0] != "A" || got[1] != "CNAME" {
38+
t.Errorf("ParseTypes dedup/normalize failed: %v", got)
39+
}
40+
41+
if d, _ := ParseTypes(""); len(d) != 2 || d[0] != "A" || d[1] != "AAAA" {
42+
t.Errorf("empty should default to A,AAAA, got %v", d)
43+
}
44+
45+
if _, err := ParseTypes("MX"); err == nil {
46+
t.Error("expected error for unsupported type MX")
47+
}
48+
}
49+
50+
func TestSimulateResolveTypes(t *testing.T) {
51+
// Force a resolve with hitRate 100 and request only CNAME.
52+
recs, ok := SimulateResolve("zzz.example.com", 100, false, []string{"CNAME"})
53+
if !ok {
54+
t.Fatal("expected simulate to resolve at hitRate 100")
55+
}
56+
if len(recs) != 1 || recs[0].Type != "CNAME" {
57+
t.Errorf("expected a single CNAME record, got %v", recs)
58+
}
59+
}
60+
3261
// TestSimulateResolutionConcurrent calls SimulateResolution from many goroutines
3362
// at once. With math/rand/v2 top-level functions this is race-free; the test
3463
// exists to be caught by `go test -race`.

internal/scan/runner.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Config struct {
2222
Force bool
2323
Verbose bool
2424
Rate int
25+
Types []string
2526
}
2627

2728
// EventKind categorises a scan event.
@@ -139,9 +140,9 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) {
139140
var resolved bool
140141
var records []dns.Record
141142
if cfg.Simulate {
142-
records, resolved = dns.SimulateResolve(fullDomain, cfg.HitRate, cfg.Verbose)
143+
records, resolved = dns.SimulateResolve(fullDomain, cfg.HitRate, cfg.Verbose, cfg.Types)
143144
} else {
144-
records, resolved = dns.ResolveDomainWithRetry(ctx, fullDomain, cfg.Timeout, cfg.DNSServer, cfg.Verbose, cfg.Attempts)
145+
records, resolved = dns.ResolveDomainWithRetry(ctx, fullDomain, cfg.Timeout, cfg.DNSServer, cfg.Verbose, cfg.Attempts, cfg.Types)
145146
}
146147
if resolved {
147148
atomic.AddInt64(&found, 1)

main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"syscall"
3333
"time"
3434

35+
"github.com/TMHSDigital/subenum/internal/dns"
3536
"github.com/TMHSDigital/subenum/internal/output"
3637
"github.com/TMHSDigital/subenum/internal/scan"
3738
"github.com/TMHSDigital/subenum/internal/tui"
@@ -102,6 +103,7 @@ type cliFlags struct {
102103
force bool
103104
format string
104105
rate int
106+
recordTypes string
105107
}
106108

107109
func parseFlags() cliFlags {
@@ -122,6 +124,7 @@ func parseFlags() cliFlags {
122124
flag.BoolVar(&f.force, "force", false, "Continue scanning even if wildcard DNS is detected")
123125
flag.StringVar(&f.format, "format", "text", "Output format: text, json, or csv")
124126
flag.IntVar(&f.rate, "rate", 0, "Max DNS queries per second across all workers (0 = unlimited)")
127+
flag.StringVar(&f.recordTypes, "type", "A,AAAA", "Comma-separated DNS record types to look up: A, AAAA, CNAME")
125128
flag.Parse()
126129
return f
127130
}
@@ -222,12 +225,17 @@ func run() int {
222225
f := parseFlags()
223226

224227
format, formatErr := output.ParseFormat(f.format)
228+
recordTypes, typesErr := dns.ParseTypes(f.recordTypes)
225229
maxAttempts, err := resolveAttempts(f.attempts, f.retries)
226230
out := output.New(nil, f.testMode, format)
227231
if formatErr != nil {
228232
out.Error("%v", formatErr)
229233
return 1
230234
}
235+
if typesErr != nil {
236+
out.Error("%v", typesErr)
237+
return 1
238+
}
231239
if err != nil {
232240
out.Error("%v", err)
233241
return 1
@@ -318,6 +326,7 @@ func run() int {
318326
Force: f.force,
319327
Verbose: f.verbose,
320328
Rate: f.rate,
329+
Types: recordTypes,
321330
}
322331

323332
events := make(chan scan.Event, 64)

0 commit comments

Comments
 (0)