Skip to content

Commit 13aef85

Browse files
authored
Merge pull request #37 from trustedsec/feat/target-flag
Add --target for scanning an address without a target file
2 parents 0d46224 + 1cd3a1e commit 13aef85

3 files changed

Lines changed: 315 additions & 35 deletions

File tree

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ If a previous scan's output is detected in `output_path`, the tool offers three
109109

110110
Re-answering the prompts rewrites `config.json`, **merging** rather than overwriting: any keys you added to the file by hand are preserved.
111111

112+
### Scanning a target without a file
113+
114+
To scan a single address (or a short list) without editing a target file, use `--target`:
115+
116+
```
117+
./spoonmap.py --target 10.0.0.5
118+
./spoonmap.py --target 10.0.0.0/24
119+
./spoonmap.py --target 10.0.0.5,10.0.1.0/24,10.0.2.1-10.0.2.9
120+
```
121+
122+
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.
123+
124+
`--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.
125+
112126
To resume an interrupted scan without any prompts, use the `--resume` flag:
113127

114128
```

‎spoonmap.py‎

Lines changed: 133 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -194,47 +194,134 @@ def _parse_target_ranges(filepath):
194194
line = line.split('#')[0].strip()
195195
if not line:
196196
continue
197-
# Standard CIDR or bare IP
198-
try:
199-
net = ipaddress.ip_network(line, strict=False)
200-
except ValueError:
201-
net = None
202-
if net is not None:
203-
if net.version != 4:
204-
print(_COLOR_ERROR
205-
+ f'Warning: {filepath} line {lineno}: '
206-
+ f'ignoring non-IPv4 target "{line}" '
207-
+ '— SpooNMAP scans IPv4 only.'
208-
+ _COLOR_RESET)
209-
continue
210-
ranges.append((int(net.network_address),
211-
int(net.broadcast_address)))
197+
bounds, error = _parse_range_line(line)
198+
if error == 'ipv6':
199+
print(_COLOR_ERROR
200+
+ f'Warning: {filepath} line {lineno}: '
201+
+ f'ignoring non-IPv4 target "{line}" '
202+
+ '— SpooNMAP scans IPv4 only.'
203+
+ _COLOR_RESET)
212204
continue
213-
# Range notation: A.B.C.D-E.F.G.H
214-
if '-' in line:
215-
parts = line.split('-', 1)
216-
try:
217-
start = int(ipaddress.IPv4Address(parts[0].strip()))
218-
end = int(ipaddress.IPv4Address(parts[1].strip()))
219-
if start <= end:
220-
ranges.append((start, end))
221-
continue
222-
except ValueError:
223-
pass
224-
# Netmask notation: A.B.C.D M.M.M.M
225-
parts = line.split()
226-
if len(parts) == 2:
227-
try:
228-
net = ipaddress.ip_network(f'{parts[0]}/{parts[1]}', strict=False)
229-
ranges.append((int(net.network_address),
230-
int(net.broadcast_address)))
231-
except ValueError:
232-
pass
205+
# An unparseable line is skipped silently, as it always has been:
206+
# masscan accepts forms this parser does not model, and this
207+
# function also runs on the exclusions file.
208+
if bounds is not None:
209+
ranges.append(bounds)
233210
except OSError:
234211
pass
235212
return ranges
236213

237214

