Skip to content

Commit 344130e

Browse files
committed
add ASN numbers as scan targets with async expansion via new ASN helper
1 parent 38d90f2 commit 344130e

69 files changed

Lines changed: 1561 additions & 1239 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bbot/cli.py

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from bbot.errors import *
88
from bbot import __version__
99
from bbot.logger import log_to_stderr
10-
from bbot.core.helpers.misc import chain_lists, rm_rf
10+
from bbot.core.helpers.misc import chain_lists
1111

1212

1313
if multiprocessing.current_process().name == "MainProcess":
@@ -56,6 +56,10 @@ async def _main():
5656
return
5757
# ensure arguments (-c config options etc.) are valid
5858
options = preset.args.parsed
59+
# apply CLI log level options (e.g. --debug/--verbose/--silent) to the
60+
# global core logger even for CLI-only commands (like --install-all-deps)
61+
# that don't construct a full Scanner.
62+
preset.apply_log_level(apply_core=True)
5963

6064
# print help if no arguments
6165
if len(sys.argv) == 1:
@@ -90,7 +94,8 @@ async def _main():
9094
preset._default_output_modules = options.output_modules
9195
preset._default_internal_modules = []
9296

93-
preset.bake()
97+
# Bake a temporary copy of the preset so that flags correctly enable their associated modules before listing them
98+
preset = preset.bake()
9499

95100
# --list-modules
96101
if options.list_modules:
@@ -144,59 +149,67 @@ async def _main():
144149
print(row)
145150
return
146151

147-
try:
148-
scan = Scanner(preset=preset)
149-
except (PresetAbortError, ValidationError) as e:
150-
log.warning(str(e))
152+
baked_preset = preset.bake()
153+
154+
# --current-preset / --current-preset-full
155+
if options.current_preset or options.current_preset_full:
156+
# Ensure we always have a human-friendly description. Prefer an
157+
# explicit scan_name if present, otherwise fall back to the
158+
# preset name (e.g. "bbot_cli_main").
159+
if not baked_preset.description:
160+
if baked_preset.scan_name:
161+
baked_preset.description = str(baked_preset.scan_name)
162+
elif baked_preset.name:
163+
baked_preset.description = str(baked_preset.name)
164+
if options.current_preset_full:
165+
print(baked_preset.to_yaml(full_config=True))
166+
else:
167+
print(baked_preset.to_yaml())
168+
sys.exit(0)
151169
return
152170

171+
# deadly modules (no scan required yet)
153172
deadly_modules = [
154-
m for m in scan.preset.scan_modules if "deadly" in preset.preloaded_module(m).get("flags", [])
173+
m for m in baked_preset.scan_modules if "deadly" in baked_preset.preloaded_module(m).get("flags", [])
155174
]
156175
if deadly_modules and not options.allow_deadly:
157176
log.hugewarning(f"You enabled the following deadly modules: {','.join(deadly_modules)}")
158177
log.hugewarning("Deadly modules are highly intrusive")
159178
log.hugewarning("Please specify --allow-deadly to continue")
160179
return False
161180

162-
# --current-preset
163-
if options.current_preset:
164-
print(scan.preset.to_yaml())
165-
sys.exit(0)
166-
return
167-
168-
# --current-preset-full
169-
if options.current_preset_full:
170-
print(scan.preset.to_yaml(full_config=True))
171-
sys.exit(0)
181+
try:
182+
scan = Scanner(preset=baked_preset)
183+
except (PresetAbortError, ValidationError) as e:
184+
log.warning(str(e))
172185
return
173186

