Skip to content

Commit b749538

Browse files
authored
Merge pull request #2810 from blacklanternsecurity/untangle-cli-args
untangle cli arg issues
2 parents 6b9af37 + c2052a4 commit b749538

File tree

5 files changed

+150
-75
lines changed

5 files changed

+150
-75
lines changed

bbot/cli.py

Lines changed: 54 additions & 46 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":
@@ -34,13 +34,8 @@ async def _main():
3434
import traceback
3535
from contextlib import suppress
3636

37-
# fix tee buffering (only if on real TTY)
38-
if hasattr(sys.stdout, "reconfigure"):
39-
try:
40-
if sys.stdout.isatty():
41-
sys.stdout.reconfigure(line_buffering=True)
42-
except Exception:
43-
pass
37+
# fix tee buffering
38+
sys.stdout.reconfigure(line_buffering=True)
4439

4540
log = logging.getLogger("bbot.cli")
4641

@@ -61,6 +56,10 @@ async def _main():
6156
return
6257
# ensure arguments (-c config options etc.) are valid
6358
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)
6463

6564
# print help if no arguments
6665
if len(sys.argv) == 1:
@@ -95,7 +94,8 @@ async def _main():
9594
preset._default_output_modules = options.output_modules
9695
preset._default_internal_modules = []
9796

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

100100
# --list-modules
101101
if options.list_modules:
@@ -149,61 +149,68 @@ async def _main():
149149
print(row)
150150
return
151151

152-
try:
153-
scan = Scanner(preset=preset)
154-
except (PresetAbortError, ValidationError) as e:
155-
log.warning(str(e))
152+
baked_preset = await 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)
156169
return
157170

158-
await scan._prep()
159-
171+
# deadly modules (no scan required yet)
160172
deadly_modules = [
161-
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", [])
162174
]
163175
if deadly_modules and not options.allow_deadly:
164176
log.hugewarning(f"You enabled the following deadly modules: {','.join(deadly_modules)}")
165177
log.hugewarning("Deadly modules are highly intrusive")
166178
log.hugewarning("Please specify --allow-deadly to continue")
167179
return False
168180

169-
# --current-preset
170-
if options.current_preset:
171-
print(scan.preset.to_yaml())
172-
sys.exit(0)
173-
return
174-
175-
# --current-preset-full
176-
if options.current_preset_full:
177-
print(scan.preset.to_yaml(full_config=True))
178-
sys.exit(0)
181+
try:
182+
scan = Scanner(preset=baked_preset)
183+
except (PresetAbortError, ValidationError) as e:
184+
log.warning(str(e))
179185
return
180186

181187
# --install-all-deps
182188
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+
183192
preloaded_modules = preset.module_loader.preloaded()
184-
scan_modules = [k for k, v in preloaded_modules.items() if str(v.get("type", "")) == "scan"]
185-
output_modules = [k for k, v in preloaded_modules.items() if str(v.get("type", "")) == "output"]
186-
log.verbose("Creating dummy scan with all modules + output modules for deps installation")
187-
dummy_scan = Scanner(preset=preset, modules=scan_modules, output_modules=output_modules)
188-
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+
await dummy_scan._unbaked_preset.bake(dummy_scan)
200+
201+
helper = dummy_scan.helpers
189202
log.info("Installing module dependencies")
190-
await dummy_scan.load_modules()
191-
log.verbose("Running module setups")
192-
succeeded, hard_failed, soft_failed = await dummy_scan.setup_modules(deps_only=True)
193-
# remove any leftovers from the dummy scan
194-
rm_rf(dummy_scan.home, ignore_errors=True)
195-
rm_rf(dummy_scan.temp_dir, ignore_errors=True)
203+
succeeded, failed = await helper.depsinstaller.install(*modules_for_deps)
196204
if succeeded:
197205
log.success(
198206
f"Successfully installed dependencies for {len(succeeded):,} modules: {','.join(succeeded)}"
199207
)
200-
if soft_failed or hard_failed:
201-
failed = soft_failed + hard_failed
208+
if failed:
202209
log.warning(f"Failed to install dependencies for {len(failed):,} modules: {', '.join(failed)}")
203210
return False
204211
return True
205212

