Skip to content
Merged
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 @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Lab mode can no longer send queries off the machine: `-r`, `-dns-server` or `-simulate` from a `SUBENUM_*` variable or the config file is refused with `-simulate-zone` just like the flag, naming where the value came from. Previously `SUBENUM_R` sent every lab query to the listed resolvers under a "no traffic leaves this machine" banner (#106).
- Config and environment defaults: `-h`, `-version`, `-print-config` and `-tui` work despite a bad value; every bad value is reported at once; config values must be strings, numbers or booleans (`exclude` and `type` also take a list) instead of being stringified into values such as `<nil>`; `resume` is refused in the config file; `-attempts` and `-retries` from different sources resolve by precedence instead of conflicting; and `SUBENUM_PROGRESS` counts as an explicit `-progress` (#116).
- An unreadable `-r` file exits 1 like every other unreadable input file (a malformed one is still a usage error, 2), and argument errors are reported before the simulation banner is printed or any resolver list is read (#120).
- `tools/wordlist-gen` parses flags that appear after a positional the same way `subenum` does, instead of silently ignoring both: a leftover argument is now a usage error (2) that points at `-domain`, and an invalid `-combine` value or an empty `-o` exits 2 instead of 1 (#120).
- A wildcard check whose probes time out or SERVFAIL is no longer read as "no wildcard". Probes are retried up to `-attempts`; if still inconclusive, the scan aborts (scans without filtering under `-force`, with a warning), and in recursive mode the branch is skipped and not cached. Previously a wildcard zone with flaky probes reported every wordlist entry as a hit (#47).
- The dispatcher no longer does quadratic queue copying, so large wordlists scale linearly (simulated 2M entries: 19.4s down to 4.6s). Candidate names are generated lazily from the wordlist instead of being built up front, and a found parent is expanded in the dispatcher instead of pushing every child through a channel. `make bench` runs a 1M-entry benchmark (#49).
- `-o` results files are written to a temporary file beside the target and renamed into place only once a scan finishes (including an interrupted one with partial results), in both the CLI and the TUI. A run that fails before scanning (preflight failure, wildcard abort, recursion refusal, Ctrl+C during preflight) no longer truncates an existing results file (#52).
Expand Down
62 changes: 59 additions & 3 deletions tools/wordlist-gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,54 @@ func validateCombine(combine string) error {
return nil
}

// parseInterspersed parses flags that appear anywhere in args. The standard flag
// package stops at the first non-flag argument, which silently dropped every flag
// written after the domain (#29). Arguments after a "--" terminator are all
// treated as positionals.
func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) {
var positionals []string
for {
before := len(args)
if err := fs.Parse(args); err != nil {
return nil, err
}
rest := fs.Args()
if len(rest) == 0 {
return positionals, nil
}
consumed := args[:before-len(rest)]
if endsWithTerminator(fs, consumed) {
return append(positionals, rest...), nil
}
positionals = append(positionals, rest[0])
args = rest[1:]
}
}

// endsWithTerminator reports whether the parsed arguments ended at a "--"
// terminator, as opposed to "--" being the value of a flag such as "-o --".
func endsWithTerminator(fs *flag.FlagSet, consumed []string) bool {
n := len(consumed)
if n == 0 || consumed[n-1] != "--" {
return false
}
if n < 2 {
return true
}
prev := consumed[n-2]
if !strings.HasPrefix(prev, "-") || strings.Contains(prev, "=") {
return true
}
fl := fs.Lookup(strings.TrimLeft(prev, "-"))
if fl == nil {
return true
}
if b, ok := fl.Value.(interface{ IsBoolFlag() bool }); ok && b.IsBoolFlag() {
return true // bool flags take no separate value
}
return false // "--" was the value of the preceding flag
}

func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
Expand All @@ -153,20 +201,28 @@ func run(args []string, stdout, stderr io.Writer) int {
addCommon := fs.Bool("common", true, "Add common subdomain prefixes")
domainInfo := fs.String("domain", "", "Domain to extract potential subdomains from (e.g., company-name.com -> company, name)")
verbose := fs.Bool("v", false, "Print every generated entry")
if err := fs.Parse(args); err != nil {

positionals, err := parseInterspersed(fs, args)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}

// If any positionals remain, that's a usage error
if len(positionals) > 0 {
_, _ = fmt.Fprintf(stderr, "Error: unexpected argument %q; to take subdomains from a domain, pass -domain %s\n", positionals[0], positionals[0])
return 2
}

if *outputFile == "" {
_, _ = fmt.Fprintln(stderr, "Error: output file cannot be empty")
return 1
return 2
}
if err := validateCombine(*combineWith); err != nil {
_, _ = fmt.Fprintf(stderr, "Error: %v\n", err)
return 1
return 2
}

words := generate(options{common: *addCommon, domain: *domainInfo, combine: *combineWith})
Expand Down
61 changes: 59 additions & 2 deletions tools/wordlist-gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,65 @@ func TestRunGuards(t *testing.T) {

for _, bad := range []string{"shop!", "a b", "x.y"} {
stderr.Reset()
if code := run([]string{"-combine", "dev," + bad, "-o", filepath.Join(dir, "c.txt")}, io.Discard, &stderr); code != 1 || !strings.Contains(stderr.String(), "invalid -combine prefix") {
t.Errorf("-combine %q: exit %d, stderr %q", bad, code, stderr.String())
if code := run([]string{"-combine", "dev," + bad, "-o", filepath.Join(dir, "c.txt")}, io.Discard, &stderr); code != 2 || !strings.Contains(stderr.String(), "invalid -combine prefix") {
t.Errorf("-combine %q: exit %d, stderr %q; want 2", bad, code, stderr.String())
}
}
}

// TestRunRejectsPositionalAndAcceptsDomain tests issue #120: a leftover
// positional is a usage error that points at -domain, while -domain itself
// works and accepts flags written after it.
func TestRunRejectsPositionalAndAcceptsDomain(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "wl.txt")
var stderr strings.Builder

// Without -domain, a positional should cause an error mentioning -domain
if code := run([]string{"acme.com"}, io.Discard, &stderr); code != 2 || !strings.Contains(stderr.String(), "-domain") {
t.Errorf("positional without -domain: exit %d, stderr %q; want 2 with -domain mentioned", code, stderr.String())
}

// -domain takes acme.com as its value; -o (written after it) is still parsed.
if code := run([]string{"-domain", "acme.com", "-o", out}, io.Discard, &stderr); code != 0 {
t.Errorf("valid -domain with trailing flags: exit %d, stderr %q; want 0", code, stderr.String())
}

// Check that the file was written
content, err := os.ReadFile(out)
if err != nil {
t.Fatalf("could not read output file: %v", err)
}
if !strings.Contains(string(content), "www\n") {
t.Errorf("expected 'www' in output; got %q", string(content))
}
}

// TestRunEmptyOutputFile tests issue #120: empty -o should exit 2.
func TestRunEmptyOutputFile(t *testing.T) {
var stderr strings.Builder
if code := run([]string{"-o", ""}, io.Discard, &stderr); code != 2 || !strings.Contains(stderr.String(), "output file cannot be empty") {
t.Errorf("empty -o: exit %d, stderr %q; want 2 with error message", code, stderr.String())
}
}

// TestRunInvalidCombinePrefix tests issue #120: invalid -combine should exit 2.
func TestRunInvalidCombinePrefix(t *testing.T) {
dir := t.TempDir()
var stderr strings.Builder
if code := run([]string{"-combine", "bad!.x", "-o", filepath.Join(dir, "c.txt")}, io.Discard, &stderr); code != 2 || !strings.Contains(stderr.String(), "invalid -combine prefix") {
t.Errorf("invalid -combine prefix: exit %d, stderr %q; want 2 with error message", code, stderr.String())
}
}

// TestRunTerminator tests that -- terminator works correctly.
func TestRunTerminator(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "wl.txt")
var stderr strings.Builder

// With --, all subsequent args are positionals and cause an error
if code := run([]string{"-domain", "acme.com", "--", "-o", out}, io.Discard, &stderr); code != 2 || !strings.Contains(stderr.String(), "unexpected argument") {
t.Errorf("-- terminator: exit %d, stderr %q; want 2", code, stderr.String())
}
}