Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `.<name>.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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ No flags required. Fill in the form and press `ctrl+r` to start scanning. Last-u

<img src="docs/assets/tui-form.png" alt="subenum interactive TUI" width="640"/>

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

Expand Down
7 changes: 3 additions & 4 deletions internal/tui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 23 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading