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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- 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).

## [0.5.1] - 2026-06-03

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![Go](https://img.shields.io/badge/Go-1.24.2+-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?style=for-the-badge)](LICENSE)
[![CodeQL](https://img.shields.io/github/actions/workflow/status/TMHSDigital/subenum/codeql.yml?label=CodeQL&style=for-the-badge)](https://github.com/TMHSDigital/subenum/actions/workflows/codeql.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/TMHSDigital/subenum?style=for-the-badge&v=0.5.1)](https://goreportcard.com/report/github.com/TMHSDigital/subenum)
[![Go Report Card](https://goreportcard.com/badge/github.com/TMHSDigital/subenum?style=for-the-badge&v=0.6.0)](https://goreportcard.com/report/github.com/TMHSDigital/subenum)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge)](./docs/CONTRIBUTING.md)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=for-the-badge)](#installation)

Expand Down Expand Up @@ -77,6 +77,7 @@ Or launch the interactive terminal UI with no flags:
| Wordlist Dedup | Deduplicate wordlist entries in a single pass before scanning begins |
| Simulation Mode | Generate synthetic DNS results at a configurable hit rate, with zero network I/O |
| 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` |
| Interactive TUI | Form-based config and live-scrolling results via `-tui`; session values persisted |

<br>
Expand Down Expand Up @@ -198,6 +199,7 @@ make help # list all targets
| `-attempts <n>` | `1` | DNS resolution attempts per subdomain (1 = no retry) |
| `-force` | `false` | Continue scanning even if wildcard DNS is detected |
| `-o <file>` | n/a | Write results to file in addition to stdout |
| `-format <fmt>` | `text` | Output format: `text`, `json`, or `csv` |
| `-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
7 changes: 4 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +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.ResolveDomain(ctx, domain, timeout, dnsServer, verbose) bool`
* Function: `dns.ResolveDomainWithRetry(ctx, domain, timeout, dnsServer, verbose, maxAttempts) bool` — wraps `ResolveDomain` with configurable retry logic and linear backoff between attempts.
* 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.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.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 Expand Up @@ -98,7 +99,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
* **Purpose**: Thread-safe output that keeps stdout pipe-clean. Resolved subdomains go to stdout; everything else (progress, verbose diagnostics, errors) goes to stderr.
* **Implementation**:
* `output.Writer` struct with mutex-protected methods:
* `Result(domain)` prints `Found: <domain>` to stdout (and to the output file if configured).
* `Result(domain, records)` - in `text` format prints `Found: <domain>` to stdout (and the output file if configured); in `json` format buffers `{"subdomain", "records"}` objects and writes a single array at completion; in `csv` format streams `subdomain,type,value` rows with a header. The format is selected with `-format text|json|csv` (default `text`, which is byte-for-byte identical to prior behavior). Output formats are CLI-only for now (TUI-pending).
* `Progress(pct, processed, total, found)` — writes a carriage-return progress line to stderr.
* `Info(format, args...)` — writes an informational line to stderr.
* `Error(format, args...)` — writes an error line to stderr.
Expand Down
4 changes: 2 additions & 2 deletions docs/_layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@
<a href="{{ '/docker.html' | relative_url }}"{% if page.title == 'Docker Usage' %} class="active"{% endif %}>Docker</a>
<a href="{{ '/CONTRIBUTING.html' | relative_url }}"{% if page.title == 'Contributing' %} class="active"{% endif %}>Contributing</a>
<div class="nav-right">
<a href="https://github.com/TMHSDigital/subenum/releases/latest" target="_blank">Download v0.5.1</a>
<a href="https://github.com/TMHSDigital/subenum/releases/latest" target="_blank">Download v0.6.0</a>
</div>
</nav>

Expand All @@ -345,7 +345,7 @@
<img src="https://img.shields.io/github/v/release/TMHSDigital/subenum?style=flat-square" alt="release">
<img src="https://img.shields.io/badge/Go-1.24.2+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="go">
<img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="license">
<img src="https://goreportcard.com/badge/github.com/TMHSDigital/subenum?style=flat-square&v=0.5.1" alt="go report">
<img src="https://goreportcard.com/badge/github.com/TMHSDigital/subenum?style=flat-square&v=0.6.0" alt="go report">
</div>

<a href="https://github.com/TMHSDigital/subenum/releases/latest" class="btn-hero btn-hero-primary" target="_blank">Download latest release</a>
Expand Down
38 changes: 37 additions & 1 deletion examples/advanced_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,49 @@ Simulation mode with verbose output shows fake IPs and timings:
./subenum -simulate -hit-rate 25 -v -w examples/sample_wordlist.txt example.com
```

## Output Formats

By default `subenum` prints human-readable `Found:` lines. Use `-format` to emit structured output instead. The `-o` file honors the same format.

### JSON

Emits a single JSON array of objects, each with the subdomain and its resolved records:

```bash
./subenum -w wordlist.txt -format json example.com
```

```json
[
{
"subdomain": "www.example.com",
"records": [{ "type": "A", "value": "93.184.216.34" }]
}
]
```

JSON is buffered and written once at completion (it is a single document, so it does not stream like text and CSV).

### CSV

Streams a header followed by one `subdomain,type,value` row per record:

```bash
./subenum -w wordlist.txt -format csv -o results.csv example.com
```

```csv
subdomain,type,value
www.example.com,A,93.184.216.34
```

## Continuous Integration / Automated Testing

For CI/CD environments, you can use the version flag to ensure the correct version is installed:

```bash
./subenum -version
# Output: subenum v0.5.1
# Output: subenum v0.6.0
```

Use simulation mode in CI pipelines to test the tool's behaviour without network access:
Expand Down
69 changes: 53 additions & 16 deletions internal/dns/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,92 @@ import (
"fmt"
"net"
"os"
"strings"
"time"
)

// ResolveDomain performs a single DNS lookup for the given domain using the
// specified server and timeout. It returns true if the domain resolves.
func ResolveDomain(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool) bool {
resolver := &net.Resolver{
// Record is a single resolved DNS record. Type is "A", "AAAA", "CNAME", etc.
// Value is the IP address (for A/AAAA) or target name (for CNAME).
type Record struct {
Type string `json:"type"`
Value string `json:"value"`
}

func newResolver(timeout time.Duration, dnsServer string) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(dialCtx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: timeout}
return d.DialContext(dialCtx, "udp", dnsServer)
},
}
}

// 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) {
resolver := newResolver(timeout, dnsServer)

timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

start := time.Now()
ips, err := resolver.LookupHost(timeoutCtx, domain)
elapsed := time.Since(start)
if err != nil {
return nil, elapsed, err
}

records := make([]Record, 0, len(ips))
for _, ip := range ips {
typ := "A"
if strings.Contains(ip, ":") {
typ = "AAAA"
}
records = append(records, Record{Type: typ, Value: ip})
}
return records, elapsed, nil
}

// 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)
return len(records) > 0
}

if verbose && err == nil {
fmt.Fprintf(os.Stderr, "Resolved: %s (IP: %s) in %s\n", domain, ips[0], elapsed)
// 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)
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 {
fmt.Fprintf(os.Stderr, "Failed to resolve: %s (Error: %v) in %s\n", domain, err, elapsed)
}

return err == nil
return records, elapsed, err
}

// ResolveDomainWithRetry calls ResolveDomain up to maxAttempts times, respecting
// ctx cancellation between attempts with a linear backoff delay.
func ResolveDomainWithRetry(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, maxAttempts int) bool {
// 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) {
for attempt := 0; attempt < maxAttempts; attempt++ {
if ctx.Err() != nil {
return false
return nil, false
}
if ResolveDomain(ctx, domain, timeout, dnsServer, verbose) {
return true
if records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose); len(records) > 0 {
return records, true
}
if attempt < maxAttempts-1 {
select {
case <-time.After(time.Duration(50*(attempt+1)) * time.Millisecond):
case <-ctx.Done():
return false
return nil, false
}
}
}
return false
return nil, false
}

// randomHex returns n random hex characters.
Expand Down
9 changes: 6 additions & 3 deletions internal/dns/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,15 @@ func TestResolveDomainWithRetry(t *testing.T) {

timeout := time.Second * 2

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)
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)
if result {
t.Errorf("Expected non-existent domain to fail even with retries")
}
Expand All @@ -121,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)
elapsed := time.Since(start)