206-
scan_name = str(scan.name)
213+
await scan._prep()
207214

208215
log.verbose("")
209216
log.verbose("### MODULES ENABLED ###")
@@ -213,17 +220,18 @@ async def _main():
213220

214221
scan.helpers.word_cloud.load()
215222

223+
scan_name = str(scan.name)
224+
216225
if not options.dry_run:
217226
log.trace(f"Command: {' '.join(sys.argv)}")
218227

228+
# In some environments (e.g. tests) stdin may be closed or not support isatty(). Treat those cases as non-interactive.
219229
try:
220-
is_tty = (
221-
hasattr(sys.stdin, "isatty") and not getattr(sys.stdin, "closed", False) and sys.stdin.isatty()
222-
)
223-
except Exception:
224-
is_tty = False
230+
stdin_is_tty = sys.stdin.isatty()
231+
except (ValueError, io.UnsupportedOperation):
232+
stdin_is_tty = False
225233

226-
if is_tty:
234+
if stdin_is_tty:
227235
# warn if any targets belong directly to a cloud provider
228236
if not scan.preset.strict_scope:
229237
for event in scan.target.seeds.event_seeds:

bbot/core/helpers/web/web.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,7 @@ def __init__(self, parent_helper):
5656
self.target = self.preset.target
5757
self.ssl_verify = self.config.get("ssl_verify", False)
5858
engine_debug = self.config.get("engine", {}).get("debug", False)
59-
super().__init__(
60-
server_kwargs={"config": self.config, "target": self.parent_helper.preset.target},
61-
debug=engine_debug,
62-
)
59+
super().__init__(server_kwargs={"config": self.config, "target": self.target}, debug=engine_debug)
6360

6461
def AsyncClient(self, *args, **kwargs):
6562
# cache by retries to prevent unwanted accumulation of clients

bbot/scanner/preset/args.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,14 +368,15 @@ def create_parser(self, *args, **kwargs):
368368
deps = p.add_argument_group(
369369
title="Module dependencies", description="Control how modules install their dependencies"
370370
)
371+
# Behavior flags are mutually exclusive with each other. But need to be able to be combined with --install-all-deps.
371372
g2 = deps.add_mutually_exclusive_group()
372373
g2.add_argument("--no-deps", action="store_true", help="Don't install module dependencies")
373374
g2.add_argument("--force-deps", action="store_true", help="Force install all module dependencies")
374375
g2.add_argument("--retry-deps", action="store_true", help="Try again to install failed module dependencies")
375376
g2.add_argument(
376377
"--ignore-failed-deps", action="store_true", help="Run modules even if they have failed dependencies"
377378
)
378-
g2.add_argument("--install-all-deps", action="store_true", help="Install dependencies for all modules")
379+
deps.add_argument("--install-all-deps", action="store_true", help="Install dependencies for all modules")
379380

380381
misc = p.add_argument_group(title="Misc")
381382
misc.add_argument("--version", action="store_true", help="show BBOT version and exit")

bbot/scanner/preset/preset.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,11 +600,34 @@ def apply_log_level(self, apply_core=False):
600600
@property
601601
def helpers(self):
602602
if self._helpers is None:
603+
# Ensure we have at least a minimal target object before any helper (especially web helpers) is constructed.
604+
605+
self._ensure_minimal_target()
603606
from bbot.core.helpers.helper import ConfigAwareHelper
604607

605608
self._helpers = ConfigAwareHelper(preset=self)
606609
return self._helpers
607610