215+
def _parse_range_line(line):
216+
"""Parse one masscan-style target line into inclusive IPv4 ``(start, end)``.
217+
218+
Returns a ``(bounds, error)`` pair: *bounds* is the tuple or None, and
219+
*error* is None, ``'ipv6'`` (parsed, but not IPv4), or ``'invalid'``.
220+
221+
Split out of _parse_target_ranges() so that --target validates a value
222+
against exactly the syntax a target *file* accepts. Two parsers would
223+
drift, and the divergence would show up as a CLI target the tool accepts
224+
at the command line and then silently fails to scan.
225+
"""
226+
# Standard CIDR or bare IP
227+
try:
228+
net = ipaddress.ip_network(line, strict=False)
229+
except ValueError:
230+
net = None
231+
if net is not None:
232+
if net.version != 4:
233+
return None, 'ipv6'
234+
return (int(net.network_address), int(net.broadcast_address)), None
235+
# Range notation: A.B.C.D-E.F.G.H
236+
if '-' in line:
237+
parts = line.split('-', 1)
238+
try:
239+
start = int(ipaddress.IPv4Address(parts[0].strip()))
240+
end = int(ipaddress.IPv4Address(parts[1].strip()))
241+
if start <= end:
242+
return (start, end), None
243+
return None, 'invalid'
244+
except ValueError:
245+
pass
246+
# Netmask notation: A.B.C.D M.M.M.M
247+
parts = line.split()
248+
if len(parts) == 2:
249+
try:
250+
net = ipaddress.ip_network(f'{parts[0]}/{parts[1]}', strict=False)
251+
return (int(net.network_address), int(net.broadcast_address)), None
252+
except ValueError:
253+
pass
254+
return None, 'invalid'
255+
256+
257+
def _parse_target_arg(value):
258+
"""Validate a ``--target`` value into a list of target lines.
259+
260+
Accepts a comma-separated list of anything a target file line may hold (bare
261+
IP, CIDR, ``A-B`` range, ``addr netmask``). Raises ValueError naming the
262+
offending token, so a typo is rejected before the scan starts rather than
263+
producing a clean run over an empty target set — a false negative with a
264+
report attached is this tool's worst outcome.
265+
"""
266+
tokens = [tok.strip() for tok in value.split(',')]
267+
tokens = [tok for tok in tokens if tok]
268+
if not tokens:
269+
raise ValueError('--target was given no address')
270+
for tok in tokens:
271+
bounds, error = _parse_range_line(tok)
272+
if error == 'ipv6':
273+
raise ValueError(f'--target "{tok}" is not IPv4 '
274+
'— SpooNMAP scans IPv4 only')
275+
if bounds is None:
276+
raise ValueError(f'--target "{tok}" is not a valid '
277+
'IP, CIDR, range, or address/netmask')
278+
return tokens
279+
280+
281+
def _cli_target_from_argv(argv):
282+
"""Return the ``--target`` value from *argv*, or None if not supplied.
283+
284+
Raises ValueError when the flag is present but its value is missing, so
285+
``--target --resume`` cannot silently consume the next flag as an address.
286+
"""
287+
if '--target' not in argv:
288+
return None
289+
idx = argv.index('--target')
290+
if idx + 1 >= len(argv) or argv[idx + 1].startswith('-'):
291+
raise ValueError('--target requires an address, e.g. --target 10.0.0.5')
292+
return argv[idx + 1]
293+
294+
295+
def _resolve_cli_target(argv):
296+
"""Return validated ``--target`` tokens from *argv*, or None if absent.
297+
298+
Exits non-zero on a malformed value. Lives outside main() so the reject
299+
path is testable: main() is an interactive entry point excluded from
300+
coverage, and "bad --target aborts the run" is exactly the behaviour that
301+
must not regress into a clean scan over an empty target set.
302+
"""
303+
try:
304+
value = _cli_target_from_argv(argv)
305+
return _parse_target_arg(value) if value else None
306+
except ValueError as exc:
307+
print(_COLOR_ERROR + f'ERROR: {exc}' + _COLOR_RESET)
308+
sys.exit(1)
309+
310+
311+
def _write_cli_target_file(tokens, output_path):
312+
"""Write ``--target`` addresses to a scope file and return its path.
313+
314+
Deliberately does *not* touch ranges.txt. That file is the operator's
315+
engagement scope input and is tracked in-repo as an empty template;
316+
overwriting it from a convenience flag would destroy scope data and leave no
317+
record of what was actually authorised. Written through _atomic_write() so
318+
a partial write can never be read as a complete target list.
319+
"""
320+
path = os.path.join(output_path, 'cli_targets.txt')
321+
_atomic_write(path, ''.join(f'{tok}\n' for tok in tokens))
322+
return path
323+
324+
238325
def _merge_ranges(ranges):
239326
"""Coalesce overlapping/adjacent ``(start, end)`` bounds, sorted by start."""
240327
out = []
@@ -5257,6 +5344,9 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates
52575344
if '--cleanup' in sys.argv:
52585345
_cleanup_cmd(dir_path) # prints result and exits
52595346
resume = '--resume' in sys.argv
5347+
# Validate --target up front: a bad address must abort before any
5348+
# prompting or scanning, not after.
5349+
cli_target_tokens = _resolve_cli_target(sys.argv)
52605350
config_loaded = False
52615351
config_generated = False
52625352
if os.path.exists(f'{dir_path}/config.json'):
@@ -5481,6 +5571,14 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates
54815571
) or output_path
54825572
os.makedirs(output_path, exist_ok=True)
54835573

5574+
# --target overrides both config.json and the prompt, and must be applied
5575+
# after output_path is known so the generated scope file lands with the
5576+
# rest of the run's output.
5577+
if cli_target_tokens:
5578+
target_file = _write_cli_target_file(cli_target_tokens, output_path)
5579+
print(_COLOR_INFO + f'Using --target: {", ".join(cli_target_tokens)}'
5580+
+ _COLOR_RESET)
5581+
54845582
if not target_file:
54855583
target_file = _prior_default(prior, 'target_file', output_path+"/ranges.txt")
54865584
while True:

