diff --git a/CHANGELOG.md b/CHANGELOG.md
index 432f065..41f8a11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index dee14d7..a39091f 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
[](https://go.dev)
[](LICENSE)
[](https://github.com/TMHSDigital/subenum/actions/workflows/codeql.yml)
-[](https://goreportcard.com/report/github.com/TMHSDigital/subenum)
+[](https://goreportcard.com/report/github.com/TMHSDigital/subenum)
[](./docs/CONTRIBUTING.md)
[](#installation)
@@ -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 |
@@ -198,6 +199,7 @@ make help # list all targets
| `-attempts ` | `1` | DNS resolution attempts per subdomain (1 = no retry) |
| `-force` | `false` | Continue scanning even if wildcard DNS is detected |
| `-o ` | n/a | Write results to file in addition to stdout |
+| `-format ` | `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 |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 9d8d6cd..af034e0 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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.
@@ -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: ` to stdout (and to the output file if configured).
+ * `Result(domain, records)` - in `text` format prints `Found: ` 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.
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
index ded9d76..bc4819c 100644
--- a/docs/_layouts/default.html
+++ b/docs/_layouts/default.html
@@ -329,7 +329,7 @@
Docker
Contributing
@@ -345,7 +345,7 @@
-
+
Download latest release
diff --git a/examples/advanced_usage.md b/examples/advanced_usage.md
index da0e96a..94a185f 100644
--- a/examples/advanced_usage.md
+++ b/examples/advanced_usage.md
@@ -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:
diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go
index 16f4148..7568037 100644
--- a/internal/dns/resolver.go
+++ b/internal/dns/resolver.go
@@ -6,19 +6,31 @@ 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()
@@ -26,35 +38,60 @@ func ResolveDomain(ctx context.Context, domain string, timeout time.Duration, dn
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.
diff --git a/internal/dns/resolver_test.go b/internal/dns/resolver_test.go
index 9c8a56e..607ef6c 100644
--- a/internal/dns/resolver_test.go
+++ b/internal/dns/resolver_test.go
@@ -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")
}
@@ -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 {
diff --git a/internal/dns/simulate.go b/internal/dns/simulate.go
index 78f5333..0977005 100644
--- a/internal/dns/simulate.go
+++ b/internal/dns/simulate.go
@@ -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",
@@ -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
}
diff --git a/internal/output/writer.go b/internal/output/writer.go
index e7123d3..81fb43f 100644
--- a/internal/output/writer.go
+++ b/internal/output/writer.go
@@ -2,29 +2,90 @@ package output
import (
"bufio"
+ "encoding/csv"
+ "encoding/json"
"fmt"
"os"
+ "strings"
"sync"
+
+ "github.com/TMHSDigital/subenum/internal/dns"
+)
+
+// Format selects how resolved results are rendered on stdout and in the output
+// file.
+type Format int
+
+const (
+ // FormatText is the default human-friendly streaming output.
+ FormatText Format = iota
+ // FormatJSON buffers results and emits a single JSON array at completion.
+ FormatJSON
+ // FormatCSV streams "subdomain,type,value" rows with a header.
+ //
+ // Note: the JSON format buffers the whole document and therefore does not
+ // stream like text and CSV do. If live JSON piping is ever needed, JSONL
+ // (one JSON object per line) is the streaming-friendly alternative.
+ FormatCSV
)
+// ParseFormat converts a flag string into a Format.
+func ParseFormat(s string) (Format, error) {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "", "text":
+ return FormatText, nil
+ case "json":
+ return FormatJSON, nil
+ case "csv":
+ return FormatCSV, nil
+ default:
+ return FormatText, fmt.Errorf("invalid format %q (want text, json, or csv)", s)
+ }
+}
+
+// Result is one resolved subdomain and its records, used for structured output.
+type Result struct {
+ Subdomain string `json:"subdomain"`
+ Records []dns.Record `json:"records"`
+}
+
// Writer synchronises all output. Results go to stdout (and optionally a file);
// everything else (progress, verbose, errors) goes to stderr.
type Writer struct {
mu sync.Mutex
outWriter *bufio.Writer
simulate bool
+ format Format
+
+ buffered []Result // FormatJSON: accumulated until Finish
+ csvStdout *csv.Writer
+ csvFile *csv.Writer
+ csvInit bool
}
// New returns a Writer. If outWriter is non-nil, resolved domains are also
-// written there. Set simulate to true to tag result lines as simulated.
-func New(outWriter *bufio.Writer, simulate bool) *Writer {
- return &Writer{outWriter: outWriter, simulate: simulate}
+// written there. Set simulate to true to tag text result lines as simulated.
+func New(outWriter *bufio.Writer, simulate bool, format Format) *Writer {
+ return &Writer{outWriter: outWriter, simulate: simulate, format: format}
}
-// Result prints a resolved domain to stdout (and the output file if configured).
-func (w *Writer) Result(domain string) {
+// Result records a resolved domain. In text mode it prints immediately; in JSON
+// mode it is buffered for Finish; in CSV mode rows are streamed.
+func (w *Writer) Result(domain string, records []dns.Record) {
w.mu.Lock()
defer w.mu.Unlock()
+
+ switch w.format {
+ case FormatJSON:
+ w.buffered = append(w.buffered, Result{Subdomain: domain, Records: records})
+ case FormatCSV:
+ w.writeCSVRows(domain, records)
+ default:
+ w.writeText(domain)
+ }
+}
+
+func (w *Writer) writeText(domain string) {
if w.simulate {
fmt.Printf("Found (SIMULATED): %s\n", domain)
} else {
@@ -35,6 +96,66 @@ func (w *Writer) Result(domain string) {
}
}
+func (w *Writer) ensureCSV() {
+ if w.csvInit {
+ return
+ }
+ w.csvInit = true
+ w.csvStdout = csv.NewWriter(os.Stdout)
+ header := []string{"subdomain", "type", "value"}
+ _ = w.csvStdout.Write(header)
+ if w.outWriter != nil {
+ w.csvFile = csv.NewWriter(w.outWriter)
+ _ = w.csvFile.Write(header)
+ }
+}
+
+func (w *Writer) writeCSVRows(domain string, records []dns.Record) {
+ w.ensureCSV()
+ rows := records
+ if len(rows) == 0 {
+ rows = []dns.Record{{}}
+ }
+ for _, r := range rows {
+ row := []string{domain, r.Type, r.Value}
+ _ = w.csvStdout.Write(row)
+ if w.csvFile != nil {
+ _ = w.csvFile.Write(row)
+ }
+ }
+}
+
+// Finish flushes any buffered or streamed structured output. It must be called
+// once after the scan completes (before the output file is flushed and closed).
+func (w *Writer) Finish() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ switch w.format {
+ case FormatJSON:
+ results := w.buffered
+ if results == nil {
+ results = []Result{}
+ }
+ data, err := json.MarshalIndent(results, "", " ")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: encoding JSON output: %v\n", err)
+ return
+ }
+ fmt.Printf("%s\n", data)
+ if w.outWriter != nil {
+ fmt.Fprintf(w.outWriter, "%s\n", data)
+ }
+ case FormatCSV:
+ if w.csvStdout != nil {
+ w.csvStdout.Flush()
+ }
+ if w.csvFile != nil {
+ w.csvFile.Flush()
+ }
+ }
+}
+
// Progress writes a progress line to stderr using carriage-return overwrite.
func (w *Writer) Progress(pct float64, processed, total, found int64) {
w.mu.Lock()
diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go
index c6953fc..1ecbc0f 100644
--- a/internal/output/writer_test.go
+++ b/internal/output/writer_test.go
@@ -2,12 +2,36 @@ package output
import (
"bufio"
+ "encoding/json"
"os"
"strings"
"testing"
+
+ "github.com/TMHSDigital/subenum/internal/dns"
)
-func TestWriterResult(t *testing.T) {
+func TestParseFormat(t *testing.T) {
+ cases := map[string]Format{
+ "": FormatText,
+ "text": FormatText,
+ "JSON": FormatJSON,
+ "csv": FormatCSV,
+ }
+ for in, want := range cases {
+ got, err := ParseFormat(in)
+ if err != nil {
+ t.Errorf("ParseFormat(%q) error: %v", in, err)
+ }
+ if got != want {
+ t.Errorf("ParseFormat(%q) = %v, want %v", in, got, want)
+ }
+ }
+ if _, err := ParseFormat("yaml"); err == nil {
+ t.Error("expected error for invalid format")
+ }
+}
+
+func TestWriterResultText(t *testing.T) {
tmp, err := os.CreateTemp("", "output-test-*.txt")
if err != nil {
t.Fatal(err)
@@ -15,12 +39,13 @@ func TestWriterResult(t *testing.T) {
defer func() { _ = os.Remove(tmp.Name()) }()
bw := bufio.NewWriter(tmp)
- w := New(bw, false)
+ w := New(bw, false, FormatText)
domains := []string{"www.example.com", "api.example.com", "mail.example.com"}
for _, d := range domains {
- w.Result(d)
+ w.Result(d, []dns.Record{{Type: "A", Value: "1.2.3.4"}})
}
+ w.Finish()
if err := bw.Flush(); err != nil {
t.Fatal(err)
}
@@ -32,10 +57,82 @@ func TestWriterResult(t *testing.T) {
if err != nil {
t.Fatal(err)
}
-
for _, d := range domains {
if !strings.Contains(string(content), d) {
t.Errorf("expected output file to contain %q\nGot:\n%s", d, content)
}
}
}
+
+func TestWriterResultJSONFile(t *testing.T) {
+ tmp, err := os.CreateTemp("", "output-json-*.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = os.Remove(tmp.Name()) }()
+
+ bw := bufio.NewWriter(tmp)
+ w := New(bw, false, FormatJSON)
+ w.Result("www.example.com", []dns.Record{{Type: "A", Value: "93.184.216.34"}})
+ w.Result("ipv6.example.com", []dns.Record{{Type: "AAAA", Value: "2606:2800:220:1::1"}})
+ w.Finish()
+ if err := bw.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ if err := tmp.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ content, err := os.ReadFile(tmp.Name())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var results []Result
+ if err := json.Unmarshal(content, &results); err != nil {
+ t.Fatalf("output is not valid JSON array: %v\nGot:\n%s", err, content)
+ }
+ if len(results) != 2 {
+ t.Fatalf("expected 2 results, got %d", len(results))
+ }
+ if results[0].Subdomain != "www.example.com" || results[0].Records[0].Type != "A" {
+ t.Errorf("unexpected first result: %+v", results[0])
+ }
+}
+
+func TestWriterResultCSVFile(t *testing.T) {
+ tmp, err := os.CreateTemp("", "output-csv-*.csv")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = os.Remove(tmp.Name()) }()
+
+ bw := bufio.NewWriter(tmp)
+ w := New(bw, false, FormatCSV)
+ w.Result("www.example.com", []dns.Record{
+ {Type: "A", Value: "1.1.1.1"},
+ {Type: "AAAA", Value: "2606::1"},
+ })
+ w.Finish()
+ if err := bw.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ if err := tmp.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ content, err := os.ReadFile(tmp.Name())
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := string(content)
+ if !strings.Contains(got, "subdomain,type,value") {
+ t.Errorf("expected CSV header, got:\n%s", got)
+ }
+ if !strings.Contains(got, "www.example.com,A,1.1.1.1") {
+ t.Errorf("expected A row, got:\n%s", got)
+ }
+ if !strings.Contains(got, "www.example.com,AAAA,2606::1") {
+ t.Errorf("expected AAAA row, got:\n%s", got)
+ }
+}
diff --git a/internal/scan/runner.go b/internal/scan/runner.go
index b1d870e..ec7d4cc 100644
--- a/internal/scan/runner.go
+++ b/internal/scan/runner.go
@@ -37,11 +37,12 @@ const (
// Event is emitted on the events channel during a scan.
type Event struct {
Kind EventKind
- Domain string // EventResult: the resolved subdomain
- Processed int64 // EventProgress
- Total int64 // EventProgress
- Found int64 // EventProgress / EventDone
- Message string // EventError / EventWildcard
+ Domain string // EventResult: the resolved subdomain
+ Records []dns.Record // EventResult: the resolved records
+ Processed int64 // EventProgress
+ Total int64 // EventProgress
+ Found int64 // EventProgress / EventDone
+ Message string // EventError / EventWildcard
}
// Run executes the subdomain scan, sending events to the provided channel.
@@ -114,14 +115,15 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) {
}
fullDomain := prefix + "." + cfg.Domain
var resolved bool
+ var records []dns.Record
if cfg.Simulate {
- resolved = dns.SimulateResolution(fullDomain, cfg.HitRate, cfg.Verbose)
+ records, resolved = dns.SimulateResolve(fullDomain, cfg.HitRate, cfg.Verbose)
} else {
- 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)
}
if resolved {
atomic.AddInt64(&found, 1)
- events <- Event{Kind: EventResult, Domain: fullDomain}
+ events <- Event{Kind: EventResult, Domain: fullDomain, Records: records}
}
atomic.AddInt64(&processed, 1)
}
diff --git a/main.go b/main.go
index 45f4a21..07399e1 100644
--- a/main.go
+++ b/main.go
@@ -40,7 +40,7 @@ import (
const (
ProgramName = "subenum"
- Version = "0.5.1"
+ Version = "0.6.0"
DefaultDNSServer = "8.8.8.8:53"
)
@@ -100,6 +100,7 @@ type cliFlags struct {
attempts int
retries int
force bool
+ format string
}
func parseFlags() cliFlags {
@@ -118,6 +119,7 @@ func parseFlags() cliFlags {
flag.IntVar(&f.attempts, "attempts", 0, "Total DNS resolution attempts per subdomain (1 = no retry)")
flag.IntVar(&f.retries, "retries", 0, "Deprecated: use -attempts instead")
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.Parse()
return f
}
@@ -158,7 +160,7 @@ func validateFlags(f cliFlags, out *output.Writer, maxAttempts int) (string, boo
return domain, true
}
-func openOutputFile(path string, testMode bool, out *output.Writer) (*output.Writer, *bufio.Writer, *os.File, bool) {
+func openOutputFile(path string, testMode bool, format output.Format, out *output.Writer) (*output.Writer, *bufio.Writer, *os.File, bool) {
if path == "" {
return out, nil, nil, true
}
@@ -168,7 +170,7 @@ func openOutputFile(path string, testMode bool, out *output.Writer) (*output.Wri
return out, nil, nil, false
}
w := bufio.NewWriter(f)
- return output.New(w, testMode), w, f, true
+ return output.New(w, testMode, format), w, f, true
}
func logVerboseStart(f cliFlags, domain string, maxAttempts int, out *output.Writer) {
@@ -213,8 +215,13 @@ func logVerboseDone(ev scan.Event, f cliFlags, outWriter *bufio.Writer, out *out
func run() int {
f := parseFlags()
+ format, formatErr := output.ParseFormat(f.format)
maxAttempts, err := resolveAttempts(f.attempts, f.retries)
- out := output.New(nil, f.testMode)
+ out := output.New(nil, f.testMode, format)
+ if formatErr != nil {
+ out.Error("%v", formatErr)
+ return 1
+ }
if err != nil {
out.Error("%v", err)
return 1
@@ -242,7 +249,7 @@ func run() int {
return 1
}
- out, outWriter, outFile, ok := openOutputFile(f.outputFile, f.testMode, out)
+ out, outWriter, outFile, ok := openOutputFile(f.outputFile, f.testMode, format, out)
if !ok {
return 1
}
@@ -256,6 +263,9 @@ func run() int {
}
}()
}
+ // Finish runs before the file flush/close defer above (LIFO), so buffered
+ // JSON and streamed CSV are written before the file is closed.
+ defer out.Finish()
if f.verbose {
logVerboseStart(f, domain, maxAttempts, out)
@@ -310,7 +320,7 @@ func run() int {
for ev := range events {
switch ev.Kind {
case scan.EventResult:
- out.Result(ev.Domain)
+ out.Result(ev.Domain, ev.Records)
case scan.EventProgress:
if f.showProgress && totalWords > 0 {
progressStarted = true