From 1cd3a1e08d7a5523116789a53f44797e78463050 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 11:21:32 -0400 Subject: [PATCH] feat: add --target for scanning an address without a target file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4. Requested in Jan 2021 and attempted in PR #12, which could not be applied: its fork is gone and the code it patched has since been restructured. Its approach also overwrote ranges.txt in place, unvalidated, from a prompt that fired even in config-file mode where the written file is never read. --target accepts a comma-separated list of anything a target file line accepts (bare IP, CIDR, A-B range, address netmask) and takes precedence over both config.json's target_file and the interactive prompt, so it works in fully non-interactive runs. Three properties the earlier attempt lacked: - ranges.txt is never touched. It is the operator's engagement scope input, tracked in-repo as an empty template. Addresses go to cli_targets.txt in output_path via _atomic_write(), which also leaves a record of what each run actually targeted. - The value is validated before anything else happens, and a bad entry exits non-zero naming the token. Accepting a typo would produce a clean run over an empty target set — a false negative with a report attached. - Validation reuses the target-file parser. _parse_range_line() is extracted from _parse_target_ranges() so the CLI cannot accept syntax the scan then fails to read; the extraction is behaviour-preserving, including the silent skip of unparseable lines and the IPv6 file/line warning. _resolve_cli_target() holds the argv-to-tokens glue so the reject path is tested: main() is an interactive entry point excluded from coverage. 939 tests pass (32 new), 100% coverage. Co-Authored-By: Claude Fable 5 --- README.md | 14 ++++ spoonmap.py | 168 ++++++++++++++++++++++++++++++++--------- tests/test_spoonmap.py | 168 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e103314..8e6af3f 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,20 @@ If a previous scan's output is detected in `output_path`, the tool offers three Re-answering the prompts rewrites `config.json`, **merging** rather than overwriting: any keys you added to the file by hand are preserved. +### Scanning a target without a file + +To scan a single address (or a short list) without editing a target file, use `--target`: + +``` +./spoonmap.py --target 10.0.0.5 +./spoonmap.py --target 10.0.0.0/24 +./spoonmap.py --target 10.0.0.5,10.0.1.0/24,10.0.2.1-10.0.2.9 +``` + +The value accepts anything a target file line accepts — bare IP, CIDR, `A-B` range, or `address netmask` — comma-separated. It is validated before the scan starts: a malformed or IPv6 address aborts with a non-zero exit naming the offending entry, rather than running cleanly over an empty target set. + +`--target` takes precedence over both `config.json`'s `target_file` and the interactive prompt, so it also works in otherwise fully non-interactive runs. The addresses are written to `cli_targets.txt` in `output_path`; **`ranges.txt` is never modified**, so your engagement scope file stays intact and there is a record on disk of what each run actually targeted. + To resume an interrupted scan without any prompts, use the `--resume` flag: ``` diff --git a/spoonmap.py b/spoonmap.py index 80baa98..954ac87 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -194,47 +194,134 @@ def _parse_target_ranges(filepath): line = line.split('#')[0].strip() if not line: continue - # Standard CIDR or bare IP - try: - net = ipaddress.ip_network(line, strict=False) - except ValueError: - net = None - if net is not None: - if net.version != 4: - print(_COLOR_ERROR - + f'Warning: {filepath} line {lineno}: ' - + f'ignoring non-IPv4 target "{line}" ' - + '— SpooNMAP scans IPv4 only.' - + _COLOR_RESET) - continue - ranges.append((int(net.network_address), - int(net.broadcast_address))) + bounds, error = _parse_range_line(line) + if error == 'ipv6': + print(_COLOR_ERROR + + f'Warning: {filepath} line {lineno}: ' + + f'ignoring non-IPv4 target "{line}" ' + + '— SpooNMAP scans IPv4 only.' + + _COLOR_RESET) continue - # Range notation: A.B.C.D-E.F.G.H - if '-' in line: - parts = line.split('-', 1) - try: - start = int(ipaddress.IPv4Address(parts[0].strip())) - end = int(ipaddress.IPv4Address(parts[1].strip())) - if start <= end: - ranges.append((start, end)) - continue - except ValueError: - pass - # Netmask notation: A.B.C.D M.M.M.M - parts = line.split() - if len(parts) == 2: - try: - net = ipaddress.ip_network(f'{parts[0]}/{parts[1]}', strict=False) - ranges.append((int(net.network_address), - int(net.broadcast_address))) - except ValueError: - pass + # An unparseable line is skipped silently, as it always has been: + # masscan accepts forms this parser does not model, and this + # function also runs on the exclusions file. + if bounds is not None: + ranges.append(bounds) except OSError: pass return ranges +def _parse_range_line(line): + """Parse one masscan-style target line into inclusive IPv4 ``(start, end)``. + + Returns a ``(bounds, error)`` pair: *bounds* is the tuple or None, and + *error* is None, ``'ipv6'`` (parsed, but not IPv4), or ``'invalid'``. + + Split out of _parse_target_ranges() so that --target validates a value + against exactly the syntax a target *file* accepts. Two parsers would + drift, and the divergence would show up as a CLI target the tool accepts + at the command line and then silently fails to scan. + """ + # Standard CIDR or bare IP + try: + net = ipaddress.ip_network(line, strict=False) + except ValueError: + net = None + if net is not None: + if net.version != 4: + return None, 'ipv6' + return (int(net.network_address), int(net.broadcast_address)), None + # Range notation: A.B.C.D-E.F.G.H + if '-' in line: + parts = line.split('-', 1) + try: + start = int(ipaddress.IPv4Address(parts[0].strip())) + end = int(ipaddress.IPv4Address(parts[1].strip())) + if start <= end: + return (start, end), None + return None, 'invalid' + except ValueError: + pass + # Netmask notation: A.B.C.D M.M.M.M + parts = line.split() + if len(parts) == 2: + try: + net = ipaddress.ip_network(f'{parts[0]}/{parts[1]}', strict=False) + return (int(net.network_address), int(net.broadcast_address)), None + except ValueError: + pass + return None, 'invalid' + + +def _parse_target_arg(value): + """Validate a ``--target`` value into a list of target lines. + + Accepts a comma-separated list of anything a target file line may hold (bare + IP, CIDR, ``A-B`` range, ``addr netmask``). Raises ValueError naming the + offending token, so a typo is rejected before the scan starts rather than + producing a clean run over an empty target set — a false negative with a + report attached is this tool's worst outcome. + """ + tokens = [tok.strip() for tok in value.split(',')] + tokens = [tok for tok in tokens if tok] + if not tokens: + raise ValueError('--target was given no address') + for tok in tokens: + bounds, error = _parse_range_line(tok) + if error == 'ipv6': + raise ValueError(f'--target "{tok}" is not IPv4 ' + '— SpooNMAP scans IPv4 only') + if bounds is None: + raise ValueError(f'--target "{tok}" is not a valid ' + 'IP, CIDR, range, or address/netmask') + return tokens + + +def _cli_target_from_argv(argv): + """Return the ``--target`` value from *argv*, or None if not supplied. + + Raises ValueError when the flag is present but its value is missing, so + ``--target --resume`` cannot silently consume the next flag as an address. + """ + if '--target' not in argv: + return None + idx = argv.index('--target') + if idx + 1 >= len(argv) or argv[idx + 1].startswith('-'): + raise ValueError('--target requires an address, e.g. --target 10.0.0.5') + return argv[idx + 1] + + +def _resolve_cli_target(argv): + """Return validated ``--target`` tokens from *argv*, or None if absent. + + Exits non-zero on a malformed value. Lives outside main() so the reject + path is testable: main() is an interactive entry point excluded from + coverage, and "bad --target aborts the run" is exactly the behaviour that + must not regress into a clean scan over an empty target set. + """ + try: + value = _cli_target_from_argv(argv) + return _parse_target_arg(value) if value else None + except ValueError as exc: + print(_COLOR_ERROR + f'ERROR: {exc}' + _COLOR_RESET) + sys.exit(1) + + +def _write_cli_target_file(tokens, output_path): + """Write ``--target`` addresses to a scope file and return its path. + + Deliberately does *not* touch ranges.txt. That file is the operator's + engagement scope input and is tracked in-repo as an empty template; + overwriting it from a convenience flag would destroy scope data and leave no + record of what was actually authorised. Written through _atomic_write() so + a partial write can never be read as a complete target list. + """ + path = os.path.join(output_path, 'cli_targets.txt') + _atomic_write(path, ''.join(f'{tok}\n' for tok in tokens)) + return path + + def _merge_ranges(ranges): """Coalesce overlapping/adjacent ``(start, end)`` bounds, sorted by start.""" out = [] @@ -5257,6 +5344,9 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates if '--cleanup' in sys.argv: _cleanup_cmd(dir_path) # prints result and exits resume = '--resume' in sys.argv + # Validate --target up front: a bad address must abort before any + # prompting or scanning, not after. + cli_target_tokens = _resolve_cli_target(sys.argv) config_loaded = False config_generated = False if os.path.exists(f'{dir_path}/config.json'): @@ -5481,6 +5571,14 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates ) or output_path os.makedirs(output_path, exist_ok=True) + # --target overrides both config.json and the prompt, and must be applied + # after output_path is known so the generated scope file lands with the + # rest of the run's output. + if cli_target_tokens: + target_file = _write_cli_target_file(cli_target_tokens, output_path) + print(_COLOR_INFO + f'Using --target: {", ".join(cli_target_tokens)}' + + _COLOR_RESET) + if not target_file: target_file = _prior_default(prior, 'target_file', output_path+"/ranges.txt") while True: diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 01b461d..aa0d67f 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -55,6 +55,12 @@ _CONFIG_GENERATED_KEY, _host_discovery, _atomic_write, + _cli_target_from_argv, + _parse_range_line, + _parse_target_arg, + _parse_target_ranges, + _resolve_cli_target, + _write_cli_target_file, _combine_live_hosts, _load_config, _read_config_file, @@ -10662,3 +10668,165 @@ def test_keyboard_interrupt_during_masscan_reraised_after_nmap_join(self, tmp_pa with pytest.raises(KeyboardInterrupt): _internal_host_discovery( str(targets), str(disc), '1000', None, HOST_DISCOVERY_NMAP_THRESHOLD) + + +# ── --target CLI flag ───────────────────────────────────────────────────────── + +class TestParseRangeLine: + """The shared per-line parser behind both target files and --target.""" + + @pytest.mark.parametrize('line,expected', [ + ('10.0.0.5', (167772165, 167772165)), + ('10.0.0.0/30', (167772160, 167772163)), + ('10.0.0.1-10.0.0.3', (167772161, 167772163)), + ('10.0.0.0 255.255.255.252', (167772160, 167772163)), + ]) + def test_accepted_forms(self, line, expected): + bounds, error = _parse_range_line(line) + assert error is None + assert bounds == expected + + def test_ipv6_reported_separately(self): + """IPv6 parses but is not IPv4, so it gets its own error code.""" + bounds, error = _parse_range_line('2001:db8::/32') + assert bounds is None + assert error == 'ipv6' + + @pytest.mark.parametrize('line', [ + 'nonsense', + '10.0.0.9-10.0.0.1', # reversed range + '10.0.0.0 999.999.999.999', + '10.0.0.300', + ]) + def test_rejected_forms(self, line): + bounds, error = _parse_range_line(line) + assert bounds is None + assert error == 'invalid' + + def test_file_parser_still_skips_bad_lines_silently(self, tmp_path): + """Extracting the helper must not change _parse_target_ranges behaviour: + a junk line is skipped without aborting the surrounding parse.""" + f = tmp_path / 'ranges.txt' + f.write_text('10.0.0.0/30\nnonsense\n10.0.1.1\n') + assert _parse_target_ranges(str(f)) == [ + (167772160, 167772163), (167772417, 167772417)] + + def test_file_parser_warns_on_ipv6_with_line_number(self, tmp_path, capsys): + f = tmp_path / 'ranges.txt' + f.write_text('10.0.0.1\n2001:db8::1\n') + result = _parse_target_ranges(str(f)) + out = capsys.readouterr().out + assert result == [(167772161, 167772161)] + assert 'line 2' in out + assert 'IPv4 only' in out + + +class TestParseTargetArg: + """--target value validation.""" + + def test_single_ip(self): + assert _parse_target_arg('10.0.0.5') == ['10.0.0.5'] + + def test_comma_separated_with_whitespace(self): + assert _parse_target_arg(' 10.0.0.0/24 , 10.0.1.5 ') == [ + '10.0.0.0/24', '10.0.1.5'] + + def test_range_and_netmask_forms(self): + assert _parse_target_arg('10.0.0.1-10.0.0.9') == ['10.0.0.1-10.0.0.9'] + assert _parse_target_arg('10.0.0.0 255.255.0.0') == ['10.0.0.0 255.255.0.0'] + + def test_empty_value_rejected(self): + with pytest.raises(ValueError, match='no address'): + _parse_target_arg('') + + def test_only_commas_rejected(self): + with pytest.raises(ValueError, match='no address'): + _parse_target_arg(' , , ') + + def test_ipv6_rejected_by_name(self): + with pytest.raises(ValueError, match='not IPv4'): + _parse_target_arg('2001:db8::1') + + def test_garbage_rejected_naming_the_token(self): + """The message must name the offending token, not just fail.""" + with pytest.raises(ValueError, match='nope'): + _parse_target_arg('10.0.0.1,nope') + + +class TestCliTargetFromArgv: + """--target extraction from argv.""" + + def test_absent_returns_none(self): + assert _cli_target_from_argv(['spoonmap.py']) is None + + def test_value_returned(self): + assert _cli_target_from_argv( + ['spoonmap.py', '--target', '10.0.0.5']) == '10.0.0.5' + + def test_value_missing_at_end_rejected(self): + with pytest.raises(ValueError, match='requires an address'): + _cli_target_from_argv(['spoonmap.py', '--target']) + + def test_next_flag_not_consumed_as_value(self): + """--target --resume must not treat --resume as an address.""" + with pytest.raises(ValueError, match='requires an address'): + _cli_target_from_argv(['spoonmap.py', '--target', '--resume']) + + def test_coexists_with_other_flags(self): + assert _cli_target_from_argv( + ['spoonmap.py', '--resume', '--target', '10.0.0.5']) == '10.0.0.5' + + +class TestResolveCliTarget: + """main()'s entry point: returns tokens or exits non-zero.""" + + def test_absent_returns_none(self): + assert _resolve_cli_target(['spoonmap.py']) is None + + def test_valid_returns_tokens(self): + assert _resolve_cli_target( + ['spoonmap.py', '--target', '10.0.0.0/24']) == ['10.0.0.0/24'] + + def test_invalid_exits_nonzero(self, capsys): + """A bad address must abort the run, not scan an empty target set.""" + with pytest.raises(SystemExit) as exc: + _resolve_cli_target(['spoonmap.py', '--target', 'nope']) + assert exc.value.code == 1 + assert 'ERROR' in capsys.readouterr().out + + def test_missing_value_exits_nonzero(self, capsys): + with pytest.raises(SystemExit) as exc: + _resolve_cli_target(['spoonmap.py', '--target']) + assert exc.value.code == 1 + assert 'requires an address' in capsys.readouterr().out + + +class TestWriteCliTargetFile: + """--target writes its own scope file and never touches ranges.txt.""" + + def test_writes_one_token_per_line(self, tmp_path): + path = _write_cli_target_file(['10.0.0.5', '10.0.1.0/24'], str(tmp_path)) + assert Path(path).read_text() == '10.0.0.5\n10.0.1.0/24\n' + + def test_lands_in_output_path(self, tmp_path): + path = _write_cli_target_file(['10.0.0.5'], str(tmp_path)) + assert path == str(tmp_path / 'cli_targets.txt') + + def test_does_not_touch_ranges_txt(self, tmp_path): + """ranges.txt is the operator's scope input; a convenience flag must + never overwrite it (the 2021 PR #12 approach did).""" + ranges = tmp_path / 'ranges.txt' + ranges.write_text('192.168.50.0/24\n') + _write_cli_target_file(['10.0.0.5'], str(tmp_path)) + assert ranges.read_text() == '192.168.50.0/24\n' + + def test_output_is_parseable_by_the_file_parser(self, tmp_path): + """Round-trip: what --target writes must be what the scan can read.""" + path = _write_cli_target_file( + ['10.0.0.0/30', '10.0.1.1-10.0.1.3'], str(tmp_path)) + assert _parse_target_ranges(path) == [ + (167772160, 167772163), (167772417, 167772419)] + + def test_atomic_leaves_no_temp_file(self, tmp_path): + _write_cli_target_file(['10.0.0.5'], str(tmp_path)) + assert [p.name for p in tmp_path.iterdir()] == ['cli_targets.txt']