From d00a09a9f73c89066ba9e01f276b9c8958836dad Mon Sep 17 00:00:00 2001 From: kragent66-glitch Date: Tue, 6 Oct 2026 01:57:18 +0000 Subject: [PATCH] fix(wordlist-gen): parse flags interspersed and reject positionals (#120) wordlist-gen called fs.Parse directly, which stops at the first non-flag argument. 'wordlist-gen acme.com -o out.txt' therefore silently ignored both acme.com and the -o after it and wrote ./wordlist.txt. Use the same interspersed parsing as main.go, reject leftover positionals with a -domain hint (exit 2), and return the usage code (2) for an invalid -combine value or an empty -o instead of 1. --- CHANGELOG.md | 1 + tools/wordlist-gen.go | 62 ++++++++++++++++++++++++++++++++++++-- tools/wordlist-gen_test.go | 61 +++++++++++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8766fb..092d77f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``; `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). diff --git a/tools/wordlist-gen.go b/tools/wordlist-gen.go index d724c37..4b9efc9 100644 --- a/tools/wordlist-gen.go +++ b/tools/wordlist-gen.go @@ -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)) } @@ -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}) diff --git a/tools/wordlist-gen_test.go b/tools/wordlist-gen_test.go index f070da7..850f6e6 100644 --- a/tools/wordlist-gen_test.go +++ b/tools/wordlist-gen_test.go @@ -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()) + } +}