From 73aeca21d36959d2dd5145b9a657e66d5c4fbaa4 Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Sun, 15 Nov 2026 08:59:56 +0800 Subject: [PATCH] Empty TUI wordlist scans with the bundled list (#288) The TUI form pre-filled examples/sample_wordlist.txt and rejected an empty field, so a binary run outside a checkout failed on ctrl+r with "cannot read wordlist: open examples/sample_wordlist.txt", and the bundled top-5000 list the command line falls back to was unreachable from the TUI. The Wordlist field is now optional: validate no longer rejects an empty value, and loadWordlist reads the bundled list through the same Preparer/EachLine path prepareWordlist uses for a missing -w. beginScan adds the CLI's notice, so the scan view names the list that ran. The field loses its repo-relative default and its required marker, with a placeholder that says what empty means. Tests: TestEmptyWordlistUsesBundledList starts a scan from an empty field and checks the loaded entries and the notice (it fails before the fix with the issue's "Wordlist path is required"); TestNewFormModelDefaults and TestFormViewRows pin the empty default. go test ./... passes, gofmt clean. Refs #288 --- CHANGELOG.md | 1 + README.md | 2 +- internal/tui/form.go | 7 +++---- internal/tui/form_test.go | 6 ++++-- internal/tui/model.go | 24 +++++++++++++++++++++++- internal/tui/model_test.go | 37 +++++++++++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eaf2c0..58e5574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tests no longer flake under load: lab end-to-end runs use a 5 s timeout with 3 attempts and fail with a clear message if the lab 2 wildcard probe times out, the resolver-pool spread test counts lookups per member instead of timing the scan (the timing view is now `BenchmarkPoolSpreadsLoad`), and CI runs tests in shuffled order (#167). ### Fixed +- The TUI no longer requires a wordlist file: an empty **Wordlist** field scans with the bundled top-5000 list, as a missing `-w` does on the command line, and the scan view says which list ran. The field no longer pre-fills `examples/sample_wordlist.txt`, which only exists inside a checkout, so `subenum -tui` from an installed binary works on the first run (#288). - On Windows, `-o NUL`, `-stats NUL` and `-sarif NUL` no longer fail with "Incorrect function" and exit 1: device and pipe targets are opened without truncation (#208, thanks @bfs2021). - An unreadable `-seeds` file no longer leaves a hidden `..tmp` file beside the `-o` path (kept locked on Windows until exit): seeds are read before the output file is opened (#212). - On Windows, `-simulate-zone` labs and the test DNS server no longer fail to start about a quarter of the time with "no free UDP+TCP port pair": after a failed TCP bind the server retries on random ports instead of the next OS-assigned one, which stayed inside the same reserved port block (#204). diff --git a/README.md b/README.md index c0bedd8..fbc31b1 100644 --- a/README.md +++ b/README.md @@ -593,7 +593,7 @@ No flags required. Fill in the form and press `ctrl+r` to start scanning. Last-u subenum interactive TUI -Flags and a domain given with `-tui` fill the form, so a prepared command line opens ready to run: `./subenum -tui example.com -w big.txt -r resolvers.txt`. The form takes one wordlist (the first `-w`), and `-tui` warns about any flag it has no field for. The **Resolver Pool** and **Rate/Resolver** fields are `-r` and `-rate-per-resolver`; the pool is not used with Simulate on. **Auto Rate**, **Max Duration** (blank for none, or a duration such as `20m`) and **Retry Inconclusive** are `-auto-rate`, `-max-duration` and `-retry-inconclusive`. +Flags and a domain given with `-tui` fill the form, so a prepared command line opens ready to run: `./subenum -tui example.com -w big.txt -r resolvers.txt`. The form takes one wordlist (the first `-w`), and `-tui` warns about any flag it has no field for. **Wordlist is optional:** leave the field empty to scan with the bundled top-5000 list, as a missing `-w` does on the command line. The **Resolver Pool** and **Rate/Resolver** fields are `-r` and `-rate-per-resolver`; the pool is not used with Simulate on. **Auto Rate**, **Max Duration** (blank for none, or a duration such as `20m`) and **Retry Inconclusive** are `-auto-rate`, `-max-duration` and `-retry-inconclusive`. ### Go library diff --git a/internal/tui/form.go b/internal/tui/form.go index 80e3f81..4a18d55 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -80,7 +80,7 @@ type formField struct { var formFields = [fieldCount]formField{ fieldDomain: {label: "Domain*", kind: textField, placeholder: "e.g. example.com", saved: func(c *savedConfig) any { return &c.Domain }}, - fieldWordlist: {label: "Wordlist*", flag: "w", kind: textField, placeholder: "e.g. examples/sample_wordlist.txt", def: "examples/sample_wordlist.txt", + fieldWordlist: {label: "Wordlist", flag: "w", kind: textField, placeholder: "empty = bundled top-5000 list", saved: func(c *savedConfig) any { return &c.Wordlist }}, fieldSimulate: {label: "Simulate", flag: "simulate", kind: toggleField, saved: func(c *savedConfig) any { return &c.Simulate }}, @@ -388,10 +388,9 @@ func (m *formModel) validate() (formValues, string) { if err != nil { return formValues{}, err.Error() } + // An empty Wordlist field scans with the bundled list, the way a missing + // -w does on the command line (#288). wl := expandHome(strings.TrimSpace(text(fieldWordlist))) - if wl == "" { - return formValues{}, "Wordlist path is required" - } // The TUI owns stdin, so reading a wordlist from it would block forever (#55). if wl == wordlist.Stdin { return formValues{}, "Wordlist from standard input (-) is not supported in the TUI; enter a file path" diff --git a/internal/tui/form_test.go b/internal/tui/form_test.go index 57d522e..1f355a7 100644 --- a/internal/tui/form_test.go +++ b/internal/tui/form_test.go @@ -17,7 +17,9 @@ func TestNewFormModelDefaults(t *testing.T) { if m.focus != fieldDomain { t.Errorf("expected initial focus on domain, got %d", m.focus) } - if got := m.inputs[fieldWordlist].Value(); got != "examples/sample_wordlist.txt" { + // No repo-relative default: an empty field means the bundled list, so a + // binary run outside a checkout works on the first try (#288). + if got := m.inputs[fieldWordlist].Value(); got != "" { t.Errorf("default wordlist = %q", got) } if got := m.inputs[fieldConcurrency].Value(); got != "100" { @@ -328,7 +330,7 @@ func TestFormViewRows(t *testing.T) { } want := []string{ "▸ Domain*: > e.g. example.com", - " Wordlist*: > examples/sample_wordlist.txt", + " Wordlist: > empty = bundled top-5000 list", " Simulate: OFF", " Hit Rate (%): (enable Simulate)", " DNS Server: > 8.8.8.8:53", diff --git a/internal/tui/model.go b/internal/tui/model.go index 5120c3c..e369cf9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -6,10 +6,12 @@ import ( "errors" "fmt" "math/rand/v2" + "strings" "time" tea "charm.land/bubbletea/v2" + "github.com/TMHSDigital/subenum/data" "github.com/TMHSDigital/subenum/internal/dns" "github.com/TMHSDigital/subenum/internal/output" "github.com/TMHSDigital/subenum/internal/report" @@ -169,12 +171,27 @@ func (m Model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { // pool, in a tea.Cmd goroutine. func loadWordlistCmd(vals formValues) tea.Cmd { return func() tea.Msg { - entries, _, skipped, err := wordlist.LoadWordlist(vals.wordlist, vals.domain) + entries, skipped, err := loadWordlist(vals) pool, poolErr := loadPool(vals) return wordlistLoadedMsg{cfg: vals, entries: entries, skipped: skipped, err: err, pool: pool, poolErr: poolErr} } } +// loadWordlist reads the file named on the form, or the bundled list when the +// field is empty, the way the CLI treats a missing -w (#288). +func loadWordlist(vals formValues) (entries []string, skipped int, err error) { + if vals.wordlist != "" { + entries, _, skipped, err = wordlist.LoadWordlist(vals.wordlist, vals.domain) + return entries, skipped, err + } + pr := wordlist.NewPreparer() + if err := wordlist.EachLine(strings.NewReader(data.Subdomains5k), pr.Add); err != nil { + return nil, 0, err + } + entries, _, skipped = pr.Prepared().For(vals.domain) + return entries, skipped, nil +} + // loadPool builds the resolver pool from the Resolver Pool file, as the CLI // does for -r (#195). It returns nil without a file, and in simulation mode, // which sends no DNS queries. @@ -239,6 +256,11 @@ func (m Model) beginScan(vals formValues, entries []string, skipped int, pool *d m.scanView.addMessage(dimStyle.Render("Note: the resolver pool is ignored in simulation mode, which sends no DNS queries")) } + // Same notice as the CLI's, so the TUI user knows which list ran (#288). + if vals.wordlist == "" { + m.scanView.addMessage(dimStyle.Render("Note: empty wordlist field: using the bundled top-5000 list (SecLists)")) + } + opts := vals.options(entries, seed) opts.Pool = pool if vals.maxDuration > 0 { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 9d47569..0b39d9f 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -260,6 +260,43 @@ func TestRunLoadsWordlistAsync(t *testing.T) { } } +// TestEmptyWordlistUsesBundledList covers #288: an installed binary has no +// examples/sample_wordlist.txt beside it, so an empty Wordlist field must scan +// with the bundled list, the way a missing -w does for the CLI. +func TestEmptyWordlistUsesBundledList(t *testing.T) { + redirectConfig(t) // beginScan saves the form values + m := Model{state: stateForm, form: newFormModel(savedConfig{})} + m.form.inputs[fieldDomain].SetValue("example.com") + m.form.inputs[fieldWordlist].SetValue("") + m.form.inputs[fieldOutput].SetValue("") // no output file + m.form.toggles[fieldSimulate] = true // simulate: the started scan sends no DNS traffic + + next, cmd := m.updateForm(keyCtrlR) + nm := next.(Model) + if nm.form.err != "" { + t.Fatalf("form error: %s", nm.form.err) + } + if cmd == nil { + t.Fatal("ctrl+r with an empty wordlist field did not start a load") + } + loaded, ok := cmd().(wordlistLoadedMsg) + if !ok || loaded.err != nil { + t.Fatalf("load result = %+v, want the bundled list", loaded) + } + if len(loaded.entries) < 1000 { + t.Fatalf("loaded %d entries, want the bundled top-5000 list", len(loaded.entries)) + } + started, _ := nm.updateForm(loaded) + sm := started.(Model) + defer sm.cancel() + if sm.state != stateScan { + t.Fatalf("state=%v after load, want the scan screen", sm.state) + } + if !strings.Contains(sm.scanView.View(), "bundled top-5000 list") { + t.Error("scan view does not say which wordlist ran") + } +} + // TestPoolErrorStaysOnForm covers #195: a resolver pool file that cannot be // used is reported on the form instead of starting the scan. func TestPoolErrorStaysOnForm(t *testing.T) {