Skip to content

Commit 6ce6da7

Browse files
PR2: record-aware result schema + -format text/json/csv; bump to 0.6.0
- internal/dns exposes Resolve and Record{Type, Value}; scan.Event carries Records per resolved subdomain. - -format text|json|csv flag (default text, byte-identical to prior output); -o honors the selected format. CLI-only for now (TUI-pending). - Version bumped to 0.6.0 (main.go, advanced_usage -version output, Pages Download string + cache-busters in README and default.html). - CHANGELOG [Unreleased] and ARCHITECTURE 2.3/2.5 document the new schema and output formats. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 06623e6 commit 6ce6da7

12 files changed

Lines changed: 393 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
- 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).
12+
- `-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).
13+
814
## [0.5.1] - 2026-06-03
915

1016
### Fixed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
[![Go](https://img.shields.io/badge/Go-1.24.2+-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev)
1010
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?style=for-the-badge)](LICENSE)
1111
[![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)
12-
[![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)
12+
[![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)
1313
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge)](./docs/CONTRIBUTING.md)
1414
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=for-the-badge)](#installation)
1515

@@ -77,6 +77,7 @@ Or launch the interactive terminal UI with no flags:
7777
| Wordlist Dedup | Deduplicate wordlist entries in a single pass before scanning begins |
7878
| Simulation Mode | Generate synthetic DNS results at a configurable hit rate, with zero network I/O |
7979
| Output Pipeline | Resolved domains to stdout (pipe-clean); progress and diagnostics to stderr |
80+
| Output Formats | Emit results as `text`, `json` (array of subdomain plus typed records), or `csv` via `-format` |
8081
| Interactive TUI | Form-based config and live-scrolling results via `-tui`; session values persisted |
8182

8283
<br>
@@ -198,6 +199,7 @@ make help # list all targets
198199
| `-attempts <n>` | `1` | DNS resolution attempts per subdomain (1 = no retry) |
199200
| `-force` | `false` | Continue scanning even if wildcard DNS is detected |
200201
| `-o <file>` | n/a | Write results to file in addition to stdout |
202+
| `-format <fmt>` | `text` | Output format: `text`, `json`, or `csv` |
201203
| `-v` | `false` | Verbose output: IPs, timings, per-query detail (stderr) |
202204
| `-progress` | `true` | Live progress line on stderr |
203205
| `-simulate` | `false` | Simulation mode: no real DNS queries |

docs/ARCHITECTURE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +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.ResolveDomain(ctx, domain, timeout, dnsServer, verbose) bool`
72-
* Function: `dns.ResolveDomainWithRetry(ctx, domain, timeout, dnsServer, verbose, maxAttempts) bool` — wraps `ResolveDomain` with configurable retry logic and linear backoff between attempts.
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).
72+
* 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.
7374
* Function: `dns.CheckWildcard(ctx, domain, timeout, dnsServer) (bool, error)` — resolves two random subdomains to detect wildcard DNS records.
7475
* `net.Resolver{}`: A custom DNS resolver is configured.
7576
* `PreferGo: true`: Instructs the resolver to use the pure Go DNS client.
@@ -98,7 +99,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
9899
* **Purpose**: Thread-safe output that keeps stdout pipe-clean. Resolved subdomains go to stdout; everything else (progress, verbose diagnostics, errors) goes to stderr.
99100
* **Implementation**:
100101
* `output.Writer` struct with mutex-protected methods:
101-
* `Result(domain)` prints `Found: <domain>` to stdout (and to the output file if configured).
102+
* `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).
102103
* `Progress(pct, processed, total, found)` — writes a carriage-return progress line to stderr.
103104
* `Info(format, args...)` — writes an informational line to stderr.
104105
* `Error(format, args...)` — writes an error line to stderr.

docs/_layouts/default.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@
329329
<a href="{{ '/docker.html' | relative_url }}"{% if page.title == 'Docker Usage' %} class="active"{% endif %}>Docker</a>
330330
<a href="{{ '/CONTRIBUTING.html' | relative_url }}"{% if page.title == 'Contributing' %} class="active"{% endif %}>Contributing</a>
331331
<div class="nav-right">
332-
<a href="https://github.com/TMHSDigital/subenum/releases/latest" target="_blank">Download v0.5.1</a>
332+
<a href="https://github.com/TMHSDigital/subenum/releases/latest" target="_blank">Download v0.6.0</a>
333333
</div>
334334
</nav>
335335

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

351351
<a href="https://github.com/TMHSDigital/subenum/releases/latest" class="btn-hero btn-hero-primary" target="_blank">Download latest release</a>

examples/advanced_usage.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,49 @@ 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+
## Output Formats
155+
156+
By default `subenum` prints human-readable `Found:` lines. Use `-format` to emit structured output instead. The `-o` file honors the same format.
157+
158+
### JSON
159+
160+
Emits a single JSON array of objects, each with the subdomain and its resolved records:
161+
162+
```bash
163+
./subenum -w wordlist.txt -format json example.com
164+
```
165+
166+
```json
167+
[
168+
{
169+
"subdomain": "www.example.com",
170+
"records": [{ "type": "A", "value": "93.184.216.34" }]
171+
}
172+
]
173+
```
174+
175+
JSON is buffered and written once at completion (it is a single document, so it does not stream like text and CSV).
176+
177+
### CSV
178+
179+
Streams a header followed by one `subdomain,type,value` row per record:
180+
181+
```bash
182+
./subenum -w wordlist.txt -format csv -o results.csv example.com
183+
```
184+
185+
```csv
186+
subdomain,type,value
187+
www.example.com,A,93.184.216.34
188+
```
189+
154190
## Continuous Integration / Automated Testing
155191

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

158194
```bash
159195
./subenum -version
160-
# Output: subenum v0.5.1
196+
# Output: subenum v0.6.0
161197
```
162198

163199
Use simulation mode in CI pipelines to test the tool's behaviour without network access:

internal/dns/resolver.go

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,55 +6,92 @@ import (
66
"fmt"
77
"net"
88
"os"
9+
"strings"
910
"time"
1011
)
1112

12-
// ResolveDomain performs a single DNS lookup for the given domain using the
13-
// specified server and timeout. It returns true if the domain resolves.
14-
func ResolveDomain(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool) bool {
15-
resolver := &net.Resolver{
13+
// Record is a single resolved DNS record. Type is "A", "AAAA", "CNAME", etc.
14+
// Value is the IP address (for A/AAAA) or target name (for CNAME).
15+
type Record struct {
16+
Type string `json:"type"`
17+
Value string `json:"value"`
18+
}
19+
20+
func newResolver(timeout time.Duration, dnsServer string) *net.Resolver {
21+
return &net.Resolver{
1622
PreferGo: true,
1723
Dial: func(dialCtx context.Context, network, address string) (net.Conn, error) {
1824
d := net.Dialer{Timeout: timeout}
1925
return d.DialContext(dialCtx, "udp", dnsServer)
2026
},
2127
}
28+
}
29+
30+
// Resolve performs a single host lookup and returns the resolved records (A and
31+
// AAAA), the elapsed time, and any error. It performs no logging.
32+
func Resolve(ctx context.Context, domain string, timeout time.Duration, dnsServer string) ([]Record, time.Duration, error) {
33+
resolver := newResolver(timeout, dnsServer)
2234

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

2638
start := time.Now()
2739
ips, err := resolver.LookupHost(timeoutCtx, domain)
2840
elapsed := time.Since(start)
41+
if err != nil {
42+
return nil, elapsed, err
43+
}
44+
45+
records := make([]Record, 0, len(ips))
46+
for _, ip := range ips {
47+
typ := "A"
48+
if strings.Contains(ip, ":") {
49+
typ = "AAAA"
50+
}
51+
records = append(records, Record{Type: typ, Value: ip})
52+
}
53+
return records, elapsed, nil
54+
}
55+
56+
// ResolveDomain performs a single DNS lookup for the given domain using the
57+
// specified server and timeout. It returns true if the domain resolves (A/AAAA).
58+
func ResolveDomain(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool) bool {
59+
records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose)
60+
return len(records) > 0
61+
}
2962

30-
if verbose && err == nil {
31-
fmt.Fprintf(os.Stderr, "Resolved: %s (IP: %s) in %s\n", domain, ips[0], elapsed)
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)
67+
if verbose && len(records) > 0 {
68+
fmt.Fprintf(os.Stderr, "Resolved: %s (%s: %s) in %s\n", domain, records[0].Type, records[0].Value, elapsed)
3269
} else if verbose {
3370
fmt.Fprintf(os.Stderr, "Failed to resolve: %s (Error: %v) in %s\n", domain, err, elapsed)
3471
}
35-
36-
return err == nil
72+
return records, elapsed, err
3773
}
3874

39-
// ResolveDomainWithRetry calls ResolveDomain up to maxAttempts times, respecting
40-
// ctx cancellation between attempts with a linear backoff delay.
41-
func ResolveDomainWithRetry(ctx context.Context, domain string, timeout time.Duration, dnsServer string, verbose bool, maxAttempts int) bool {
75+
// ResolveDomainWithRetry calls ResolveWithLog up to maxAttempts times, respecting
76+
// ctx cancellation between attempts with a linear backoff delay. It returns the
77+
// 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) {
4279
for attempt := 0; attempt < maxAttempts; attempt++ {
4380
if ctx.Err() != nil {
44-
return false
81+
return nil, false
4582
}
46-
if ResolveDomain(ctx, domain, timeout, dnsServer, verbose) {
47-
return true
83+
if records, _, _ := ResolveWithLog(ctx, domain, timeout, dnsServer, verbose); len(records) > 0 {
84+
return records, true
4885
}
4986
if attempt < maxAttempts-1 {
5087
select {
5188
case <-time.After(time.Duration(50*(attempt+1)) * time.Millisecond):
5289
case <-ctx.Done():
53-
return false
90+
return nil, false
5491
}
5592
}
5693
}
57-
return false
94+
return nil, false
5895
}
5996

6097
// randomHex returns n random hex characters.

internal/dns/resolver_test.go

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

101101
timeout := time.Second * 2
102102

103-
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)
104104
if !result {
105105
t.Errorf("Expected google.com to resolve with retries, but it failed")
106106
}
107+
if len(records) == 0 {
108+
t.Errorf("Expected resolved records for google.com, got none")
109+
}
107110

108-
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)
109112
if result {
110113
t.Errorf("Expected non-existent domain to fail even with retries")
111114
}
@@ -121,7 +124,7 @@ func TestResolveDomainWithRetryContextCancellation(t *testing.T) {
121124

122125
timeout := time.Second * 2
123126
start := time.Now()
124-
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)
125128
elapsed := time.Since(start)
126129

127130
if result {

internal/dns/simulate.go

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +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)
16+
return ok
17+
}
18+
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) {
1522
commonSubdomains := []string{
1623
"www", "mail", "ftp", "blog",
1724
"api", "dev", "staging", "test",
@@ -20,26 +27,34 @@ func SimulateResolution(domain string, hitRate int, verbose bool) bool {
2027

2128
for _, sub := range commonSubdomains {
2229
if strings.HasPrefix(domain, sub+".") {
23-
if verbose {
24-
fakeTiming := time.Duration(50+rand.IntN(200)) * time.Millisecond
25-
fakeIP := fmt.Sprintf("192.168.%d.%d", rand.IntN(255), 1+rand.IntN(254))
26-
fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (IP: %s) in %s\n", domain, fakeIP, fakeTiming)
30+
if rand.IntN(100) < 90 {
31+
return synthResolved(domain, verbose)
2732
}
28-
return rand.IntN(100) < 90
33+
return synthFailed(domain, verbose)
2934
}
3035
}
3136

32-
result := rand.IntN(100) < hitRate
37+
if rand.IntN(100) < hitRate {
38+
return synthResolved(domain, verbose)
39+
}
40+
return synthFailed(domain, verbose)
41+
}
3342

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

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

0 commit comments

Comments
 (0)