611+
def _ensure_minimal_target(self):
612+
"""
613+
Lazily construct a minimal BBOTTarget from the current seeds / whitelist / blacklist if one does not already exist.
614+
615+
This is intentionally lighter-weight than the full async target
616+
preparation performed in `bake()` (which also calls
617+
`target.generate_children()`).
618+
"""
619+
if self._target is not None:
620+
return
621+
622+
from bbot.scanner.target import BBOTTarget
623+
624+
self._target = BBOTTarget(
625+
*list(self._seeds),
626+
whitelist=self._whitelist, # modify this after scope rework branch is merged into dev
627+
blacklist=self._blacklist,
628+
strict_scope=self.strict_scope,
629+
)
630+
608631
@property
609632
def module_loader(self):
610633
self.environ

bbot/scanner/scanner.py

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ def __init__(
158158
self.modules = OrderedDict({})
159159
self.dummy_modules = {}
160160
self.preset = None
161+
# initial status before `_prep()` runs
162+
self._status = "NOT_STARTED"
163+
self._status_code = self._status_codes[self._status]
161164

162165
async def _prep(self):
163166
"""
@@ -216,26 +219,26 @@ async def _prep(self):
216219
self.scope_report_distance = int(self.scope_config.get("report_distance", 1))
217220

218221
# web config
219-
self.web_config = self.config.get("web", {})
220-
self.web_spider_distance = self.web_config.get("spider_distance", 0)
221-
self.web_spider_depth = self.web_config.get("spider_depth", 1)
222-
self.web_spider_links_per_page = self.web_config.get("spider_links_per_page", 20)
223-
max_redirects = self.web_config.get("http_max_redirects", 5)
222+
web_config = self.config.get("web", {})
223+
self.web_spider_distance = web_config.get("spider_distance", 0)
224+
self.web_spider_depth = web_config.get("spider_depth", 1)
225+
self.web_spider_links_per_page = web_config.get("spider_links_per_page", 20)
226+
max_redirects = web_config.get("http_max_redirects", 5)
224227
self.web_max_redirects = max(max_redirects, self.web_spider_distance)
225-
self.http_proxy = self.web_config.get("http_proxy", "")
226-
self.http_timeout = self.web_config.get("http_timeout", 10)
227-
self.httpx_timeout = self.web_config.get("httpx_timeout", 5)
228-
self.http_retries = self.web_config.get("http_retries", 1)
229-
self.httpx_retries = self.web_config.get("httpx_retries", 1)
230-
self.useragent = self.web_config.get("user_agent", "BBOT")
228+
self.http_proxy = web_config.get("http_proxy", "")
229+
self.http_timeout = web_config.get("http_timeout", 10)
230+
self.httpx_timeout = web_config.get("httpx_timeout", 5)
231+
self.http_retries = web_config.get("http_retries", 1)
232+
self.httpx_retries = web_config.get("httpx_retries", 1)
233+
self.useragent = web_config.get("user_agent", "BBOT")
231234
# custom HTTP headers warning
232-
self.custom_http_headers = self.web_config.get("http_headers", {})
235+
self.custom_http_headers = web_config.get("http_headers", {})
233236
if self.custom_http_headers:
234237
self.warning(
235238
"You have enabled custom HTTP headers. These will be attached to all in-scope requests and all requests made by httpx."
236239
)
237240
# custom HTTP cookies warning
238-
self.custom_http_cookies = self.web_config.get("http_cookies", {})
241+
self.custom_http_cookies = web_config.get("http_cookies", {})
239242
if self.custom_http_cookies:
240243
self.warning(
241244
"You have enabled custom HTTP cookies. These will be attached to all in-scope requests and all requests made by httpx."
@@ -562,8 +565,18 @@ async def load_modules(self):
562565
After all modules are loaded, they are sorted by `_priority` and stored in the `modules` dictionary.
563566
"""
564567
if not self._modules_loaded:
568+
# If the preset hasn't been baked yet but modules have been
569+
# manually attached (e.g. in tests), skip the automatic loading
570+
# pipeline and operate only on the existing modules.
571+
if self.preset is None:
572+
if not self.modules:
573+
self.warning("No modules to load")
574+
self._modules_loaded = True
575+
return
576+
565577
if not self.preset.modules:
566578
self.warning("No modules to load")
579+
self._modules_loaded = True
567580
return
568581

569582
if not self.preset.scan_modules:
@@ -897,9 +910,15 @@ async def _cleanup(self):
897910
# clean up modules
898911
for mod in self.modules.values():
899912
await mod._cleanup()
900-
with contextlib.suppress(Exception):
901-
self.home.rmdir()
902-
self.helpers.rm_rf(self.temp_dir, ignore_errors=True)
913+
# In some test paths, `_prep()` is never called, so `home` and
914+
# `temp_dir` may not exist. Treat those as best-effort cleanups.
915+
home = getattr(self, "home", None)
916+
if home is not None:
917+
with contextlib.suppress(Exception):
918+
home.rmdir()
919+
temp_dir = getattr(self, "temp_dir", None)
920+
if temp_dir is not None:
921+
self.helpers.rm_rf(temp_dir, ignore_errors=True)
903922
self.helpers.clean_old_scans()
904923

905924
def in_scope(self, *args, **kwargs):
@@ -913,11 +932,29 @@ def blacklisted(self, *args, **kwargs):
913932

914933
@property
915934
def core(self):
916-
return self.preset.core
935+
# Before `_prep()` runs, fall back to the unbaked preset's core so that basic configuration is still available (during module construction in tests)
936+
if self.preset is not None:
937+
return self.preset.core
938+
return self._unbaked_preset.core
917939

918940
@property
919941
def config(self):
920-
return self.preset.core.config
942+
# Allow access to the scan config even before `_prep()` by falling back to the unbaked preset's core config.
943+
if self.preset is not None:
944+
return self.preset.core.config
945+
return self._unbaked_preset.core.config
946+
947+
@property
948+
def web_config(self):
949+
"""
950+
Web-related configuration for the scan.
951+
952+
Exposed as a property so it is available even before `_prep()` runs,
953+
falling back to the underlying config's `web` section. During `_prep()`
954+
an instance attribute of the same name is assigned, which will then
955+
override this property for the remainder of the scan lifetime.
956+
"""
957+
return self.config.get("web", {})
921958

922959
@property
923960
def target(self):
@@ -937,7 +974,13 @@ def blacklist(self):
937974

938975
@property
939976
def helpers(self):
940-
return self.preset.helpers
977+
# Before `_prep()` runs, `self.preset` is None. In those cases,
978+
# fall back to the unbaked preset's helpers so that CLI utilities
979+
# (e.g. depsinstaller) and other lightweight helper functionality
980+
# remain available without requiring a full scan prep.
981+
if self.preset is not None:
982+
return self.preset.helpers
983+
return self._unbaked_preset.helpers
941984

942985
@property
943986
def force_start(self):
@@ -986,12 +1029,15 @@ def status(self, status):
9861029
if status != self._status:
9871030
self._status = status
9881031
self._status_code = self._status_codes[status]
989-
self.dispatcher_tasks.append(
990-
asyncio.create_task(
991-
self.dispatcher.catch(self.dispatcher.on_status, self._status, self.id),
992-
name=f"{self.name}.dispatcher.on_status({status})",
1032+
# During early initialization (or in certain tests),`dispatcher` may not be set yet. In that case we just update the status without scheduling dispatcher tasks
1033+
dispatcher = getattr(self, "dispatcher", None)
1034+
if dispatcher is not None:
1035+
self.dispatcher_tasks.append(
1036+
asyncio.create_task(
1037+
dispatcher.catch(self.dispatcher.on_status, self._status, self.id),
1038+
name=f"{self.name}.dispatcher.on_status({status})",
1039+
)
9931040
)
994-
)
9951041
else:
9961042
self.debug(f'Scan status is already "{status}"')
9971043
else:

0 commit comments

Comments
 (0)