‎tests/test_spoonmap.py‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
_CONFIG_GENERATED_KEY,
5656
_host_discovery,
5757
_atomic_write,
58+
_cli_target_from_argv,
59+
_parse_range_line,
60+
_parse_target_arg,
61+
_parse_target_ranges,
62+
_resolve_cli_target,
63+
_write_cli_target_file,
5864
_combine_live_hosts,
5965
_load_config,
6066
_read_config_file,
@@ -10662,3 +10668,165 @@ def test_keyboard_interrupt_during_masscan_reraised_after_nmap_join(self, tmp_pa
1066210668
with pytest.raises(KeyboardInterrupt):
1066310669
_internal_host_discovery(
1066410670
str(targets), str(disc), '1000', None, HOST_DISCOVERY_NMAP_THRESHOLD)
10671+
10672+
10673+
# ── --target CLI flag ─────────────────────────────────────────────────────────
10674+
10675+
class TestParseRangeLine:
10676+
"""The shared per-line parser behind both target files and --target."""
10677+
10678+
@pytest.mark.parametrize('line,expected', [
10679+
('10.0.0.5', (167772165, 167772165)),
10680+
('10.0.0.0/30', (167772160, 167772163)),
10681+
('10.0.0.1-10.0.0.3', (167772161, 167772163)),
10682+
('10.0.0.0 255.255.255.252', (167772160, 167772163)),
10683+
])
10684+
def test_accepted_forms(self, line, expected):
10685+
bounds, error = _parse_range_line(line)
10686+
assert error is None
10687+
assert bounds == expected
10688+
10689+
def test_ipv6_reported_separately(self):
10690+
"""IPv6 parses but is not IPv4, so it gets its own error code."""
10691+
bounds, error = _parse_range_line('2001:db8::/32')
10692+
assert bounds is None
10693+
assert error == 'ipv6'
10694+
10695+
@pytest.mark.parametrize('line', [
10696+
'nonsense',
10697+
'10.0.0.9-10.0.0.1', # reversed range
10698+
'10.0.0.0 999.999.999.999',
10699+
'10.0.0.300',
10700+
])
10701+
def test_rejected_forms(self, line):
10702+
bounds, error = _parse_range_line(line)
10703+
assert bounds is None
10704+
assert error == 'invalid'
10705+
10706+
def test_file_parser_still_skips_bad_lines_silently(self, tmp_path):
10707+
"""Extracting the helper must not change _parse_target_ranges behaviour:
10708+
a junk line is skipped without aborting the surrounding parse."""
10709+
f = tmp_path / 'ranges.txt'
10710+
f.write_text('10.0.0.0/30\nnonsense\n10.0.1.1\n')
10711+
assert _parse_target_ranges(str(f)) == [
10712+
(167772160, 167772163), (167772417, 167772417)]
10713+
10714+
def test_file_parser_warns_on_ipv6_with_line_number(self, tmp_path, capsys):
10715+
f = tmp_path / 'ranges.txt'
10716+
f.write_text('10.0.0.1\n2001:db8::1\n')
10717+
result = _parse_target_ranges(str(f))
10718+
out = capsys.readouterr().out
10719+
assert result == [(167772161, 167772161)]
10720+
assert 'line 2' in out
10721+
assert 'IPv4 only' in out
10722+
10723+
10724+
class TestParseTargetArg:
10725+
"""--target value validation."""
10726+
10727+
def test_single_ip(self):
10728+
assert _parse_target_arg('10.0.0.5') == ['10.0.0.5']
10729+
10730+
def test_comma_separated_with_whitespace(self):
10731+
assert _parse_target_arg(' 10.0.0.0/24 , 10.0.1.5 ') == [
10732+
'10.0.0.0/24', '10.0.1.5']
10733+
10734+
def test_range_and_netmask_forms(self):
10735+
assert _parse_target_arg('10.0.0.1-10.0.0.9') == ['10.0.0.1-10.0.0.9']
10736+
assert _parse_target_arg('10.0.0.0 255.255.0.0') == ['10.0.0.0 255.255.0.0']
10737+
10738+
def test_empty_value_rejected(self):
10739+
with pytest.raises(ValueError, match='no address'):
10740+
_parse_target_arg('')
10741+
10742+
def test_only_commas_rejected(self):
10743+
with pytest.raises(ValueError, match='no address'):
10744+
_parse_target_arg(' , , ')
10745+
10746+
def test_ipv6_rejected_by_name(self):
10747+
with pytest.raises(ValueError, match='not IPv4'):
10748+
_parse_target_arg('2001:db8::1')
10749+
10750+
def test_garbage_rejected_naming_the_token(self):
10751+
"""The message must name the offending token, not just fail."""
10752+
with pytest.raises(ValueError, match='nope'):
10753+
_parse_target_arg('10.0.0.1,nope')
10754+
10755+
10756+
class TestCliTargetFromArgv:
10757+
"""--target extraction from argv."""
10758+
10759+
def test_absent_returns_none(self):
10760+
assert _cli_target_from_argv(['spoonmap.py']) is None
10761+
10762+
def test_value_returned(self):
10763+
assert _cli_target_from_argv(
10764+
['spoonmap.py', '--target', '10.0.0.5']) == '10.0.0.5'
10765+
10766+
def test_value_missing_at_end_rejected(self):
10767+
with pytest.raises(ValueError, match='requires an address'):
10768+
_cli_target_from_argv(['spoonmap.py', '--target'])
10769+
10770+
def test_next_flag_not_consumed_as_value(self):
10771+
"""--target --resume must not treat --resume as an address."""
10772+
with pytest.raises(ValueError, match='requires an address'):
10773+
_cli_target_from_argv(['spoonmap.py', '--target', '--resume'])
10774+
10775+
def test_coexists_with_other_flags(self):
10776+
assert _cli_target_from_argv(
10777+
['spoonmap.py', '--resume', '--target', '10.0.0.5']) == '10.0.0.5'
10778+
10779+
10780+
class TestResolveCliTarget:
10781+
"""main()'s entry point: returns tokens or exits non-zero."""
10782+
10783+
def test_absent_returns_none(self):
10784+
assert _resolve_cli_target(['spoonmap.py']) is None
10785+
10786+
def test_valid_returns_tokens(self):
10787+
assert _resolve_cli_target(
10788+
['spoonmap.py', '--target', '10.0.0.0/24']) == ['10.0.0.0/24']
10789+
10790+
def test_invalid_exits_nonzero(self, capsys):
10791+
"""A bad address must abort the run, not scan an empty target set."""
10792+
with pytest.raises(SystemExit) as exc:
10793+
_resolve_cli_target(['spoonmap.py', '--target', 'nope'])
10794+
assert exc.value.code == 1
10795+
assert 'ERROR' in capsys.readouterr().out
10796+
10797+
def test_missing_value_exits_nonzero(self, capsys):
10798+
with pytest.raises(SystemExit) as exc:
10799+
_resolve_cli_target(['spoonmap.py', '--target'])
10800+
assert exc.value.code == 1
10801+
assert 'requires an address' in capsys.readouterr().out
10802+
10803+
10804+
class TestWriteCliTargetFile:
10805+
"""--target writes its own scope file and never touches ranges.txt."""
10806+
10807+
def test_writes_one_token_per_line(self, tmp_path):
10808+
path = _write_cli_target_file(['10.0.0.5', '10.0.1.0/24'], str(tmp_path))
10809+
assert Path(path).read_text() == '10.0.0.5\n10.0.1.0/24\n'
10810+
10811+
def test_lands_in_output_path(self, tmp_path):
10812+
path = _write_cli_target_file(['10.0.0.5'], str(tmp_path))
10813+
assert path == str(tmp_path / 'cli_targets.txt')
10814+
10815+
def test_does_not_touch_ranges_txt(self, tmp_path):
10816+
"""ranges.txt is the operator's scope input; a convenience flag must
10817+
never overwrite it (the 2021 PR #12 approach did)."""
10818+
ranges = tmp_path / 'ranges.txt'
10819+
ranges.write_text('192.168.50.0/24\n')
10820+
_write_cli_target_file(['10.0.0.5'], str(tmp_path))
10821+
assert ranges.read_text() == '192.168.50.0/24\n'
10822+
10823+
def test_output_is_parseable_by_the_file_parser(self, tmp_path):
10824+
"""Round-trip: what --target writes must be what the scan can read."""
10825+
path = _write_cli_target_file(
10826+
['10.0.0.0/30', '10.0.1.1-10.0.1.3'], str(tmp_path))
10827+
assert _parse_target_ranges(path) == [
10828+
(167772160, 167772163), (167772417, 167772419)]
10829+
10830+
def test_atomic_leaves_no_temp_file(self, tmp_path):
10831+
_write_cli_target_file(['10.0.0.5'], str(tmp_path))
10832+
assert [p.name for p in tmp_path.iterdir()] == ['cli_targets.txt']

0 commit comments

Comments
 (0)