Skip to content

Commit bfa73f5

Browse files
author
r0BIT
committed
feat: Add CIDR notation support for target specification
- Add expand_cidr() and is_cidr() functions to helpers.py - Update normalize_targets() to expand CIDR ranges to individual IPs - Consolidate duplicate IP check functions (remove _is_ip_address, use is_ipv4) - Supports mixed input: CIDRs, IPs, hostnames in CLI and targets file - Add comprehensive tests for CIDR expansion
1 parent 2676155 commit bfa73f5

File tree

4 files changed

+170
-21
lines changed

4 files changed

+170
-21
lines changed

taskhound/engine/online.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@
2626
from ..parsers.task_xml import parse_task_xml
2727
from ..smb.connection import (
2828
_dns_ptr_lookup,
29-
_is_ip_address,
3029
get_server_fqdn,
3130
get_server_sid,
3231
smb_connect,
3332
smb_login,
3433
smb_negotiate,
3534
)
35+
from ..utils.helpers import is_ipv4
3636
from ..smb.credguard import check_credential_guard
3737
from ..smb.task_rpc import CredentialStatus, TaskRunInfo, TaskSchedulerRPC
3838
from ..smb.tasks import crawl_tasks, smb_listdir
@@ -196,7 +196,7 @@ def process_target(
196196
if not discovered_hostname:
197197
# SMBv3 doesn't populate server name during negotiate
198198
# Try DNS reverse lookup as fallback
199-
if _is_ip_address(target):
199+
if is_ipv4(target):
200200
# Try DC first, then system DNS
201201
if dc_ip:
202202
discovered_hostname = _dns_ptr_lookup(target, nameserver=dc_ip, use_tcp=dns_tcp)

taskhound/smb/connection.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from impacket.smbconnection import SMBConnection
1313

14+
from ..utils.helpers import is_ipv4
15+
1416

1517
def _parse_hashes(password: str):
1618
# Parse a provided password or NTLM hash string.
@@ -379,7 +381,7 @@ def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip:
379381
FQDN string (e.g., "DC.badsuccessor.lab") or "UNKNOWN_HOST"
380382
"""
381383
# Method 0: If target already looks like an FQDN (has dots and isn't an IP), use it
382-
if target_ip and "." in target_ip and not _is_ip_address(target_ip):
384+
if target_ip and "." in target_ip and not is_ipv4(target_ip):
383385
return target_ip
384386

385387
try:
@@ -404,7 +406,7 @@ def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip:
404406
server_name = None
405407

406408
# Method 3 & 4: DNS fallback - try to resolve via PTR record
407-
if target_ip and _is_ip_address(target_ip):
409+
if target_ip and is_ipv4(target_ip):
408410
# Method 3: Try using DC as DNS server (if provided)
409411
if dc_ip:
410412
fqdn = _dns_ptr_lookup(target_ip, nameserver=dc_ip, use_tcp=dns_tcp)
@@ -423,17 +425,6 @@ def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip:
423425
return "UNKNOWN_HOST"
424426

425427

426-
def _is_ip_address(hostname: str) -> bool:
427-
"""Check if a string is an IPv4 address."""
428-
parts = hostname.split(".")
429-
if len(parts) == 4:
430-
try:
431-
return all(0 <= int(p) <= 255 for p in parts)
432-
except (ValueError, TypeError):
433-
return False
434-
return False
435-
436-
437428
def _dns_ptr_lookup(ip: str, nameserver: Optional[str] = None, use_tcp: bool = False) -> Optional[str]:
438429
"""
439430
Perform DNS PTR lookup to resolve IP to hostname.

taskhound/utils/helpers.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# This module contains simple utilities for classifying RunAs values,
44
# normalizing target hostnames, and the ASCII banner used by the CLI.
55

6+
import ipaddress
67
import re
78
import uuid
89
from typing import List, Tuple
@@ -41,17 +42,67 @@ def parse_ntlm_hashes(hashes: str) -> Tuple[str, str]:
4142
return "", hashes
4243

4344

45+
def expand_cidr(cidr: str) -> List[str]:
46+
"""Expand a CIDR notation to a list of IP addresses.
47+
48+
Args:
49+
cidr: CIDR notation string (e.g., '192.168.1.0/24')
50+
51+
Returns:
52+
List of IP address strings (excludes network and broadcast for /31+)
53+
54+
Raises:
55+
ValueError: If the CIDR notation is invalid
56+
"""
57+
try:
58+
network = ipaddress.ip_network(cidr, strict=False)
59+
# For /31 and /32, return all addresses (point-to-point or single host)
60+
# For larger networks, exclude network and broadcast addresses
61+
if network.prefixlen >= 31:
62+
return [str(ip) for ip in network.hosts()] or [str(network.network_address)]
63+
return [str(ip) for ip in network.hosts()]
64+
except ValueError as e:
65+
raise ValueError(f"Invalid CIDR notation '{cidr}': {e}")
66+
67+
68+
def is_cidr(target: str) -> bool:
69+
"""Check if a string is CIDR notation (e.g., '192.168.1.0/24')."""
70+
if "/" not in target:
71+
return False
72+
try:
73+
ipaddress.ip_network(target, strict=False)
74+
return True
75+
except ValueError:
76+
return False
77+
78+
4479
def normalize_targets(targets: List[str], domain: str) -> List[str]:
45-
# Normalize a list of targets: keep IPs, append domain for short hostnames.
46-
#
47-
# Empty lines are ignored. This mirrors the behavior expected by the CLI
48-
# where users may pass bare hostnames that need to be FQDN-ified.
80+
"""Normalize a list of targets: expand CIDRs, keep IPs, append domain for short hostnames.
81+
82+
Args:
83+
targets: List of target strings (IPs, hostnames, FQDNs, or CIDR notation)
84+
domain: Domain to append to short hostnames
85+
86+
Returns:
87+
Normalized list of targets with CIDRs expanded to individual IPs
88+
89+
Empty lines are ignored. This mirrors the behavior expected by the CLI
90+
where users may pass bare hostnames that need to be FQDN-ified.
91+
"""
4992
out = []
5093
for t in targets:
5194
t = t.strip()
5295
if not t:
5396
continue
54-
if is_ipv4(t):
97+
# Check for CIDR notation first
98+
if is_cidr(t):
99+
try:
100+
expanded = expand_cidr(t)
101+
out.extend(expanded)
102+
except ValueError:
103+
# Invalid CIDR, treat as hostname
104+
out.append(t)
105+
elif is_ipv4(t):
55106
out.append(t)
56107
else:
57108
# append domain if it's a short host (no dot)

tests/test_helpers_extended.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44
Tests cover:
55
- is_ipv4 function
66
- parse_ntlm_hashes function
7-
- normalize_targets function
7+
- normalize_targets function (including CIDR expansion)
8+
- expand_cidr function
9+
- is_cidr function
810
- sanitize_json_string function
911
"""
1012

1113
import pytest
1214

1315
from taskhound.utils.helpers import (
1416
is_ipv4,
17+
is_cidr,
18+
expand_cidr,
1519
parse_ntlm_hashes,
1620
normalize_targets,
1721
sanitize_json_string,
@@ -161,6 +165,109 @@ def test_empty_list(self):
161165

162166
assert result == []
163167

168+
def test_cidr_expansion(self):
169+
"""Should expand CIDR notation to individual IPs"""
170+
targets = ["192.168.1.0/30"]
171+
172+
result = normalize_targets(targets, "example.com")
173+
174+
# /30 has 4 IPs, but .hosts() excludes network (.0) and broadcast (.3)
175+
assert result == ["192.168.1.1", "192.168.1.2"]
176+
177+
def test_cidr_with_mixed_targets(self):
178+
"""Should handle CIDR mixed with other target types"""
179+
targets = ["10.0.0.0/30", "DC01", "192.168.1.100"]
180+
181+
result = normalize_targets(targets, "example.com")
182+
183+
assert result == ["10.0.0.1", "10.0.0.2", "DC01.example.com", "192.168.1.100"]
184+
185+
def test_cidr_single_host(self):
186+
"""Should handle /32 single host CIDR"""
187+
targets = ["192.168.1.50/32"]
188+
189+
result = normalize_targets(targets, "example.com")
190+
191+
assert result == ["192.168.1.50"]
192+
193+
194+
# ============================================================================
195+
# Test: expand_cidr
196+
# ============================================================================
197+
198+
199+
class TestExpandCidr:
200+
"""Tests for expand_cidr function"""
201+
202+
def test_slash_24(self):
203+
"""Should expand /24 to 254 hosts"""
204+
result = expand_cidr("192.168.1.0/24")
205+
206+
assert len(result) == 254
207+
assert result[0] == "192.168.1.1"
208+
assert result[-1] == "192.168.1.254"
209+
210+
def test_slash_30(self):
211+
"""Should expand /30 to 2 hosts"""
212+
result = expand_cidr("10.0.0.0/30")
213+
214+
assert result == ["10.0.0.1", "10.0.0.2"]
215+
216+
def test_slash_31(self):
217+
"""Should expand /31 point-to-point"""
218+
result = expand_cidr("10.0.0.0/31")
219+
220+
assert result == ["10.0.0.0", "10.0.0.1"]
221+
222+
def test_slash_32(self):
223+
"""Should expand /32 single host"""
224+
result = expand_cidr("192.168.1.100/32")
225+
226+
assert result == ["192.168.1.100"]
227+
228+
def test_invalid_cidr_raises(self):
229+
"""Should raise ValueError for invalid CIDR"""
230+
with pytest.raises(ValueError):
231+
expand_cidr("not-a-cidr")
232+
233+
def test_invalid_prefix_raises(self):
234+
"""Should raise ValueError for invalid prefix"""
235+
with pytest.raises(ValueError):
236+
expand_cidr("192.168.1.0/33")
237+
238+
239+
# ============================================================================
240+
# Test: is_cidr
241+
# ============================================================================
242+
243+
244+
class TestIsCidr:
245+
"""Tests for is_cidr function"""
246+
247+
def test_valid_cidr(self):
248+
"""Should return True for valid CIDR notation"""
249+
assert is_cidr("192.168.1.0/24") is True
250+
assert is_cidr("10.0.0.0/8") is True
251+
assert is_cidr("172.16.0.0/16") is True
252+
253+
def test_single_host_cidr(self):
254+
"""Should return True for /32 single host"""
255+
assert is_cidr("192.168.1.1/32") is True
256+
257+
def test_plain_ip_not_cidr(self):
258+
"""Should return False for plain IP address"""
259+
assert is_cidr("192.168.1.1") is False
260+
261+
def test_hostname_not_cidr(self):
262+
"""Should return False for hostname"""
263+
assert is_cidr("DC01.example.com") is False
264+
assert is_cidr("DC01") is False
265+
266+
def test_invalid_cidr(self):
267+
"""Should return False for invalid CIDR notation"""
268+
assert is_cidr("192.168.1.0/33") is False
269+
assert is_cidr("not/valid") is False
270+
164271

165272
# ============================================================================
166273
# Test: sanitize_json_string

0 commit comments

Comments
 (0)