174187
# --install-all-deps
175188
if options.install_all_deps:
189+
# create a throwaway Scanner solely so that Preset.bake(scan) can perform find_and_replace() on all module configs so that placeholders like "#{BBOT_TOOLS}" are resolved before running Ansible tasks.
190+
from bbot.scanner import Scanner as _ScannerForDeps
191+
176192
preloaded_modules = preset.module_loader.preloaded()
177-
scan_modules = [k for k, v in preloaded_modules.items() if str(v.get("type", "")) == "scan"]
178-
output_modules = [k for k, v in preloaded_modules.items() if str(v.get("type", "")) == "output"]
179-
log.verbose("Creating dummy scan with all modules + output modules for deps installation")
180-
dummy_scan = Scanner(preset=preset, modules=scan_modules, output_modules=output_modules)
181-
dummy_scan.helpers.depsinstaller.force_deps = True
193+
modules_for_deps = [
194+
k for k, v in preloaded_modules.items() if str(v.get("type", "")) in ("scan", "output")
195+
]
196+
197+
# dummy scan used only for environment preparation
198+
dummy_scan = _ScannerForDeps(preset=preset)
199+
200+
helper = dummy_scan.helpers
182201
log.info("Installing module dependencies")
183-
await dummy_scan.load_modules()
184-
log.verbose("Running module setups")
185-
succeeded, hard_failed, soft_failed = await dummy_scan.setup_modules(deps_only=True)
186-
# remove any leftovers from the dummy scan
187-
rm_rf(dummy_scan.home, ignore_errors=True)
188-
rm_rf(dummy_scan.temp_dir, ignore_errors=True)
202+
succeeded, failed = await helper.depsinstaller.install(*modules_for_deps)
189203
if succeeded:
190204
log.success(
191205
f"Successfully installed dependencies for {len(succeeded):,} modules: {','.join(succeeded)}"
192206
)
193-
if soft_failed or hard_failed:
194-
failed = soft_failed + hard_failed
207+
if failed:
195208
log.warning(f"Failed to install dependencies for {len(failed):,} modules: {', '.join(failed)}")
196209
return False
197210
return True
198211

199-
scan_name = str(scan.name)
212+
await scan._prep()
200213

201214
log.verbose("")
202215
log.verbose("### MODULES ENABLED ###")
@@ -205,12 +218,19 @@ async def _main():
205218
log.verbose(row)
206219

207220
scan.helpers.word_cloud.load()
208-
await scan._prep()
221+
222+
scan_name = str(scan.name)
209223

210224
if not options.dry_run:
211225
log.trace(f"Command: {' '.join(sys.argv)}")
212226

213-
if sys.stdin.isatty():
227+
# In some environments (e.g. tests) stdin may be closed or not support isatty(). Treat those cases as non-interactive.
228+
try:
229+
stdin_is_tty = sys.stdin.isatty()
230+
except (ValueError, io.UnsupportedOperation):
231+
stdin_is_tty = False
232+
233+
if stdin_is_tty:
214234
# warn if any targets belong directly to a cloud provider
215235
if not scan.preset.strict_scope:
216236
for event in scan.target.seeds.event_seeds:

bbot/core/engine.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,26 @@ async def shutdown(self):
343343
self.context.term()
344344
except Exception:
345345
print(traceback.format_exc(), file=sys.stderr)
346+
# terminate the server process/thread
347+
if self._server_process is not None:
348+
try:
349+
self._server_process.join(timeout=5)
350+
if self._server_process.is_alive():
351+
# threads don't have terminate/kill, only processes do
352+
terminate = getattr(self._server_process, "terminate", None)
353+
if callable(terminate):
354+
terminate()
355+
self._server_process.join(timeout=3)
356+
if self._server_process.is_alive():
357+
kill = getattr(self._server_process, "kill", None)
358+
if callable(kill):
359+
kill()
360+
except Exception:
361+
with suppress(Exception):
362+
kill = getattr(self._server_process, "kill", None)
363+
if callable(kill):
364+
kill()
365+
self._server_process = None
346366
# delete socket file on exit
347367
self.socket_path.unlink(missing_ok=True)
348368

