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 @@ [![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) @@ -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 @@ release go license - go report + go report 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