if result {
Expand Down
43 changes: 29 additions & 14 deletions internal/dns/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +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)
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) {
commonSubdomains := []string{
"www", "mail", "ftp", "blog",
"api", "dev", "staging", "test",
Expand All @@ -20,26 +27,34 @@ func SimulateResolution(domain string, hitRate int, verbose bool) bool {

for _, sub := range commonSubdomains {
if strings.HasPrefix(domain, sub+".") {
if verbose {
fakeTiming := time.Duration(50+rand.IntN(200)) * time.Millisecond
fakeIP := fmt.Sprintf("192.168.%d.%d", rand.IntN(255), 1+rand.IntN(254))
fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (IP: %s) in %s\n", domain, fakeIP, fakeTiming)
if rand.IntN(100) < 90 {
return synthResolved(domain, verbose)
}
return rand.IntN(100) < 90
return synthFailed(domain, verbose)
}
}

result := rand.IntN(100) < hitRate
if rand.IntN(100) < hitRate {
return synthResolved(domain, 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))},
}
if verbose {
fakeTiming := time.Duration(100+rand.IntN(500)) * time.Millisecond
if result {
fakeIP := fmt.Sprintf("10.%d.%d.%d", rand.IntN(255), rand.IntN(255), 1+rand.IntN(254))
fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (IP: %s) in %s\n", domain, fakeIP, fakeTiming)
} else {
fmt.Fprintf(os.Stderr, "Failed to resolve (SIMULATED): %s (Error: no such host) in %s\n", domain, fakeTiming)
}
fakeTiming := time.Duration(50+rand.IntN(450)) * time.Millisecond
fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (%s: %s) in %s\n", domain, records[0].Type, records[0].Value, fakeTiming)
}
return records, true
}

return result
func synthFailed(domain string, verbose bool) ([]Record, bool) {
if verbose {
fakeTiming := time.Duration(100+rand.IntN(500)) * time.Millisecond
fmt.Fprintf(os.Stderr, "Failed to resolve (SIMULATED): %s (Error: no such host) in %s\n", domain, fakeTiming)
}
return nil, false
}
Loading
Loading