bbot/core/event/base.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ def parent(self, parent):
605605
self.web_spider_distance = getattr(parent, "web_spider_distance", 0)
606606
event_has_url = getattr(self, "parsed_url", None) is not None
607607
for t in parent.tags:
608-
if t in ("affiliate",):
608+
if t in ("affiliate"):
609609
self.add_tag(t)
610610
elif t.startswith("mutation-"):
611611
self.add_tag(t)
@@ -1129,6 +1129,41 @@ class ASN(DictEvent):
11291129
_always_emit = True
11301130
_quick_emit = True
11311131

1132+
def sanitize_data(self, data):
1133+
if not isinstance(data, int):
1134+
raise ValidationError(f"ASN number must be an integer: {data}")
1135+
return data
1136+
1137+
def _data_human(self):
1138+
"""Create a concise human-readable representation of ASN data."""
1139+
# Start with basic ASN info
1140+
display_data = {"asn": str(self.data)}
1141+
1142+
# Try to get additional ASN data from the helper if available
1143+
if hasattr(self, "scan") and self.scan and hasattr(self.scan, "helpers"):
1144+
try:
1145+
# Check if we can access the ASN helper synchronously
1146+
asn_helper = self.scan.helpers.asn
1147+
# Try to get cached data first (this should be synchronous)
1148+
cached_data = asn_helper._cache_lookup_asn(self.data)
1149+
if cached_data:
1150+
display_data.update(
1151+
{
1152+
"name": cached_data.get("name", ""),
1153+
"description": cached_data.get("description", ""),
1154+
"country": cached_data.get("country", ""),
1155+
}
1156+
)
1157+
# Replace subnets list with count for readability
1158+
subnets = cached_data.get("subnets", [])
1159+
if subnets and isinstance(subnets, list):
1160+
display_data["subnet_count"] = len(subnets)
1161+
except Exception:
1162+
# If anything fails, just return basic ASN info
1163+
pass
1164+
1165+
return json.dumps(display_data, sort_keys=True)
1166+
11321167

11331168
class CODE_REPOSITORY(DictHostEvent):
11341169
_always_emit = True
@@ -1617,18 +1652,6 @@ def _pretty_string(self):
16171652
return self.data["technology"]
16181653

16191654

1620-
class VHOST(DictHostEvent):
1621-
class _data_validator(BaseModel):
1622-
host: str
1623-
vhost: str
1624-
url: Optional[str] = None
1625-
_validate_url = field_validator("url")(validators.validate_url)
1626-
_validate_host = field_validator("host")(validators.validate_host)
1627-
1628-
def _pretty_string(self):
1629-
return self.data["vhost"]
1630-
1631-
16321655
class PROTOCOL(DictHostEvent):
16331656
class _data_validator(BaseModel):
16341657
host: str

bbot/core/event/helpers.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@
99
bbot_event_seeds = {}
1010

1111

12+
# Pre-compute sorted event classes for performance
13+
# This is computed once when the module is loaded instead of on every EventSeed() call
14+
def _get_sorted_event_classes():
15+
"""
16+
Sort event classes by priority (higher priority first).
17+
This ensures specific patterns like ASN:12345 are checked before broad patterns like hostname:port.
18+
"""
19+
return sorted(bbot_event_seeds.items(), key=lambda x: getattr(x[1], "priority", 5), reverse=True)
20+
21+
22+
# This will be populated after all event seed classes are registered
23+
_sorted_event_classes = None
24+
25+
1226
"""
1327
An "Event Seed" is a lightweight event containing only the minimum logic required to:
1428
- parse input to determine the event type + data
@@ -18,6 +32,19 @@
1832
It's useful for quickly parsing target lists without the cpu+memory overhead of creating full-fledged BBOT events
1933
2034
Not every type of BBOT event needs to be represented here. Only ones that are meant to be targets.
35+
36+
PRIORITY SYSTEM:
37+
Event seeds support a priority system to control the order in which regex patterns are checked.
38+
This prevents conflicts where one event type's regex might incorrectly match another type's input.
39+
40+
Priority values:
41+
- Higher numbers = checked first
42+
- Default priority = 5
43+
- Range: 1-10
44+
45+
To set priority on an event seed class:
46+
class MyEventSeed(BaseEventSeed):
47+
priority = 8 # Higher than default, will be checked before most others
2148
"""
2249

