Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
- `-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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<br>
Expand Down Expand Up @@ -202,6 +203,7 @@ make help # list all targets
| `-o <file>` | n/a | Write results to file in addition to stdout |
| `-format <fmt>` | `text` | Output format: `text`, `json`, or `csv` |
| `-rate <qps>` | `0` | Max DNS queries per second across all workers (0 = unlimited) |
| `-type <list>` | `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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions examples/advanced_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
96 changes: 89 additions & 7 deletions internal/dns/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions internal/dns/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,15 @@ 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")
}
if len(records) == 0 {
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")
}
Expand All @@ -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 {
Expand Down
32 changes: 23 additions & 9 deletions internal/dns/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions internal/dns/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
5 changes: 3 additions & 2 deletions internal/scan/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Config struct {
Force bool
Verbose bool
Rate int
Types []string
}

// EventKind categorises a scan event.
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -102,6 +103,7 @@ type cliFlags struct {
force bool
format string
rate int
recordTypes string
}

func parseFlags() cliFlags {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -318,6 +326,7 @@ func run() int {
Force: f.force,
Verbose: f.verbose,
Rate: f.rate,
Types: recordTypes,
}

events := make(chan scan.Event, 64)
Expand Down
Loading