From 96ed45d29f4a44919482407ffffe6d41c582bde0 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Wed, 3 Jun 2026 20:27:50 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + README.md | 2 + docs/ARCHITECTURE.md | 4 +- examples/advanced_usage.md | 16 ++++++ internal/dns/resolver.go | 96 ++++++++++++++++++++++++++++++++--- internal/dns/resolver_test.go | 6 +-- internal/dns/simulate.go | 32 ++++++++---- internal/dns/simulate_test.go | 29 +++++++++++ internal/scan/runner.go | 5 +- main.go | 9 ++++ 10 files changed, 177 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1979aed..69f8400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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). - `-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). - `-rate ` 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. +- `-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. ## [0.5.1] - 2026-06-03 diff --git a/README.md b/README.md index f1fa7cf..de8369a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Or launch the interactive terminal UI with no flags: | Output Pipeline | Resolved domains to stdout (pipe-clean); progress and diagnostics to stderr | | Output Formats | Emit results as `text`, `json` (array of subdomain plus typed records), or `csv` via `-format` | | Rate Limiting | Cap total DNS queries per second across the worker pool with `-rate` (context-aware) | +| Record Types | Look up and filter by `A`, `AAAA`, or `CNAME` records with `-type` | | Interactive TUI | Form-based config and live-scrolling results via `-tui`; session values persisted |
@@ -202,6 +203,7 @@ make help # list all targets | `-o ` | n/a | Write results to file in addition to stdout | | `-format ` | `text` | Output format: `text`, `json`, or `csv` | | `-rate ` | `0` | Max DNS queries per second across all workers (0 = unlimited) | +| `-type ` | `A,AAAA` | Comma-separated record types to look up: `A`, `AAAA`, `CNAME` | | `-v` | `false` | Verbose output: IPs, timings, per-query detail (stderr) | | `-progress` | `true` | Live progress line on stderr | | `-simulate` | `false` | Simulation mode: no real DNS queries | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4dd0fcd..fc7e231 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,9 +68,9 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube * **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. * **Implementation**: - * 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). + * 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`). * Function: `dns.ResolveDomain(ctx, domain, timeout, dnsServer, verbose) bool` - convenience wrapper returning a boolean, used by wildcard detection. - * 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. + * 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. * Function: `dns.CheckWildcard(ctx, domain, timeout, dnsServer) (bool, error)` — resolves two random subdomains to detect wildcard DNS records. * `net.Resolver{}`: A custom DNS resolver is configured. * `PreferGo: true`: Instructs the resolver to use the pure Go DNS client. diff --git a/examples/advanced_usage.md b/examples/advanced_usage.md index 88c293f..d655239 100644 --- a/examples/advanced_usage.md +++ b/examples/advanced_usage.md @@ -151,6 +151,22 @@ Simulation mode with verbose output shows fake IPs and timings: ./subenum -simulate -hit-rate 25 -v -w examples/sample_wordlist.txt example.com ``` +## Record Types + +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: + +```bash +./subenum -w wordlist.txt -type A,AAAA,CNAME example.com +``` + +Find only subdomains that are CNAMEs (useful for spotting potential takeovers): + +```bash +./subenum -w wordlist.txt -type CNAME -format json example.com +``` + +The record type is captured per result, so JSON and CSV output carry it in the `records` field / `type` column. + ## Rate Limiting 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: diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go index 7568037..35e7d68 100644 --- a/internal/dns/resolver.go +++ b/internal/dns/resolver.go @@ -17,6 +17,39 @@ type Record struct { Value string `json:"value"` } +// DefaultTypes is the record-type set used when none is specified; it preserves +// the historical LookupHost behavior (A and AAAA). +var DefaultTypes = []string{"A", "AAAA"} + +var supportedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true} + +// ParseTypes parses a comma-separated record-type list (for example +// "A,AAAA,CNAME") into a normalized, de-duplicated, uppercase slice. +func ParseTypes(s string) ([]string, error) { + if strings.TrimSpace(s) == "" { + return append([]string(nil), DefaultTypes...), nil + } + seen := map[string]bool{} + var out []string + for _, part := range strings.Split(s, ",") { + t := strings.ToUpper(strings.TrimSpace(part)) + if t == "" { + continue + } + if !supportedTypes[t] { + return nil, fmt.Errorf("unsupported record type %q (want A, AAAA, or CNAME)", part) + } + if !seen[t] { + seen[t] = true + out = append(out, t) + } + } + if len(out) == 0 { + return append([]string(nil), DefaultTypes...), nil + } + return out, nil +} + func newResolver(timeout time.Duration, dnsServer string) *net.Resolver { return &net.Resolver{ PreferGo: true, @@ -27,6 +60,55 @@ func newResolver(timeout time.Duration, dnsServer string) *net.Resolver { } } +// ResolveTypes performs per-type DNS lookups for the requested record types and +// returns the matching records, the elapsed time, and the last lookup error (if +// any). An empty types slice falls back to DefaultTypes. +func ResolveTypes(ctx context.Context, domain string, timeout time.Duration, dnsServer string, types []string) ([]Record, time.Duration, error) { + if len(types) == 0 { + types = DefaultTypes + } + resolver := newResolver(timeout, dnsServer) + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + start := time.Now() + var records []Record + var lastErr error + for _, t := range types { + switch t { + case "A": + ips, err := resolver.LookupIP(timeoutCtx, "ip4", domain) + if err != nil { + lastErr = err + continue + } + for _, ip := range ips { + records = append(records, Record{Type: "A", Value: ip.String()}) + } + case "AAAA": + ips, err := resolver.LookupIP(timeoutCtx, "ip6", domain) + if err != nil { + lastErr = err + continue + } + for _, ip := range ips { + records = append(records, Record{Type: "AAAA", Value: ip.String()}) + } + case "CNAME": + cname, err := resolver.LookupCNAME(timeoutCtx, domain) + if err != nil { + lastErr = err + continue + } + // LookupCNAME returns the domain itself when there is no CNAME chain. + if cname != "" && !strings.EqualFold(strings.TrimSuffix(cname, "."), strings.TrimSuffix(domain, ".")) { + records = append(records, Record{Type: "CNAME", Value: strings.TrimSuffix(cname, ".")}) + } + } + } + return records, time.Since(start), lastErr +} + // Resolve performs a single host lookup and returns the resolved records (A and // AAAA), the elapsed time, and any error. It performs no logging. 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 // ResolveDomain performs a single DNS lookup for the given domain using the // specified server and timeout. It returns true if the domain resolves (A/AAAA). func ResolveDomain(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool) bool { - records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose) + records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose, DefaultTypes) return len(records) > 0 } -// ResolveWithLog wraps Resolve with the verbose stderr logging used by the CLI -// and TUI, returning the resolved records. -func ResolveWithLog(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool) ([]Record, time.Duration, error) { - records, elapsed, err := Resolve(ctx, domain, timeout, dnsServer) +// ResolveWithLog wraps ResolveTypes with the verbose stderr logging used by the +// CLI and TUI, returning the resolved records for the requested types. +func ResolveWithLog(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, types []string) ([]Record, time.Duration, error) { + records, elapsed, err := ResolveTypes(ctx, domain, timeout, dnsServer, types) if verbose && len(records) > 0 { fmt.Fprintf(os.Stderr, "Resolved: %s (%s: %s) in %s\n", domain, records[0].Type, records[0].Value, elapsed) } else if verbose { @@ -75,12 +157,12 @@ func ResolveWithLog(ctx context.Context, domain string, timeout time.Duration, d // ResolveDomainWithRetry calls ResolveWithLog up to maxAttempts times, respecting // ctx cancellation between attempts with a linear backoff delay. It returns the // resolved records and whether resolution succeeded. -func ResolveDomainWithRetry(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, maxAttempts int) ([]Record, bool) { +func ResolveDomainWithRetry(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, maxAttempts int, types []string) ([]Record, bool) { for attempt := 0; attempt < maxAttempts; attempt++ { if ctx.Err() != nil { return nil, false } - if records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose); len(records) > 0 { + if records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose, types); len(records) > 0 { return records, true } if attempt < maxAttempts-1 { diff --git a/internal/dns/resolver_test.go b/internal/dns/resolver_test.go index 607ef6c..5fa7f55 100644 --- a/internal/dns/resolver_test.go +++ b/internal/dns/resolver_test.go @@ -100,7 +100,7 @@ func TestResolveDomainWithRetry(t *testing.T) { timeout := time.Second * 2 - records, result := ResolveDomainWithRetry(context.Background(), "google.com", timeout, "8.8.8.8:53", false, 3) + records, result := ResolveDomainWithRetry(context.Background(), "google.com", timeout, "8.8.8.8:53", false, 3, DefaultTypes) if !result { t.Errorf("Expected google.com to resolve with retries, but it failed") } @@ -108,7 +108,7 @@ func TestResolveDomainWithRetry(t *testing.T) { t.Errorf("Expected resolved records for google.com, got none") } - _, result = ResolveDomainWithRetry(context.Background(), "this-domain-should-not-exist-123456789.com", timeout, "8.8.8.8:53", false, 2) + _, result = ResolveDomainWithRetry(context.Background(), "this-domain-should-not-exist-123456789.com", timeout, "8.8.8.8:53", false, 2, DefaultTypes) if result { t.Errorf("Expected non-existent domain to fail even with retries") } @@ -124,7 +124,7 @@ func TestResolveDomainWithRetryContextCancellation(t *testing.T) { timeout := time.Second * 2 start := time.Now() - _, result := ResolveDomainWithRetry(ctx, "google.com", timeout, "8.8.8.8:53", false, 5) + _, result := ResolveDomainWithRetry(ctx, "google.com", timeout, "8.8.8.8:53", false, 5, DefaultTypes) elapsed := time.Since(start) if result { diff --git a/internal/dns/simulate.go b/internal/dns/simulate.go index 0977005..a475496 100644 --- a/internal/dns/simulate.go +++ b/internal/dns/simulate.go @@ -12,13 +12,13 @@ import ( // network I/O. Common subdomain prefixes resolve ~90% of the time; everything // else uses the supplied hitRate (0-100). func SimulateResolution(domain string, hitRate int, verbose bool) bool { - _, ok := SimulateResolve(domain, hitRate, verbose) + _, ok := SimulateResolve(domain, hitRate, verbose, DefaultTypes) return ok } -// SimulateResolve is like SimulateResolution but also returns synthetic A -// records when the domain "resolves". -func SimulateResolve(domain string, hitRate int, verbose bool) ([]Record, bool) { +// SimulateResolve is like SimulateResolution but also returns synthetic records +// for the requested types when the domain "resolves". +func SimulateResolve(domain string, hitRate int, verbose bool, types []string) ([]Record, bool) { commonSubdomains := []string{ "www", "mail", "ftp", "blog", "api", "dev", "staging", "test", @@ -28,21 +28,35 @@ func SimulateResolve(domain string, hitRate int, verbose bool) ([]Record, bool) for _, sub := range commonSubdomains { if strings.HasPrefix(domain, sub+".") { if rand.IntN(100) < 90 { - return synthResolved(domain, verbose) + return synthResolved(domain, types, verbose) } return synthFailed(domain, verbose) } } if rand.IntN(100) < hitRate { - return synthResolved(domain, verbose) + return synthResolved(domain, types, verbose) } return synthFailed(domain, verbose) } -func synthResolved(domain string, verbose bool) ([]Record, bool) { - records := []Record{ - {Type: "A", Value: fmt.Sprintf("10.%d.%d.%d", rand.IntN(255), rand.IntN(255), 1+rand.IntN(254))}, +func synthResolved(domain string, types []string, verbose bool) ([]Record, bool) { + if len(types) == 0 { + types = DefaultTypes + } + var records []Record + for _, t := range types { + switch t { + case "A": + records = append(records, Record{Type: "A", Value: fmt.Sprintf("10.%d.%d.%d", rand.IntN(255), rand.IntN(255), 1+rand.IntN(254))}) + case "AAAA": + records = append(records, Record{Type: "AAAA", Value: fmt.Sprintf("2001:db8::%x", rand.IntN(65535))}) + case "CNAME": + records = append(records, Record{Type: "CNAME", Value: "target." + domain}) + } + } + if len(records) == 0 { + return nil, false } if verbose { fakeTiming := time.Duration(50+rand.IntN(450)) * time.Millisecond diff --git a/internal/dns/simulate_test.go b/internal/dns/simulate_test.go index 4b2f8ce..0d8196b 100644 --- a/internal/dns/simulate_test.go +++ b/internal/dns/simulate_test.go @@ -29,6 +29,35 @@ func TestSimulateResolution(t *testing.T) { } } +func TestParseTypes(t *testing.T) { + got, err := ParseTypes("a, cname ,A") + if err != nil { + t.Fatalf("ParseTypes error: %v", err) + } + if len(got) != 2 || got[0] != "A" || got[1] != "CNAME" { + t.Errorf("ParseTypes dedup/normalize failed: %v", got) + } + + if d, _ := ParseTypes(""); len(d) != 2 || d[0] != "A" || d[1] != "AAAA" { + t.Errorf("empty should default to A,AAAA, got %v", d) + } + + if _, err := ParseTypes("MX"); err == nil { + t.Error("expected error for unsupported type MX") + } +} + +func TestSimulateResolveTypes(t *testing.T) { + // Force a resolve with hitRate 100 and request only CNAME. + recs, ok := SimulateResolve("zzz.example.com", 100, false, []string{"CNAME"}) + if !ok { + t.Fatal("expected simulate to resolve at hitRate 100") + } + if len(recs) != 1 || recs[0].Type != "CNAME" { + t.Errorf("expected a single CNAME record, got %v", recs) + } +} + // TestSimulateResolutionConcurrent calls SimulateResolution from many goroutines // at once. With math/rand/v2 top-level functions this is race-free; the test // exists to be caught by `go test -race`. diff --git a/internal/scan/runner.go b/internal/scan/runner.go index d7b4015..05bc67f 100644 --- a/internal/scan/runner.go +++ b/internal/scan/runner.go @@ -22,6 +22,7 @@ type Config struct { Force bool Verbose bool Rate int + Types []string } // EventKind categorises a scan event. @@ -139,9 +140,9 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) { var resolved bool var records []dns.Record if cfg.Simulate { - records, resolved = dns.SimulateResolve(fullDomain, cfg.HitRate, cfg.Verbose) + records, resolved = dns.SimulateResolve(fullDomain, cfg.HitRate, cfg.Verbose, cfg.Types) } else { - records, resolved = dns.ResolveDomainWithRetry(ctx, fullDomain, cfg.Timeout, cfg.DNSServer, cfg.Verbose, cfg.Attempts) + records, resolved = dns.ResolveDomainWithRetry(ctx, fullDomain, cfg.Timeout, cfg.DNSServer, cfg.Verbose, cfg.Attempts, cfg.Types) } if resolved { atomic.AddInt64(&found, 1) diff --git a/main.go b/main.go index bf415cc..1de7db5 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ import ( "syscall" "time" + "github.com/TMHSDigital/subenum/internal/dns" "github.com/TMHSDigital/subenum/internal/output" "github.com/TMHSDigital/subenum/internal/scan" "github.com/TMHSDigital/subenum/internal/tui" @@ -102,6 +103,7 @@ type cliFlags struct { force bool format string rate int + recordTypes string } func parseFlags() cliFlags { @@ -122,6 +124,7 @@ func parseFlags() cliFlags { flag.BoolVar(&f.force, "force", false, "Continue scanning even if wildcard DNS is detected") flag.StringVar(&f.format, "format", "text", "Output format: text, json, or csv") flag.IntVar(&f.rate, "rate", 0, "Max DNS queries per second across all workers (0 = unlimited)") + flag.StringVar(&f.recordTypes, "type", "A,AAAA", "Comma-separated DNS record types to look up: A, AAAA, CNAME") flag.Parse() return f } @@ -222,12 +225,17 @@ func run() int { f := parseFlags() format, formatErr := output.ParseFormat(f.format) + recordTypes, typesErr := dns.ParseTypes(f.recordTypes) maxAttempts, err := resolveAttempts(f.attempts, f.retries) out := output.New(nil, f.testMode, format) if formatErr != nil { out.Error("%v", formatErr) return 1 } + if typesErr != nil { + out.Error("%v", typesErr) + return 1 + } if err != nil { out.Error("%v", err) return 1 @@ -318,6 +326,7 @@ func run() int { Force: f.force, Verbose: f.verbose, Rate: f.rate, + Types: recordTypes, } events := make(chan scan.Event, 64)