2350

@@ -27,17 +54,25 @@ class EventSeedRegistry(type):
2754
"""
2855

2956
def __new__(mcs, name, bases, attrs):
30-
global bbot_event_seeds
57+
global bbot_event_seeds, _sorted_event_classes
3158
cls = super().__new__(mcs, name, bases, attrs)
3259
# Don't register the base EventSeed class
3360
if name != "BaseEventSeed":
3461
bbot_event_seeds[cls.__name__] = cls
62+
# Recompute sorted classes whenever a new event seed is registered
63+
_sorted_event_classes = _get_sorted_event_classes()
3564
return cls
3665

3766

3867
def EventSeed(input):
3968
input = smart_encode_punycode(smart_decode(input).strip())
40-
for _, event_class in bbot_event_seeds.items():
69+
70+
# Use pre-computed sorted event classes for better performance
71+
global _sorted_event_classes
72+
if _sorted_event_classes is None:
73+
_sorted_event_classes = _get_sorted_event_classes()
74+
75+
for _, event_class in _sorted_event_classes:
4176
if hasattr(event_class, "precheck"):
4277
if event_class.precheck(input):
4378
return event_class(input)
@@ -53,6 +88,7 @@ def EventSeed(input):
5388
class BaseEventSeed(metaclass=EventSeedRegistry):
5489
regexes = []
5590
_target_type = "TARGET"
91+
priority = 5 # Default priority for event seed matching (1-10, higher = checked first)
5692

5793
__slots__ = ["data", "host", "port", "input"]
5894

@@ -76,6 +112,9 @@ def _sanitize_and_extract_host(self, data):
76112
"""
77113
return data, None, None
78114

115+
async def _generate_children(self, helpers):
116+
return []
117+
79118
def _override_input(self, input):
80119
return self.data
81120

@@ -143,6 +182,7 @@ def _sanitize_and_extract_host(data):
143182

144183
class OPEN_TCP_PORT(BaseEventSeed):
145184
regexes = regexes.event_type_regexes["OPEN_TCP_PORT"]
185+
priority = 1 # Low priority: broad hostname:port pattern should be checked after specific patterns
146186

147187
@staticmethod
148188
def _sanitize_and_extract_host(data):
@@ -236,3 +276,30 @@ def _override_input(self, input):
236276
@staticmethod
237277
def handle_match(match):
238278
return match.group(1)
279+
280+
281+
class ASN(BaseEventSeed):
282+
regexes = (re.compile(r"^(?:ASN|AS):?(\d+)$", re.I),) # adjust regex to match ASN:17178 AS17178
283+
priority = 10 # High priority
284+
285+
def _override_input(self, input):
286+
return f"ASN:{self.data}"
287+
288+
# ASNs are essentially just a superset of IP_RANGES.
289+
# This method resolves the ASN to a list of IP_RANGES using the ASN API, and then adds the cidr string as a child event seed.
290+
# These will later be automatically resolved to an IP_RANGE event seed and added to the target.
291+
async def _generate_children(self, helpers):
292+
asn_data = await helpers.asn.asn_to_subnets(int(self.data))
293+
children = []
294+
if asn_data:
295+
subnets = asn_data.get("subnets")
296+
if isinstance(subnets, str):
297+
subnets = [subnets]
298+
if subnets:
299+
for cidr in subnets:
300+
children.append(cidr)
301+
return children
302+
303+
@staticmethod
304+
def handle_match(match):
305+
return match.group(1)

0 commit comments

Comments
 (0)