From 143556b2686e27e1dcf4cf2f2b2604f1ddcc6a4e Mon Sep 17 00:00:00 2001 From: Austin Stark <14080242+ausmaster@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:20:09 -0700 Subject: [PATCH 01/17] Make validation+coercion a precondition for bake() (inversion) Previously bake() coerced config and called validate() itself, and from_dict validated the raw dict up front -- so coercion (a 'bake step') effectively ran before bake(), and the programmatic/bbot.yml entry points were never validated. Invert it: - Preset.validate() is now the precondition: coerces custom_config toward its declared types AND validates it, sets self._validated, returns self. - bake() ENFORCES it: raises if the preset isn't validated (it no longer coerces or validates itself). - merge() / mutations reset _validated, so a changed preset must be revalidated. - Scanner.__init__, the CLI, and from_dict call validate() before baking. Side effects: - config is now validated on every entry point (programmatic Preset(config=)/ Scanner(config=), bbot.yml, presets, CLI), closing the validation-asymmetry gap. - surfaced a missing internal-module toggle: 'unarchive' was absent from BBOTConfig (so 'bbot -c unarchive=false' was rejected); added it. Co-Authored-By: Claude Opus 4.8 --- bbot/cli.py | 2 ++ bbot/core/config/models.py | 1 + bbot/scanner/preset/preset.py | 61 ++++++++++++++++++++++++++--------- bbot/scanner/scanner.py | 2 ++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/bbot/cli.py b/bbot/cli.py index f27bd48e7c..2ebe9046e3 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -102,6 +102,7 @@ async def _main(): preset._default_internal_modules = [] # Bake a temporary copy of the preset so that flags correctly enable their associated modules before listing them + preset.validate() preset = preset.bake() # --list-modules @@ -156,6 +157,7 @@ async def _main(): print(row) return + preset.validate() baked_preset = preset.bake() # --current-preset / --current-preset-full diff --git a/bbot/core/config/models.py b/bbot/core/config/models.py index c45434cb11..c95f88e534 100644 --- a/bbot/core/config/models.py +++ b/bbot/core/config/models.py @@ -462,6 +462,7 @@ class BBOTConfig(BaseModel): aggregate: Optional[bool] = None dnsresolve: Optional[bool] = None cloudcheck: Optional[bool] = None + unarchive: Optional[bool] = None # URL handling url_querystring_remove: Optional[bool] = None diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 55dbecdab0..d118a11b21 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -177,6 +177,8 @@ def __init__( self._module_loader = None self._yaml_str = "" self._baked = False + # whether this preset has been validated+coerced (a precondition for bake()) + self._validated = False self._default_output_modules = None self._default_internal_modules = None @@ -396,6 +398,8 @@ def merge(self, other): # transfer args if other._args is not None: self._args = other._args + # the preset changed -- it must be re-validated before baking + self._validated = False def bake(self, scan=None): """ @@ -429,23 +433,18 @@ def bake(self, scan=None): os.environ.clear() os.environ.update(os_environ) + # bake() requires an already validated + coerced preset. Validation and + # coercion are a PRECONDITION (Preset.validate(), the caller's + # responsibility) -- enforced here so an unvalidated preset never bakes. + if not self._validated: + raise ValidationError( + "Preset must be validated before baking -- call .validate() first " + "(Scanner and the CLI do this for you)." + ) + # validate log level options baked_preset.apply_log_level(apply_core=scan is not None) - # coerce config values toward their declared types so the runtime gets - # real typed values (bool fields hold True/False, not int 1; str fields - # hold strings, not YAML-parsed ints from a config file) - from bbot.core.config.models import coerce_config - - try: - index = baked_preset.module_loader.config_type_index - baked_preset.core.custom_config = coerce_config(baked_preset.core.custom_config, index) - except Exception: - pass - - # validate flags, config options - baked_preset.validate() - # now that our requirements / exclusions are validated, we can start enabling modules # enable scan modules for module in baked_preset.explicit_scan_modules: @@ -757,6 +756,9 @@ def from_dict(cls, preset_dict, name=None, _exclude=None, _log=False): _exclude=_exclude, _log=_log, ) + # validate+coerce so the returned preset is bake-ready (callers that + # mutate it afterward will need to .validate() again before baking) + new_preset.validate() return new_preset def include_preset(self, filename): @@ -981,8 +983,34 @@ def _is_valid_module(self, module, module_type, name_only=False, raise_error=Tru def validate(self): """ - Validate module/flag exclusions/requirements, and CLI config options if applicable. + Coerce config values to their declared types and validate the preset. + + This is a PRECONDITION for bake() (which enforces it): a preset must be + validated before it can be baked. Idempotent -- safe to call more than + once. Sets ``self._validated = True`` on success and returns ``self`` so + it can be chained (e.g. ``preset.validate().bake()``). """ + from bbot.core.config.models import coerce_config + + # Coerce config values toward their declared types (this is the single + # coercion point, lifted out of bake()): the runtime gets real typed + # values, and type-coercible values (e.g. an all-numeric password) pass + # validation cleanly instead of being rejected. + try: + index = self.module_loader.config_type_index + self.core.custom_config = coerce_config(self.core.custom_config, index) + except Exception: + pass + + # Validate the (coerced) user config against the schema. This covers + # every entry point (programmatic Preset(config=)/Scanner(config=), + # bbot.yml/secrets.yml, presets, CLI) -- not just from_dict / CLI args. + from .validate import validate_preset + + errs = validate_preset({"config": dict(self.core.custom_config)}, module_loader=self.module_loader) + if errs: + raise ValidationError("\n".join(str(e) for e in errs)) + if self._cli: self.args.validate() @@ -1005,6 +1033,9 @@ def validate(self): if flag not in self.module_loader.flag_choices: raise ValidationError(get_closest_match(flag, self.module_loader.flag_choices, msg="flag")) + self._validated = True + return self + @property def all_presets(self): """ diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 7d8306f7a7..5c8c677c89 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -151,6 +151,8 @@ def __init__( raise ValidationError(f'Preset must be of type Preset, not "{type(custom_preset).__name__}"') base_preset.merge(custom_preset) + # validation+coercion is a precondition for baking; Scanner does it for the caller + base_preset.validate() self.preset = base_preset.bake(self) self._prepped = False From a41da70935128cac8328f1275a7fe113572fc930 Mon Sep 17 00:00:00 2001 From: Austin Stark <14080242+ausmaster@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:20:15 -0700 Subject: [PATCH 02/17] Migrate tests to the validate-before-bake contract - Direct preset.bake() -> preset.validate().bake() (validate() returns self); bake() now requires a validated preset. - test_config: the programmatic path is now strictly validated, so the old sentinel keys ('plumbus', module 'test_option') are correctly rejected as typos; rewritten to verify config propagation with real keys (status_frequency, ipneighbor.num_bits, module_timeout). Co-Authored-By: Claude Opus 4.8 --- bbot/test/test_step_1/test_config.py | 18 ++-- bbot/test/test_step_1/test_preset_seeds.py | 4 +- bbot/test/test_step_1/test_presets.py | 106 ++++++++++----------- bbot/test/test_step_1/test_scan.py | 2 +- 4 files changed, 66 insertions(+), 64 deletions(-) diff --git a/bbot/test/test_step_1/test_config.py b/bbot/test/test_step_1/test_config.py index 6454a8f599..16a697c509 100644 --- a/bbot/test/test_step_1/test_config.py +++ b/bbot/test/test_step_1/test_config.py @@ -3,21 +3,23 @@ @pytest.mark.asyncio async def test_config(bbot_scanner): + # config is now strictly validated, so propagation is tested with REAL keys + # (the old sentinel keys "plumbus" / "test_option" are rejected as typos). config = { - "plumbus": "asdf", + "status_frequency": 5, "speculate": True, "modules": { - "ipneighbor": {"test_option": "ipneighbor"}, - "python": {"test_option": "asdf"}, - "speculate": {"test_option": "speculate"}, + "ipneighbor": {"num_bits": 8}, + "python": {"module_timeout": 60}, + "speculate": {"module_timeout": 60}, }, } scan1 = bbot_scanner("127.0.0.1", modules=["ipneighbor"], config=config) await scan1._prep() assert scan1.config["web"]["user_agent"] == "BBOT Test User-Agent" - assert scan1.config["plumbus"] == "asdf" - assert scan1.modules["ipneighbor"].config["test_option"] == "ipneighbor" - assert scan1.modules["python"].config["test_option"] == "asdf" - assert scan1.modules["speculate"].config["test_option"] == "speculate" + assert scan1.config["status_frequency"] == 5 + assert scan1.modules["ipneighbor"].config["num_bits"] == 8 + assert scan1.modules["python"].config["module_timeout"] == 60 + assert scan1.modules["speculate"].config["module_timeout"] == 60 await scan1._cleanup() diff --git a/bbot/test/test_step_1/test_preset_seeds.py b/bbot/test/test_step_1/test_preset_seeds.py index 07d74c2d9c..3c54898046 100644 --- a/bbot/test/test_step_1/test_preset_seeds.py +++ b/bbot/test/test_step_1/test_preset_seeds.py @@ -6,7 +6,7 @@ def test_preset_target_and_seeds_default(): If no explicit seeds are provided, seeds should be copied from target. """ preset = Preset("evilcorp.com") - baked = preset.bake() + baked = preset.validate().bake() target = baked.target assert set(target.target.inputs) == {"evilcorp.com"} @@ -18,7 +18,7 @@ def test_preset_target_and_seeds_explicit_seeds_override(): If explicit seeds are provided, they should NOT be copied from target. """ preset = Preset("evilcorp.com", seeds=["seedonly.evilcorp.com"]) - baked = preset.bake() + baked = preset.validate().bake() target = baked.target assert set(target.target.inputs) == {"evilcorp.com"} diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index e0b4395414..009966a5a8 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -77,7 +77,7 @@ async def test_preset_yaml(clean_default_config): silent=True, config={"keep_scans": 42}, ) - preset1 = preset1.bake() + preset1 = preset1.validate().bake() assert "evilcorp.com" in preset1.target.seeds assert "evilcorp.ce" not in preset1.target.seeds assert "asdf.www.evilcorp.ce" in preset1.target.seeds @@ -170,7 +170,7 @@ async def test_preset_scope(clean_default_config): assert {str(h) for h in scan.target.target.hosts} == {"1.2.3.4/32", "evilcorp.com"} blank_preset = Preset() - blank_preset = blank_preset.bake() + blank_preset = blank_preset.validate().bake() assert not blank_preset.target.seeds assert not blank_preset.target.target assert blank_preset.strict_scope is False @@ -181,7 +181,7 @@ async def test_preset_scope(clean_default_config): seeds=["evilcorp.com", "www.evilcorp.ce"], blacklist=["test.www.evilcorp.ce"], ) - preset1_baked = preset1.bake() + preset1_baked = preset1.validate().bake() # make sure target logic works as expected assert "evilcorp.com" in preset1_baked.target.seeds @@ -212,7 +212,7 @@ async def test_preset_scope(clean_default_config): preset1.merge(preset3) - preset1_baked = preset1.bake() + preset1_baked = preset1.validate().bake() # targets should be merged assert "evilcorp.com" in preset1_baked.target.seeds @@ -259,8 +259,8 @@ async def test_preset_scope(clean_default_config): config={"modules": {"github_workflows": {"api_key": "deadbeef", "output_folder": "asdf"}}}, ) - preset_domain_with_seed_baked = preset_domain_with_seed.bake() - preset_with_target_scope_baked = preset_with_target_scope.bake() + preset_domain_with_seed_baked = preset_domain_with_seed.validate().bake() + preset_with_target_scope_baked = preset_with_target_scope.validate().bake() # When seeds and targets are identical, only targets are serialized. domain_with_seed_dict = preset_domain_with_seed_baked.to_dict(include_target=True) @@ -308,7 +308,7 @@ async def test_preset_scope(clean_default_config): # When merging a preset that has both seeds and target with one that only has # target (no explicit seeds), explicit seeds are unioned and targets are unioned. preset_domain_with_seed.merge(preset_with_target_scope) - preset_domain_with_seed_baked = preset_domain_with_seed.bake() + preset_domain_with_seed_baked = preset_domain_with_seed.validate().bake() assert {e.data for e in preset_domain_with_seed_baked.seeds} == {"evilcorp.com", "evilcorp.org"} # After merging, target scope should include both the original domain target and the scoped network/URL assert {e.data for e in preset_domain_with_seed_baked.target.target} == { @@ -331,7 +331,7 @@ async def test_preset_scope(clean_default_config): preset_targets_only = Preset("evilcorp.com") preset_with_target_scope = Preset("1.2.3.4/24", seeds=["evilcorp.org"]) preset_with_target_scope.merge(preset_targets_only) - preset_with_target_scope_baked = preset_with_target_scope.bake() + preset_with_target_scope_baked = preset_with_target_scope.validate().bake() # Seeds stay as the explicit seeds from the base preset assert {e.data for e in preset_with_target_scope_baked.seeds} == {"evilcorp.org"} # Target scope is the union of both presets' targets. @@ -356,14 +356,14 @@ async def test_preset_scope(clean_default_config): # after bake, each has seeds backfilled from its own target, and merge unions both. preset_targets_only1 = Preset("evilcorp.com") preset_targets_only2 = Preset("evilcorp.de") - preset_targets_only1_baked = preset_targets_only1.bake() - preset_targets_only2_baked = preset_targets_only2.bake() + preset_targets_only1_baked = preset_targets_only1.validate().bake() + preset_targets_only2_baked = preset_targets_only2.validate().bake() assert {e.data for e in preset_targets_only1_baked.seeds} == {"evilcorp.com"} assert {e.data for e in preset_targets_only2_baked.seeds} == {"evilcorp.de"} assert {e.data for e in preset_targets_only1_baked.target.target} == {"evilcorp.com"} assert {e.data for e in preset_targets_only2_baked.target.target} == {"evilcorp.de"} preset_targets_only1.merge(preset_targets_only2) - preset_targets_only1_baked = preset_targets_only1.bake() + preset_targets_only1_baked = preset_targets_only1.validate().bake() assert {e.data for e in preset_targets_only1_baked.seeds} == {"evilcorp.com", "evilcorp.de"} assert {e.data for e in preset_targets_only2_baked.seeds} == {"evilcorp.de"} assert {e.data for e in preset_targets_only1_baked.target.target} == {"evilcorp.com", "evilcorp.de"} @@ -384,8 +384,8 @@ async def test_preset_scope(clean_default_config): preset_targets_only1 = Preset("evilcorp.com") preset_targets_only2 = Preset("evilcorp.de") preset_targets_only2.merge(preset_targets_only1) - preset_targets_only1_baked = preset_targets_only1.bake() - preset_targets_only2_baked = preset_targets_only2.bake() + preset_targets_only1_baked = preset_targets_only1.validate().bake() + preset_targets_only2_baked = preset_targets_only2.validate().bake() assert {e.data for e in preset_targets_only1_baked.seeds} == {"evilcorp.com"} assert {e.data for e in preset_targets_only2_baked.seeds} == {"evilcorp.com", "evilcorp.de"} assert {e.data for e in preset_targets_only1_baked.target.target} == {"evilcorp.com"} @@ -423,12 +423,12 @@ async def test_preset_logging(): assert silent_and_verbose.silent is True assert silent_and_verbose.debug is False assert silent_and_verbose.verbose is True - baked = silent_and_verbose.bake() + baked = silent_and_verbose.validate().bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = silent_and_verbose.bake(scan=scan) + baked = silent_and_verbose.validate().bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -439,12 +439,12 @@ async def test_preset_logging(): assert silent_and_debug.silent is True assert silent_and_debug.debug is True assert silent_and_debug.verbose is False - baked = silent_and_debug.bake() + baked = silent_and_debug.validate().bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = silent_and_debug.bake(scan=scan) + baked = silent_and_debug.validate().bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -455,12 +455,12 @@ async def test_preset_logging(): assert debug_and_verbose.silent is False assert debug_and_verbose.debug is True assert debug_and_verbose.verbose is True - baked = debug_and_verbose.bake() + baked = debug_and_verbose.validate().bake() assert baked.silent is False assert baked.debug is True assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = debug_and_verbose.bake(scan=scan) + baked = debug_and_verbose.validate().bake(scan=scan) assert baked.core.logger.log_level == logging.DEBUG assert CORE.logger.log_level == logging.DEBUG @@ -471,12 +471,12 @@ async def test_preset_logging(): assert all_preset.silent is True assert all_preset.debug is True assert all_preset.verbose is True - baked = all_preset.bake() + baked = all_preset.validate().bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = all_preset.bake(scan=scan) + baked = all_preset.validate().bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -484,7 +484,7 @@ async def test_preset_logging(): assert CORE.logger.log_level == original_log_level # defaults - preset = Preset().bake() + preset = Preset().validate().bake() assert preset.core.logger.log_level == original_log_level assert CORE.logger.log_level == original_log_level @@ -495,7 +495,7 @@ async def test_preset_logging(): async def test_preset_module_resolution(clean_default_config): - preset = Preset().bake() + preset = Preset().validate().bake() sslcert_preloaded = preset.preloaded_module("sslcert") wayback_preloaded = preset.preloaded_module("wayback") dotnetnuke_preloaded = preset.preloaded_module("dotnetnuke") @@ -523,11 +523,11 @@ async def test_preset_module_resolution(clean_default_config): assert preset.modules == set(preset.output_modules).union(set(preset.internal_modules)) # make sure dependency resolution works as expected - preset = Preset(modules=["dotnetnuke"]).bake() + preset = Preset(modules=["dotnetnuke"]).validate().bake() assert set(preset.scan_modules) == {"dotnetnuke", "http"} # make sure flags work as expected - preset = Preset(flags=["subdomain-enum"]).bake() + preset = Preset(flags=["subdomain-enum"]).validate().bake() assert preset.flags == {"subdomain-enum"} assert "sslcert" in preset.modules assert "wayback" in preset.modules @@ -535,40 +535,40 @@ async def test_preset_module_resolution(clean_default_config): assert "wayback" in preset.scan_modules # flag + module exclusions - preset = Preset(flags=["subdomain-enum"], exclude_modules=["sslcert"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_modules=["sslcert"]).validate().bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # flag + flag exclusions - preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).validate().bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # flag + flag requirements - preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() + preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).validate().bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # normal module enableement - preset = Preset(modules=["sslcert", "dotnetnuke", "wayback"]).bake() + preset = Preset(modules=["sslcert", "dotnetnuke", "wayback"]).validate().bake() assert set(preset.scan_modules) == {"sslcert", "dotnetnuke", "wayback", "http"} # modules + flag exclusions - preset = Preset(exclude_flags=["active"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() + preset = Preset(exclude_flags=["active"], modules=["sslcert", "dotnetnuke", "wayback"]).validate().bake() assert set(preset.scan_modules) == {"wayback"} # modules + flag requirements - preset = Preset(require_flags=["passive"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() + preset = Preset(require_flags=["passive"], modules=["sslcert", "dotnetnuke", "wayback"]).validate().bake() assert set(preset.scan_modules) == {"wayback"} # modules + module exclusions - baked_preset = Preset(exclude_modules=["sslcert"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() + baked_preset = Preset(exclude_modules=["sslcert"], modules=["sslcert", "dotnetnuke", "wayback"]).validate().bake() assert baked_preset.modules == { "wayback", "cloudcheck", @@ -629,7 +629,7 @@ def test_preset_scope_round_trip(clean_default_config): "config": {"scope": {"strict": True}}, } preset = Preset.from_dict(preset_dict) - baked = preset.bake() + baked = preset.validate().bake() # Seeds should round-trip unchanged assert list(baked.seeds) == ["127.0.0.1"] # Target list should round-trip unchanged @@ -648,7 +648,7 @@ def test_preset_target_tolerance(): "targets": ["127.0.0.2"], } preset = Preset.from_dict(preset_dict) - baked = preset.bake() + baked = preset.validate().bake() assert set(baked.seeds) == {"127.0.0.1", "127.0.0.2"} preset = Preset.from_yaml_string(""" @@ -657,7 +657,7 @@ def test_preset_target_tolerance(): targets: - 127.0.0.2 """) - baked = preset.bake() + baked = preset.validate().bake() assert set(baked.seeds) == {"127.0.0.1", "127.0.0.2"} @@ -960,25 +960,25 @@ async def test_preset_conditions(): async def test_preset_module_disablement(clean_default_config): # internal module disablement - preset = Preset().bake() + preset = Preset().validate().bake() assert "speculate" in preset.internal_modules assert "excavate" in preset.internal_modules assert "aggregate" in preset.internal_modules - preset = Preset(config={"speculate": False}).bake() + preset = Preset(config={"speculate": False}).validate().bake() assert "speculate" not in preset.internal_modules assert "excavate" in preset.internal_modules assert "aggregate" in preset.internal_modules - preset = Preset(exclude_modules=["speculate", "excavate"]).bake() + preset = Preset(exclude_modules=["speculate", "excavate"]).validate().bake() assert "speculate" not in preset.internal_modules assert "excavate" not in preset.internal_modules assert "aggregate" in preset.internal_modules # internal module disablement - preset = Preset().bake() + preset = Preset().validate().bake() assert set(preset.output_modules) == {"python", "txt", "csv", "json"} - preset = Preset(exclude_modules=["txt", "csv"]).bake() + preset = Preset(exclude_modules=["txt", "csv"]).validate().bake() assert set(preset.output_modules) == {"python", "json"} - preset = Preset(output_modules=["json"]).bake() + preset = Preset(output_modules=["json"]).validate().bake() assert set(preset.output_modules) == {"json"} @@ -1051,7 +1051,7 @@ async def test_preset_override(clean_default_config): assert preset.debug is True assert preset.silent is True assert preset.name == "override4" - preset = preset.bake() + preset = preset.validate().bake() assert preset.debug is False assert preset.silent is True assert preset.name == "override4" @@ -1071,7 +1071,7 @@ def get_module_flags(p): yield m, preloaded.get("flags", []) # enable by flag, no exclusions/requirements - preset = Preset(flags=["subdomain-enum"]).bake() + preset = Preset(flags=["subdomain-enum"]).validate().bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) dnsbrute_flags = preset.preloaded_module("dnsbrute").get("flags", []) @@ -1089,7 +1089,7 @@ def get_module_flags(p): assert any("loud" in flags for module, flags in module_flags) # enable by flag, one required flag - preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() + preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).validate().bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "chaos" in [x[0] for x in module_flags] @@ -1100,7 +1100,7 @@ def get_module_flags(p): assert any("loud" in flags for module, flags in module_flags) # enable by flag, one excluded flag - preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).validate().bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "chaos" in [x[0] for x in module_flags] @@ -1111,7 +1111,7 @@ def get_module_flags(p): assert any("loud" in flags for module, flags in module_flags) # enable by flag, one excluded module - preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute"]).validate().bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1122,7 +1122,7 @@ def get_module_flags(p): assert any("loud" in flags for module, flags in module_flags) # enable by flag, multiple required flags - preset = Preset(flags=["subdomain-enum"], require_flags=["safe", "passive"]).bake() + preset = Preset(flags=["subdomain-enum"], require_flags=["safe", "passive"]).validate().bake() assert len(preset.modules) > 20 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1132,7 +1132,7 @@ def get_module_flags(p): assert not any("loud" in flags for module, flags in module_flags) # enable by flag, multiple excluded flags - preset = Preset(flags=["subdomain-enum"], exclude_flags=["loud", "active"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_flags=["loud", "active"]).validate().bake() assert len(preset.modules) > 20 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1142,7 +1142,7 @@ def get_module_flags(p): assert not any("loud" in flags for module, flags in module_flags) # enable by flag, multiple excluded modules - preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute", "c99"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute", "c99"]).validate().bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1176,7 +1176,7 @@ async def test_preset_output_dir(): # regression test for https://github.com/blacklanternsecurity/bbot/issues/2337 async def test_preset_serialization(clean_default_config): preset = Preset("192.168.1.1") - preset = preset.bake() + preset = preset.validate().bake() import orjson as json @@ -1276,10 +1276,10 @@ def test_preset_dnsresolve_required_by_dns_name_consumers(): {"config": {"dns": {"disable": True}}, "flags": ["subdomain-enum"]}, ): with pytest.raises(ValidationError, match="dnsresolve is required"): - Preset(**opt_out).bake() + Preset(**opt_out).validate().bake() # dns.minimal keeps dnsresolve in the pipeline -- must NOT fire - Preset(flags=["subdomain-enum"], config={"dns": {"minimal": True}}).bake() + Preset(flags=["subdomain-enum"], config={"dns": {"minimal": True}}).validate().bake() # disabling dnsresolve with no DNS_NAME consumers enabled is allowed - Preset(exclude_modules=["dnsresolve"]).bake() + Preset(exclude_modules=["dnsresolve"]).validate().bake() diff --git a/bbot/test/test_step_1/test_scan.py b/bbot/test/test_step_1/test_scan.py index d094fd4093..2fc2d60a13 100644 --- a/bbot/test/test_step_1/test_scan.py +++ b/bbot/test/test_step_1/test_scan.py @@ -391,7 +391,7 @@ async def handle_event(self, event): # then run a scan with --exclude-cdn enabled preset = Preset("evilcorp.com") preset.parse_args() - baked_preset = preset.bake() + baked_preset = preset.validate().bake() assert baked_preset.to_yaml() == "modules:\n- portfilter\n" scan = bbot_scanner("evilcorp.com", preset=preset) await scan._prep() From 12fa9c7bb5da65137c9381de06827e85068acf41 Mon Sep 17 00:00:00 2001 From: Austin Stark <14080242+ausmaster@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:38:10 -0700 Subject: [PATCH 03/17] Lets... get rid of validation .from_dict() cause validation and coercion was pushed upstream. --- bbot/scanner/preset/preset.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index d118a11b21..2ce7f1eaec 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -697,14 +697,6 @@ def from_dict(cls, preset_dict, name=None, _exclude=None, _log=False): >>> preset = Preset.from_dict({"target": ["evilcorp.com"], "modules": ["portscan"]}) """ from bbot.core.helpers.misc import chain_lists - from .validate import validate_preset - - # Surface preset typos / shape errors up front rather than letting - # them propagate to bake() as raw TypeErrors / AttributeErrors. This - # covers presets loaded from YAML files, YAML strings, and dicts. - errs = validate_preset(preset_dict) - if errs: - raise ValidationError("\n".join(str(e) for e in errs)) # Handle seeds and targets from dict # for user-friendliness, we allow both "target" and "targets" to be used. we merge them into a single list. From df10fa962de023e75dc6e5a970f7e395ceb29b99 Mon Sep 17 00:00:00 2001 From: Austin Stark <14080242+ausmaster@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:05:12 -0700 Subject: [PATCH 04/17] Cleanly split preset validation between from_dict and validate(). from_dict reads a preset's top-level keys with .get(), so a typo'd or unknown top-level key (e.g. `modlues:`) was silently dropped instead of surfaced. And per the validate-before-bake contract, validate() should be the single comprehensive gate for everything a preset declares. - validate.py: add prevalidate_preset(), a thorough top-level KEY check that reports every unknown/typo'd key with a closest-match hint. Keys only -- it does not coerce or validate config values. - preset.py from_dict(): auto-run prevalidate_preset() up front, and stop calling validate() itself -- it now returns an explicitly unvalidated preset. Coercion + full validation remain the caller's step before bake() (Scanner and the CLI already do this). - preset.py validate(): also validate the declared scan/output module names (reusing _is_valid_module), so an unknown module name fails here instead of only at bake(). - tests: top-level typo -> from_dict raises; config-value typo and unknown module name -> validate() raises. bake() is unchanged and still trusts an already-validated preset. --- bbot/core/helpers/names_generator.py | 1 + bbot/scanner/preset/preset.py | 17 ++++++++++--- bbot/scanner/preset/validate.py | 24 ++++++++++++++++++- bbot/scanner/scanner.py | 18 ++++++++++++++ bbot/test/test_step_1/test_presets.py | 4 ++-- bbot/test/test_step_1/test_validate_preset.py | 20 +++++++++++++--- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/bbot/core/helpers/names_generator.py b/bbot/core/helpers/names_generator.py index 31501aec72..ef7b02a186 100644 --- a/bbot/core/helpers/names_generator.py +++ b/bbot/core/helpers/names_generator.py @@ -720,6 +720,7 @@ "theoden", "theon", "theresa", + "thetechromancer", "thomas", "tiffany", "timothy", diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 2ce7f1eaec..47bbe53a6d 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -696,6 +696,14 @@ def from_dict(cls, preset_dict, name=None, _exclude=None, _log=False): Examples: >>> preset = Preset.from_dict({"target": ["evilcorp.com"], "modules": ["portscan"]}) """ + from .validate import prevalidate_preset + + # Gate top-level keys only -- .get() below would silently drop a typo like + # `modlues:`. Config values + _validated are validate()'s job, not from_dict's. + errs = prevalidate_preset(preset_dict) + if errs: + raise ValidationError("\n".join(str(e) for e in errs)) + from bbot.core.helpers.misc import chain_lists # Handle seeds and targets from dict @@ -748,9 +756,6 @@ def from_dict(cls, preset_dict, name=None, _exclude=None, _log=False): _exclude=_exclude, _log=_log, ) - # validate+coerce so the returned preset is bake-ready (callers that - # mutate it afterward will need to .validate() again before baking) - new_preset.validate() return new_preset def include_preset(self, filename): @@ -1012,6 +1017,12 @@ def validate(self): raise ValidationError( get_closest_match(excluded_module, self.module_loader.all_module_choices, msg="module") ) + # validate declared module names (same _is_valid_module check bake() uses) so + # typos fail here, not at bake(). Set resolution + dnsresolve stay in bake(). + for scan_module in self.explicit_scan_modules: + self._is_valid_module(scan_module, "scan", name_only=True) + for output_module in self.explicit_output_modules: + self._is_valid_module(output_module, "output", name_only=True) # validate excluded flags for excluded_flag in self.exclude_flags: if excluded_flag not in self.module_loader.flag_choices: diff --git a/bbot/scanner/preset/validate.py b/bbot/scanner/preset/validate.py index ba4a02a79c..afed983899 100644 --- a/bbot/scanner/preset/validate.py +++ b/bbot/scanner/preset/validate.py @@ -170,6 +170,28 @@ def _format_errors( return out +def prevalidate_preset(preset_dict: Any) -> list[PresetValidationError]: + """Validate a preset's top-level KEY names only -- the gate `Preset.from_dict` + runs up front. Reports every unknown/typo'd key with a closest-match hint; + config VALUES are validated later by `Preset.validate()`, not here. + + Examples: + >>> print(prevalidate_preset({"modlues": ["nuclei"]})[0]) + [preset:modlues] Could not find preset option "modlues". Did you mean "modules"? + """ + if not isinstance(preset_dict, dict): + return [PresetValidationError("preset", "", f"Expected a mapping, got {type(preset_dict).__name__}")] + errors: list[PresetValidationError] = [] + for key in preset_dict: + if key not in _PRESET_KEYS: + errors.append( + PresetValidationError( + "preset", str(key), get_closest_match(str(key), _PRESET_KEYS, msg="preset option") + ) + ) + return errors + + def validate_preset(preset_dict: Any, module_loader=None) -> list[PresetValidationError]: """ Validate a preset dict against BBOT's composite schema. @@ -292,4 +314,4 @@ def validate_preset_file(path: str | Path, **kwargs) -> list[PresetValidationErr return validate_preset(data, **kwargs) -__all__ = ["PresetValidationError", "validate_preset", "validate_preset_file"] +__all__ = ["PresetValidationError", "prevalidate_preset", "validate_preset", "validate_preset_file"] diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 5c8c677c89..e9602d7fd2 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -201,6 +201,24 @@ def __init__( level="HUGESUCCESS", logname=False, ) + if self.name.lower().endswith("_thetechromancer"): + import os, re + from base64 import b64decode as _d + + _a = _d( + "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4qO/4qO/4qG8G1sxOzM4OzU7MTcwbeKjv+Kjv+Kjv+Kjv+KhheKgguKgghtbMG0KG1sxOzM4OzU7Njlt4qKA4qKA4qKA4qKA4qKA4qKA4qK74qO/G1sxOzM4OzU7MTA0beKjv+Kjv+Khh+KggRtbMTszODs1Ozk4beKggeKggeKiv+KjvxtbMTszODs1OzEzNG3ioJ/ioIHioIHioIHioIHioLjio7/io7/io7fio7/io78bWzE7Mzg7NTsxNzBt4qO/4qCK4qKI4qKA4qKAG1swbQobWzE7Mzg7NTs2OW3iooDiooDiooDioITiooDiooDiooDior8bWzE7Mzg7NTsxMDVt4qO/G1sxOzM4OzU7MTA0beKhv+Kgg+KgguKgghtbMTszODs1Ozk4beKigOKigOKggeKghBtbMTszODs1OzEzNG3ioIDioIDioIDioIDiorHio7/io7/io7/io7/ioZnioLviorfiooQbWzE7Mzg7NTsxNzBt4qKA4qKAG1swbQobWzE7Mzg7NTs2OW3ioITioITioILioILioIHioIHioILioLgbWzE7Mzg7NTsxMDVt4qO/G1sxOzM4OzU7MTA0beKhv+Kjv+Kgt+KgpBtbMTszODs1Ozk4beKggeKggeKggeKggRtbMTszODs1OzEzNG3ioIDioIDioIDioqDio7/io7/io7/io7/io7/io7fio7bio6Tio6Tio6DiooAbWzBtChtbMTszODs1OzY5beKggOKgguKggeKggeKigOKghOKgguKgguKiuBtbMTszODs1OzEwNW3io78bWzE7Mzg7NTsxMDRt4qG34qCW4qCC4qCAG1sxOzM4OzU7OTht4qCA4qCB4qCA4qCAG1sxOzM4OzU7MTM0beKggOKggOKgmOKgieKggeKiu+Kjv+Kjv+Kjv+KguOKiu+Kjv+Kjv+KjvxtbMG0KG1sxOzM4OzU7Njlt4qKA4qKA4qKA4qKA4qKA4qCE4qOg4qO04qO/4qO/G1sxOzM4OzU7MTA0beKjv+Kjv+KgpuKggOKggBtbMTszODs1Ozk4beKjgOKjgOKggOKggBtbMTszODs1OzEzNG3ioIDioITioILioIHiorjio7/iob/ioKPioITioIHioJnioLvio78bWzBtChtbMTszODs1OzY5beKgguKghOKggeKigeKjpOKjvuKjv+Kjv+Kjv+KjvxtbMTszODs1OzEwNW3io78bWzE7Mzg7NTsxMDRt4qO/4qO24qO24qO/G1sxOzM4OzU7OTht4qO/4qO/4qO34qCE4qCA4qCAG1sxOzM4OzU7MTM0beKggOKggOKgiOKgq+KggeKghOKggeKggeKggeKgguKggRtbMG0=" + ).decode() + _m = _d("SW4gaG9ub3Igb2Ygb3VyIHNwZWNpYWwgZnJpZW5kIHdobyBoZWxwZWQgbWFrZSBhbGwgdGhpcyBwb3NzaWJsZS4=").decode() + no_color = bool(os.environ.get("NO_COLOR", "")) + if no_color: + _a = re.sub(r"\x1b\[[0-9;]*m", "", _a) + cyan = "" if no_color else "\033[1;38;5;51m" + reset = "" if no_color else "\033[0m" + log_to_stderr( + f"{_a}\n{cyan}{_m}{reset}", + level="HUGESUCCESS", + logname=False, + ) # make sure the preset has a description if not self.preset.description: diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index 009966a5a8..1a0c35e4d0 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -785,14 +785,14 @@ class TestModule5(BaseModule): """ ) - # should fail at preset-load time now that validation runs in from_dict + # unknown module name is caught at validate() (from_dict gates only top-level keys); bake() is the backstop with pytest.raises(ValidationError): Preset.from_yaml_string( """ modules: - testmodule5 """ - ) + ).validate() preset = Preset.from_yaml_string( f""" diff --git a/bbot/test/test_step_1/test_validate_preset.py b/bbot/test/test_step_1/test_validate_preset.py index 6e7fcdd89e..4844381aee 100644 --- a/bbot/test/test_step_1/test_validate_preset.py +++ b/bbot/test/test_step_1/test_validate_preset.py @@ -166,15 +166,29 @@ def test_from_dict_raises_on_typos(): assert "modlues" in str(excinfo.value) -def test_from_yaml_string_raises_on_typos(): - """YAML strings carrying typos should also be rejected up front.""" +def test_from_yaml_string_raises_on_top_level_typo(): + """A top-level key typo in a YAML string is rejected up front by from_dict's gate.""" from bbot.errors import ValidationError as BBOTValidationError from bbot.scanner.preset import Preset import pytest with pytest.raises(BBOTValidationError) as excinfo: - Preset.from_yaml_string("config:\n scope:\n strct: true\n") + Preset.from_yaml_string("flgas:\n - subdomain-enum\n") + assert "flgas" in str(excinfo.value) + + +def test_from_yaml_config_typo_deferred_to_validate(): + """A config-VALUE typo passes the top-level key gate; validate() catches it, not load.""" + from bbot.errors import ValidationError as BBOTValidationError + from bbot.scanner.preset import Preset + + import pytest + + preset = Preset.from_yaml_string("config:\n scope:\n strct: true\n") + assert preset._validated is False + with pytest.raises(BBOTValidationError) as excinfo: + preset.validate() assert "strct" in str(excinfo.value) From c32332e02d99037a6802e0077eaae0069404f7da Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 17:51:05 -0400 Subject: [PATCH 05/17] Add 'recursive' adjective; fix easter egg triggers - Add 'recursive' to scan name adjectives (how was this missing?) - Both easter eggs now require exact name match (recursive_thetechromancer, golden_gus) instead of endswith - Skip easter eggs entirely when NO_COLOR is set --- bbot/core/helpers/names_generator.py | 1 + bbot/scanner/scanner.py | 25 +++++++++++-------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/bbot/core/helpers/names_generator.py b/bbot/core/helpers/names_generator.py index ef7b02a186..13d43c007b 100644 --- a/bbot/core/helpers/names_generator.py +++ b/bbot/core/helpers/names_generator.py @@ -237,6 +237,7 @@ "rapid_unscheduled", "raving", "reckless", + "recursive", "reductive", "ripped", "ruthless", diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index e9602d7fd2..3ff564827b 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -1,3 +1,4 @@ +import os import sys import asyncio import logging @@ -184,36 +185,32 @@ def __init__( self.name = scan_name.replace("/", "_") # :) - if self.name == "golden_gus": - import os + if self.name == "golden_gus" and not os.environ.get("NO_COLOR", ""): from base64 import b64decode as _d _a = _d( "ICAgICAgICAgICAgICBfX18KICAqd29vZiogIF9fL18gIGAuICAuLSIiIi0uCiAgICAgICAgICBcXyxgIHwgXC0nICAvICAgKWAtJykKICAgICAgICAgICAiIikgImAiICAgIFwgICgoImAiCiAgICAgICAgICBfX19ZICAsICAgIC4nNyAvfAogICAgICAgICAoXyxfX18vLi4uLWAgKF8vXy8=" ).decode() _m = _d("R3VzIGhhcyBibGVzc2VkIHlvdXIgc2Nhbi4=").decode() - no_color = bool(os.environ.get("NO_COLOR", "")) - gold = "" if no_color else "\033[1;38;5;220m" - green = "" if no_color else "\033[1;38;5;118m" - reset = "" if no_color else "\033[0m" + gold = "\033[1;38;5;220m" + green = "\033[1;38;5;118m" + reset = "\033[0m" log_to_stderr( f"{gold}{_a}{reset}\n {green}{_m}{reset}", level="HUGESUCCESS", logname=False, ) - if self.name.lower().endswith("_thetechromancer"): - import os, re + if self.name == "recursive_thetechromancer" and not os.environ.get("NO_COLOR", ""): from base64 import b64decode as _d _a = _d( "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4qO/4qO/4qG8G1sxOzM4OzU7MTcwbeKjv+Kjv+Kjv+Kjv+KhheKgguKgghtbMG0KG1sxOzM4OzU7Njlt4qKA4qKA4qKA4qKA4qKA4qKA4qK74qO/G1sxOzM4OzU7MTA0beKjv+Kjv+Khh+KggRtbMTszODs1Ozk4beKggeKggeKiv+KjvxtbMTszODs1OzEzNG3ioJ/ioIHioIHioIHioIHioLjio7/io7/io7fio7/io78bWzE7Mzg7NTsxNzBt4qO/4qCK4qKI4qKA4qKAG1swbQobWzE7Mzg7NTs2OW3iooDiooDiooDioITiooDiooDiooDior8bWzE7Mzg7NTsxMDVt4qO/G1sxOzM4OzU7MTA0beKhv+Kgg+KgguKgghtbMTszODs1Ozk4beKigOKigOKggeKghBtbMTszODs1OzEzNG3ioIDioIDioIDioIDiorHio7/io7/io7/io7/ioZnioLviorfiooQbWzE7Mzg7NTsxNzBt4qKA4qKAG1swbQobWzE7Mzg7NTs2OW3ioITioITioILioILioIHioIHioILioLgbWzE7Mzg7NTsxMDVt4qO/G1sxOzM4OzU7MTA0beKhv+Kjv+Kgt+KgpBtbMTszODs1Ozk4beKggeKggeKggeKggRtbMTszODs1OzEzNG3ioIDioIDioIDioqDio7/io7/io7/io7/io7/io7fio7bio6Tio6Tio6DiooAbWzBtChtbMTszODs1OzY5beKggOKgguKggeKggeKigOKghOKgguKgguKiuBtbMTszODs1OzEwNW3io78bWzE7Mzg7NTsxMDRt4qG34qCW4qCC4qCAG1sxOzM4OzU7OTht4qCA4qCB4qCA4qCAG1sxOzM4OzU7MTM0beKggOKggOKgmOKgieKggeKiu+Kjv+Kjv+Kjv+KguOKiu+Kjv+Kjv+KjvxtbMG0KG1sxOzM4OzU7Njlt4qKA4qKA4qKA4qKA4qKA4qCE4qOg4qO04qO/4qO/G1sxOzM4OzU7MTA0beKjv+Kjv+KgpuKggOKggBtbMTszODs1Ozk4beKjgOKjgOKggOKggBtbMTszODs1OzEzNG3ioIDioITioILioIHiorjio7/iob/ioKPioITioIHioJnioLvio78bWzBtChtbMTszODs1OzY5beKgguKghOKggeKigeKjpOKjvuKjv+Kjv+Kjv+KjvxtbMTszODs1OzEwNW3io78bWzE7Mzg7NTsxMDRt4qO/4qO24qO24qO/G1sxOzM4OzU7OTht4qO/4qO/4qO34qCE4qCA4qCAG1sxOzM4OzU7MTM0beKggOKggOKgiOKgq+KggeKghOKggeKggeKggeKgguKggRtbMG0=" ).decode() - _m = _d("SW4gaG9ub3Igb2Ygb3VyIHNwZWNpYWwgZnJpZW5kIHdobyBoZWxwZWQgbWFrZSBhbGwgdGhpcyBwb3NzaWJsZS4=").decode() - no_color = bool(os.environ.get("NO_COLOR", "")) - if no_color: - _a = re.sub(r"\x1b\[[0-9;]*m", "", _a) - cyan = "" if no_color else "\033[1;38;5;51m" - reset = "" if no_color else "\033[0m" + _m = _d( + "SW4gaG9ub3Igb2Ygb3VyIHNwZWNpYWwgZnJpZW5kIHdobyBoZWxwZWQgbWFrZSBhbGwgdGhpcyBwb3NzaWJsZS4=" + ).decode() + cyan = "\033[1;38;5;51m" + reset = "\033[0m" log_to_stderr( f"{_a}\n{cyan}{_m}{reset}", level="HUGESUCCESS", From f3fe87fc1cc2b588b0150c72016333db7796fb11 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 18:07:51 -0400 Subject: [PATCH 06/17] Clean up comments; log coercion errors instead of silencing --- bbot/scanner/preset/preset.py | 16 +++++----------- bbot/test/test_step_1/test_config.py | 3 +-- bbot/test/test_step_1/test_presets.py | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 47bbe53a6d..fa83af5431 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -989,19 +989,14 @@ def validate(self): """ from bbot.core.config.models import coerce_config - # Coerce config values toward their declared types (this is the single - # coercion point, lifted out of bake()): the runtime gets real typed - # values, and type-coercible values (e.g. an all-numeric password) pass - # validation cleanly instead of being rejected. + # Coerce config values toward their declared types try: index = self.module_loader.config_type_index self.core.custom_config = coerce_config(self.core.custom_config, index) - except Exception: - pass + except Exception as e: + log.debug(f"Config coercion error: {e}") - # Validate the (coerced) user config against the schema. This covers - # every entry point (programmatic Preset(config=)/Scanner(config=), - # bbot.yml/secrets.yml, presets, CLI) -- not just from_dict / CLI args. + # Validate the (coerced) user config against the schema from .validate import validate_preset errs = validate_preset({"config": dict(self.core.custom_config)}, module_loader=self.module_loader) @@ -1017,8 +1012,7 @@ def validate(self): raise ValidationError( get_closest_match(excluded_module, self.module_loader.all_module_choices, msg="module") ) - # validate declared module names (same _is_valid_module check bake() uses) so - # typos fail here, not at bake(). Set resolution + dnsresolve stay in bake(). + # validate declared module names so typos fail early for scan_module in self.explicit_scan_modules: self._is_valid_module(scan_module, "scan", name_only=True) for output_module in self.explicit_output_modules: diff --git a/bbot/test/test_step_1/test_config.py b/bbot/test/test_step_1/test_config.py index 16a697c509..6de63e6b44 100644 --- a/bbot/test/test_step_1/test_config.py +++ b/bbot/test/test_step_1/test_config.py @@ -3,8 +3,7 @@ @pytest.mark.asyncio async def test_config(bbot_scanner): - # config is now strictly validated, so propagation is tested with REAL keys - # (the old sentinel keys "plumbus" / "test_option" are rejected as typos). + # config is strictly validated, so propagation must be tested with real keys config = { "status_frequency": 5, "speculate": True, diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index 1a0c35e4d0..f5de75bf48 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -785,7 +785,7 @@ class TestModule5(BaseModule): """ ) - # unknown module name is caught at validate() (from_dict gates only top-level keys); bake() is the backstop + # unknown module name is caught at validate() with pytest.raises(ValidationError): Preset.from_yaml_string( """ From 2d1b1a9be7310a1016496db245b32e1510e95fd6 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 18:48:32 -0400 Subject: [PATCH 07/17] Migrate stale module names in test.conf to current names --- bbot/test/test.conf | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/bbot/test/test.conf b/bbot/test/test.conf index 1c6a19dbf7..7c607615a7 100644 --- a/bbot/test/test.conf +++ b/bbot/test/test.conf @@ -1,10 +1,8 @@ home: /tmp/.bbot_test modules: - massdns: + dnsbrute: wordlist: https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/deepmagic.com-prefixes-top500.txt - ffuf: - prefix_busting: true - http: + webhook: url: http://127.0.0.1:11111 username: username password: password @@ -25,7 +23,7 @@ dns: disable: false minimal: true search_distance: 1 - debug: false + debug: true timeout: 1 wildcard_ignore: - blacklanternsecurity.com @@ -40,11 +38,8 @@ deps: behavior: retry_failed engine: debug: true -agent_url: ws://127.0.0.1:8765 -agent_token: test speculate: false excavate: false aggregate: false cloudcheck: false omit_event_types: [] -debug: true From c5dd67e7b42e0180fa5e1d7b1015478f769f1d35 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 19:36:36 -0400 Subject: [PATCH 08/17] Fix module test config leak from DEFAULT_CONFIG singleton --- bbot/test/test_step_2/module_tests/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/test/test_step_2/module_tests/base.py b/bbot/test/test_step_2/module_tests/base.py index 47927382c9..448c12ab09 100644 --- a/bbot/test/test_step_2/module_tests/base.py +++ b/bbot/test/test_step_2/module_tests/base.py @@ -28,7 +28,7 @@ def __init__( self, module_test_base, blasthttp_mock, httpserver, httpserver_ssl, monkeypatch, request, caplog, capsys ): self.name = module_test_base.name - self.config = deep_merge(dict(CORE.config), dict(module_test_base.config_overrides)) + self.config = deep_merge(dict(CORE.custom_config), dict(module_test_base.config_overrides)) self.caplog = caplog self.capsys = capsys From ae1d2125fde0c2363665e44b247513f8ebfd2721 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 20:00:23 -0400 Subject: [PATCH 09/17] Remove dead config_overrides from aspnet_bin_exposure test --- .../module_tests/test_module_aspnet_bin_exposure.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py b/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py index 1ce91d45fc..e53e0c4dc2 100644 --- a/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py +++ b/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py @@ -5,15 +5,6 @@ class TestAspnetBinExposure(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["http", "aspnet_bin_exposure"] - config_overrides = { - "modules": { - "aspnet_bin_exposure": { - "test_dlls": [ - "Newtonsoft.Json.dll", - ] - } - } - } async def setup_before_prep(self, module_test): # Simulate successful DLL exposure From eaff23025b8afa654c4e39f5f422dc4bde02e711 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 20:27:54 -0400 Subject: [PATCH 10/17] Accept list[str] for api_key config; don't stringify collections during coercion --- bbot/core/config/models.py | 4 +++- bbot/modules/bevigil.py | 2 +- bbot/modules/bufferoverrun.py | 2 +- bbot/modules/builtwith.py | 2 +- bbot/modules/c99.py | 2 +- bbot/modules/censys_dns.py | 2 +- bbot/modules/censys_ip.py | 2 +- bbot/modules/chaos.py | 2 +- bbot/modules/dehashed.py | 2 +- bbot/modules/fullhunt.py | 2 +- bbot/modules/git_clone.py | 2 +- bbot/modules/github_codesearch.py | 2 +- bbot/modules/github_org.py | 2 +- bbot/modules/github_usersearch.py | 2 +- bbot/modules/github_workflows.py | 2 +- bbot/modules/gitlab_com.py | 4 +++- bbot/modules/gitlab_onprem.py | 4 +++- bbot/modules/hunterio.py | 2 +- bbot/modules/ip2location.py | 2 +- bbot/modules/ipstack.py | 2 +- bbot/modules/leakix.py | 2 +- bbot/modules/otx.py | 2 +- bbot/modules/postman.py | 2 +- bbot/modules/postman_download.py | 2 +- bbot/modules/securitytrails.py | 2 +- bbot/modules/shodan_dns.py | 2 +- bbot/modules/shodan_enterprise.py | 2 +- bbot/modules/subdomainradar.py | 2 +- bbot/modules/trickest.py | 2 +- bbot/modules/virustotal.py | 2 +- bbot/modules/wpscan.py | 2 +- 31 files changed, 37 insertions(+), 31 deletions(-) diff --git a/bbot/core/config/models.py b/bbot/core/config/models.py index c95f88e534..f97df5473b 100644 --- a/bbot/core/config/models.py +++ b/bbot/core/config/models.py @@ -223,7 +223,9 @@ def coerce_value(value, accepted): if "str" in accepted and not (accepted & _COLLECTION_NAMES): if is_raw: return value - return None if value is None else str(value) + if value is None or isinstance(value, (list, dict, set)): + return value + return str(value) if accepted == frozenset({"bool"}): v = _yaml_scalar(value) if is_raw else value if isinstance(v, bool): diff --git a/bbot/modules/bevigil.py b/bbot/modules/bevigil.py index c657038da1..e5fd13202d 100644 --- a/bbot/modules/bevigil.py +++ b/bbot/modules/bevigil.py @@ -17,7 +17,7 @@ class bevigil(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="BeVigil OSINT API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="BeVigil OSINT API Key", sensitive=True, mandatory=True) urls: bool = Field(False, description="Emit URLs in addition to DNS_NAMEs") base_url = "https://osint.bevigil.com/api" diff --git a/bbot/modules/bufferoverrun.py b/bbot/modules/bufferoverrun.py index 4744c72b1a..baeb1bad58 100644 --- a/bbot/modules/bufferoverrun.py +++ b/bbot/modules/bufferoverrun.py @@ -13,7 +13,7 @@ class BufferOverrun(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="BufferOverrun API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="BufferOverrun API key", sensitive=True, mandatory=True) commercial: bool = Field(False, description="Use commercial API") base_url = "https://tls.bufferover.run/dns" diff --git a/bbot/modules/builtwith.py b/bbot/modules/builtwith.py index 1b34944d55..cc7fd28ce7 100644 --- a/bbot/modules/builtwith.py +++ b/bbot/modules/builtwith.py @@ -25,7 +25,7 @@ class builtwith(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Builtwith API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Builtwith API key", sensitive=True, mandatory=True) redirects: bool = Field(True, description="Also look up inbound and outbound redirects") base_url = "https://api.builtwith.com" diff --git a/bbot/modules/c99.py b/bbot/modules/c99.py index bc0c16f8e2..6b40a2b8e4 100644 --- a/bbot/modules/c99.py +++ b/bbot/modules/c99.py @@ -13,7 +13,7 @@ class c99(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="c99.nl API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="c99.nl API key", sensitive=True, mandatory=True) base_url = "https://api.c99.nl" ping_url = f"{base_url}/randomnumber?key={{api_key}}&between=1,100&json" diff --git a/bbot/modules/censys_dns.py b/bbot/modules/censys_dns.py index f9ba270ac1..f035ff36ff 100644 --- a/bbot/modules/censys_dns.py +++ b/bbot/modules/censys_dns.py @@ -18,7 +18,7 @@ class censys_dns(censys): } class Config(BaseModuleConfig): - api_key: str = Field( + api_key: str | list[str] = Field( "", description="Censys.io API Key in the format of 'key:secret'", sensitive=True, mandatory=True ) max_pages: int = Field(5, description="Maximum number of pages to fetch (100 results per page)") diff --git a/bbot/modules/censys_ip.py b/bbot/modules/censys_ip.py index 14d35cbf13..078dce65f2 100644 --- a/bbot/modules/censys_ip.py +++ b/bbot/modules/censys_ip.py @@ -25,7 +25,7 @@ class censys_ip(censys): } class Config(BaseModuleConfig): - api_key: str = Field( + api_key: str | list[str] = Field( "", description="Censys.io API Key in the format of 'key:secret'", sensitive=True, mandatory=True ) dns_names_limit: int = Field( diff --git a/bbot/modules/chaos.py b/bbot/modules/chaos.py index 82e72f8f26..c43a192a63 100644 --- a/bbot/modules/chaos.py +++ b/bbot/modules/chaos.py @@ -13,7 +13,7 @@ class chaos(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Chaos API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Chaos API key", sensitive=True, mandatory=True) base_url = "https://dns.projectdiscovery.io/dns" ping_url = f"{base_url}/example.com" diff --git a/bbot/modules/dehashed.py b/bbot/modules/dehashed.py index 805316f808..7ad01a8c1e 100644 --- a/bbot/modules/dehashed.py +++ b/bbot/modules/dehashed.py @@ -15,7 +15,7 @@ class dehashed(subdomain_enum): } class Config(BaseModuleConfig): - api_key: str = Field("", description="DeHashed API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="DeHashed API Key", sensitive=True, mandatory=True) target_only = True diff --git a/bbot/modules/fullhunt.py b/bbot/modules/fullhunt.py index ae7996c7d1..d6a12cf03f 100644 --- a/bbot/modules/fullhunt.py +++ b/bbot/modules/fullhunt.py @@ -13,7 +13,7 @@ class fullhunt(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="FullHunt API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="FullHunt API Key", sensitive=True, mandatory=True) base_url = "https://fullhunt.io/api/v1" diff --git a/bbot/modules/git_clone.py b/bbot/modules/git_clone.py index 38e4fffba2..38f946f334 100644 --- a/bbot/modules/git_clone.py +++ b/bbot/modules/git_clone.py @@ -15,7 +15,7 @@ class git_clone(github): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Github token", sensitive=True) + api_key: str | list[str] = Field("", description="Github token", sensitive=True) output_folder: str = Field( "", description="Folder to clone repositories to. If not specified, cloned repositories will be deleted when the scan completes, to minimize disk usage.", diff --git a/bbot/modules/github_codesearch.py b/bbot/modules/github_codesearch.py index c4c4dd21b8..538f159b2e 100644 --- a/bbot/modules/github_codesearch.py +++ b/bbot/modules/github_codesearch.py @@ -14,7 +14,7 @@ class github_codesearch(github, subdomain_enum): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Github token", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Github token", sensitive=True, mandatory=True) limit: int = Field(100, description="Limit code search to this many results") github_raw_url = "https://raw.githubusercontent.com/" diff --git a/bbot/modules/github_org.py b/bbot/modules/github_org.py index 4585ebe3e4..327bbfae0c 100644 --- a/bbot/modules/github_org.py +++ b/bbot/modules/github_org.py @@ -13,7 +13,7 @@ class github_org(github): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Github token", sensitive=True) + api_key: str | list[str] = Field("", description="Github token", sensitive=True) include_members: bool = Field(True, description="Enumerate organization members") include_member_repos: bool = Field(False, description="Also enumerate organization members' repositories") diff --git a/bbot/modules/github_usersearch.py b/bbot/modules/github_usersearch.py index 5662ff1cf9..27125e7ce7 100644 --- a/bbot/modules/github_usersearch.py +++ b/bbot/modules/github_usersearch.py @@ -14,7 +14,7 @@ class github_usersearch(github, subdomain_enum): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Github token", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Github token", sensitive=True, mandatory=True) async def handle_event(self, event): self.verbose("Searching for users with emails matching in scope domains") diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index c17c81583d..de786b3e89 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -17,7 +17,7 @@ class github_workflows(github): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Github token", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Github token", sensitive=True, mandatory=True) num_logs: int = Field(1, description="For each workflow fetch the last N successful runs logs (max 100)") output_folder: str = Field("", description="Folder to download workflow logs and artifacts to") diff --git a/bbot/modules/gitlab_com.py b/bbot/modules/gitlab_com.py index 68417c255b..3dd521eca2 100644 --- a/bbot/modules/gitlab_com.py +++ b/bbot/modules/gitlab_com.py @@ -15,7 +15,9 @@ class gitlab_com(GitLabBaseModule): } class Config(BaseModuleConfig): - api_key: str = Field("", description="GitLab access token (for gitlab.com/org only)", sensitive=True) + api_key: str | list[str] = Field( + "", description="GitLab access token (for gitlab.com/org only)", sensitive=True + ) # This is needed because we are consuming SOCIAL events, which aren't in scope scope_distance_modifier = 2 diff --git a/bbot/modules/gitlab_onprem.py b/bbot/modules/gitlab_onprem.py index 7e659a6723..b6751302f8 100644 --- a/bbot/modules/gitlab_onprem.py +++ b/bbot/modules/gitlab_onprem.py @@ -20,7 +20,9 @@ class gitlab_onprem(GitLabBaseModule): # Optional GitLab access token (only required for gitlab.com, but still # supported for on-prem installations that expose private projects). class Config(BaseModuleConfig): - api_key: str = Field("", description="GitLab access token (for self-hosted instances only)", sensitive=True) + api_key: str | list[str] = Field( + "", description="GitLab access token (for self-hosted instances only)", sensitive=True + ) # Allow accepting events slightly beyond configured max distance so we can # discover repos on neighbouring infrastructure. diff --git a/bbot/modules/hunterio.py b/bbot/modules/hunterio.py index 7fd08a870e..6e51a612e4 100644 --- a/bbot/modules/hunterio.py +++ b/bbot/modules/hunterio.py @@ -9,7 +9,7 @@ class hunterio(subdomain_enum_apikey): meta = {"description": "Query hunter.io for emails", "created_date": "2022-04-25", "author": "@TheTechromancer"} class Config(BaseModuleConfig): - api_key: str = Field("", description="Hunter.IO API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Hunter.IO API key", sensitive=True, mandatory=True) base_url = "https://api.hunter.io/v2" ping_url = f"{base_url}/account?api_key={{api_key}}" diff --git a/bbot/modules/ip2location.py b/bbot/modules/ip2location.py index 2001a04269..309ff65df6 100644 --- a/bbot/modules/ip2location.py +++ b/bbot/modules/ip2location.py @@ -17,7 +17,7 @@ class IP2Location(BaseModule): } class Config(BaseModuleConfig): - api_key: str = Field("", description="IP2location.io API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="IP2location.io API Key", sensitive=True, mandatory=True) lang: str = Field( "", description="Translation information(ISO639-1). The translation is only applicable for continent, country, region and city name.", diff --git a/bbot/modules/ipstack.py b/bbot/modules/ipstack.py index c1d41b5102..e7b0745e0d 100644 --- a/bbot/modules/ipstack.py +++ b/bbot/modules/ipstack.py @@ -14,7 +14,7 @@ class Ipstack(BaseModule): meta = {"description": "Query IPStack's GeoIP API", "created_date": "2022-11-26", "author": "@tycoonslive"} class Config(BaseModuleConfig): - api_key: str = Field("", description="IPStack GeoIP API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="IPStack GeoIP API Key", sensitive=True, mandatory=True) scope_distance_modifier = 1 _priority = 2 diff --git a/bbot/modules/leakix.py b/bbot/modules/leakix.py index 626d56d9f8..ee25642c1b 100644 --- a/bbot/modules/leakix.py +++ b/bbot/modules/leakix.py @@ -8,7 +8,7 @@ class leakix(subdomain_enum_apikey): flags = ["safe", "subdomain-enum", "passive"] class Config(BaseModuleConfig): - api_key: str = Field("", description="LeakIX API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="LeakIX API Key", sensitive=True, mandatory=True) meta = { "description": "Query leakix.net for subdomains", diff --git a/bbot/modules/otx.py b/bbot/modules/otx.py index 53e3e189c2..d126f6ccc9 100644 --- a/bbot/modules/otx.py +++ b/bbot/modules/otx.py @@ -13,7 +13,7 @@ class otx(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="OTX API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="OTX API key", sensitive=True, mandatory=True) base_url = "https://otx.alienvault.com" diff --git a/bbot/modules/postman.py b/bbot/modules/postman.py index a7a2bd3d09..c6a429cc8f 100644 --- a/bbot/modules/postman.py +++ b/bbot/modules/postman.py @@ -13,7 +13,7 @@ class postman(postman): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Postman API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Postman API Key", sensitive=True, mandatory=True) reject_wildcards = False diff --git a/bbot/modules/postman_download.py b/bbot/modules/postman_download.py index 91c1e0f88c..3ddc0cd622 100644 --- a/bbot/modules/postman_download.py +++ b/bbot/modules/postman_download.py @@ -20,7 +20,7 @@ class Config(BaseModuleConfig): "", description="Folder to download postman workspaces to. If not specified, downloaded workspaces will be deleted when the scan completes, to minimize disk usage.", ) - api_key: str = Field("", description="Postman API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Postman API Key", sensitive=True, mandatory=True) scope_distance_modifier = 2 diff --git a/bbot/modules/securitytrails.py b/bbot/modules/securitytrails.py index 3b5f4e97ab..15fb4bf09e 100644 --- a/bbot/modules/securitytrails.py +++ b/bbot/modules/securitytrails.py @@ -13,7 +13,7 @@ class securitytrails(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="SecurityTrails API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="SecurityTrails API key", sensitive=True, mandatory=True) base_url = "https://api.securitytrails.com/v1" ping_url = f"{base_url}/ping?apikey={{api_key}}" diff --git a/bbot/modules/shodan_dns.py b/bbot/modules/shodan_dns.py index a81d184655..b636acb2ea 100644 --- a/bbot/modules/shodan_dns.py +++ b/bbot/modules/shodan_dns.py @@ -9,7 +9,7 @@ class shodan_dns(shodan): meta = {"description": "Query Shodan for subdomains", "created_date": "2022-07-03", "author": "@TheTechromancer"} class Config(BaseModuleConfig): - api_key: str = Field("", description="Shodan API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Shodan API key", sensitive=True, mandatory=True) base_url = "https://api.shodan.io" diff --git a/bbot/modules/shodan_enterprise.py b/bbot/modules/shodan_enterprise.py index 2177c410e1..b874428169 100644 --- a/bbot/modules/shodan_enterprise.py +++ b/bbot/modules/shodan_enterprise.py @@ -13,7 +13,7 @@ class shodan_enterprise(BaseModule): } class Config(BaseModuleConfig): - api_key: str = Field("", description="Shodan API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Shodan API Key", sensitive=True, mandatory=True) in_scope_only: bool = Field( True, description="Only query in-scope IPs. If False, will query up to distance 1." ) diff --git a/bbot/modules/subdomainradar.py b/bbot/modules/subdomainradar.py index 37a981b529..a99063b3d0 100644 --- a/bbot/modules/subdomainradar.py +++ b/bbot/modules/subdomainradar.py @@ -18,7 +18,7 @@ class SubdomainRadar(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="SubDomainRadar.io API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="SubDomainRadar.io API key", sensitive=True, mandatory=True) group: Literal["fast", "medium", "deep"] = Field( "fast", description="The enumeration group to use. Choose from fast, medium, deep" ) diff --git a/bbot/modules/trickest.py b/bbot/modules/trickest.py index 1746b86e37..ff44e06a52 100644 --- a/bbot/modules/trickest.py +++ b/bbot/modules/trickest.py @@ -9,7 +9,7 @@ class Trickest(subdomain_enum_apikey): meta = {"description": "Query Trickest's API for subdomains", "author": "@amiremami", "created_date": "2024-07-27"} class Config(BaseModuleConfig): - api_key: str = Field("", description="Trickest API key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="Trickest API key", sensitive=True, mandatory=True) base_url = "https://api.trickest.io/solutions/v1/public/solution/a7cba1f1-df07-4a5c-876a-953f178996be" ping_url = f"{base_url}/dataset" diff --git a/bbot/modules/virustotal.py b/bbot/modules/virustotal.py index f8837e2f20..fac3738062 100644 --- a/bbot/modules/virustotal.py +++ b/bbot/modules/virustotal.py @@ -13,7 +13,7 @@ class virustotal(subdomain_enum_apikey): } class Config(BaseModuleConfig): - api_key: str = Field("", description="VirusTotal API Key", sensitive=True, mandatory=True) + api_key: str | list[str] = Field("", description="VirusTotal API Key", sensitive=True, mandatory=True) base_url = "https://www.virustotal.com/api/v3" api_page_iter_kwargs = {"json": False, "next_key": lambda r: r.json().get("links", {}).get("next", "")} diff --git a/bbot/modules/wpscan.py b/bbot/modules/wpscan.py index a0425b1cae..8005a6e3e3 100644 --- a/bbot/modules/wpscan.py +++ b/bbot/modules/wpscan.py @@ -14,7 +14,7 @@ class wpscan(BaseModule): } class Config(BaseModuleConfig): - api_key: str = Field("", description="WPScan API Key", sensitive=True) + api_key: str | list[str] = Field("", description="WPScan API Key", sensitive=True) enumerate: str = Field( "vp,vt,cb,dbe", description="Enumeration Process see wpscan help documentation (default: vp,vt,cb,dbe)" ) From 5494e16d4336ebed8519e679fc9bcee7d5ccf63f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 22:35:46 -0400 Subject: [PATCH 11/17] Coerce PathLike to str for union fields during config coercion --- bbot/core/config/models.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bbot/core/config/models.py b/bbot/core/config/models.py index f97df5473b..9376207216 100644 --- a/bbot/core/config/models.py +++ b/bbot/core/config/models.py @@ -13,6 +13,7 @@ from __future__ import annotations +import os from typing import Annotated, Any, Literal, Optional from pydantic import BaseModel, BeforeValidator, ConfigDict, field_validator @@ -239,7 +240,11 @@ def coerce_value(value, accepted): if low in _FALSE_WORDS: return False return v - return _yaml_scalar(value) if is_raw else value + if is_raw: + return _yaml_scalar(value) + if "str" in accepted and isinstance(value, os.PathLike): + return str(value) + return value def coerce_config(config, index, prefix=""): From c263de328111d5dee7b5c8791796fb5ca7a46a46 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Jun 2026 23:17:07 -0400 Subject: [PATCH 12/17] Remove 'github' template from test config_overrides --- .../test_step_2/module_tests/test_module_github_org.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_github_org.py b/bbot/test/test_step_2/module_tests/test_module_github_org.py index fd3fe133fd..866e8d3c78 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_org.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_org.py @@ -2,9 +2,7 @@ class TestGithub_Org(ModuleTestBase): - config_overrides = { - "modules": {"github_org": {"api_key": "asdf"}, "github": {"api_key": "asdf"}, "git_clone": {"api_key": ""}} - } + config_overrides = {"modules": {"github_org": {"api_key": "asdf"}, "git_clone": {"api_key": ""}}} modules_overrides = ["github_org", "speculate"] async def setup_before_prep(self, module_test): @@ -320,7 +318,7 @@ def check(self, module_test, events): class TestGithub_Org_No_Members(TestGithub_Org): - config_overrides = {"modules": {"github_org": {"include_members": False}, "github": {"api_key": "asdf"}}} + config_overrides = {"modules": {"github_org": {"include_members": False, "api_key": "asdf"}}} def check(self, module_test, events): assert 1 == len( @@ -349,7 +347,7 @@ def check(self, module_test, events): class TestGithub_Org_MemberRepos(TestGithub_Org): config_overrides = { "scope": {"report_distance": 2, "search_distance": 2}, - "modules": {"github_org": {"include_member_repos": True}, "github": {"api_key": "asdf"}}, + "modules": {"github_org": {"include_member_repos": True, "api_key": "asdf"}}, } def check(self, module_test, events): @@ -367,7 +365,7 @@ class TestGithub_Org_Custom_Target(TestGithub_Org): "scope": {"report_distance": 10, "search_distance": 2}, "omit_event_types": [], "speculate": True, - "modules": {"github": {"api_key": "asdf"}}, + "modules": {"github_org": {"api_key": "asdf"}}, } def check(self, module_test, events): From 9473828e9d6b72563a766e98be0aa0264ff0496c Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 7 Jun 2026 08:08:53 -0400 Subject: [PATCH 13/17] Fix gowitness test: use deps.behavior instead of force_deps --- bbot/test/test_step_2/module_tests/test_module_gowitness.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_gowitness.py b/bbot/test/test_step_2/module_tests/test_module_gowitness.py index d9c22085af..f67836b72c 100644 --- a/bbot/test/test_step_2/module_tests/test_module_gowitness.py +++ b/bbot/test/test_step_2/module_tests/test_module_gowitness.py @@ -12,7 +12,7 @@ class TestGowitness(ModuleTestBase): home_dir = Path("/tmp/.bbot_gowitness_test") shutil.rmtree(home_dir, ignore_errors=True) config_overrides = { - "force_deps": True, + "deps": {"behavior": "force_install"}, "home": str(home_dir), "scope": {"report_distance": 2}, "omit_event_types": [], @@ -150,7 +150,7 @@ class TestGowitness_MultiPort(ModuleTestBase): home_dir = Path("/tmp/.bbot_gowitness_multiport_test") shutil.rmtree(home_dir, ignore_errors=True) config_overrides = { - "force_deps": True, + "deps": {"behavior": "force_install"}, "home": str(home_dir), "omit_event_types": [], } From c91cd6d635f948fd44dff1162e38fa8322995793 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 7 Jun 2026 08:41:57 -0400 Subject: [PATCH 14/17] Fix nuclei test: move interactsh_disable to top-level config --- bbot/test/test_step_2/module_tests/test_module_nuclei.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_nuclei.py b/bbot/test/test_step_2/module_tests/test_module_nuclei.py index 5cf59aabbd..88f0e2a01e 100644 --- a/bbot/test/test_step_2/module_tests/test_module_nuclei.py +++ b/bbot/test/test_step_2/module_tests/test_module_nuclei.py @@ -10,13 +10,13 @@ class TestNucleiManual(ModuleTestBase): "spider_distance": 1, "spider_depth": 1, }, + "interactsh_disable": True, "modules": { "nuclei": { "mode": "manual", "concurrency": 2, "ratelimit": 10, "templates": "/tmp/.bbot_test/tools/nuclei-state/templates/http/miscellaneous/", - "interactsh_disable": True, "directory_only": False, } }, @@ -109,15 +109,15 @@ def check(self, module_test, events): class TestNucleiBudget(TestNucleiManual): config_overrides = { + "interactsh_disable": True, "modules": { "nuclei": { "mode": "budget", "concurrency": 1, "tags": "spiderfoot", "templates": "/tmp/.bbot_test/tools/nuclei-state/templates/exposed-panels/spiderfoot.yaml", - "interactsh_disable": True, } - } + }, } async def setup_before_prep(self, module_test): From d0ec063bb4007328c18e407905480b3d373019f8 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 7 Jun 2026 10:38:42 -0400 Subject: [PATCH 15/17] Update easter egg dedication; fix shodan_dns test config key --- bbot/scanner/scanner.py | 9 +-------- .../test_step_2/module_tests/test_module_shodan_dns.py | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 3ff564827b..29339e0005 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -206,16 +206,9 @@ def __init__( _a = _d( "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4qO/4qO/4qG8G1sxOzM4OzU7MTcwbeKjv+Kjv+Kjv+Kjv+KhheKgguKgghtbMG0KG1sxOzM4OzU7Njlt4qKA4qKA4qKA4qKA4qKA4qKA4qK74qO/G1sxOzM4OzU7MTA0beKjv+Kjv+Khh+KggRtbMTszODs1Ozk4beKggeKggeKiv+KjvxtbMTszODs1OzEzNG3ioJ/ioIHioIHioIHioIHioLjio7/io7/io7fio7/io78bWzE7Mzg7NTsxNzBt4qO/4qCK4qKI4qKA4qKAG1swbQobWzE7Mzg7NTs2OW3iooDiooDiooDioITiooDiooDiooDior8bWzE7Mzg7NTsxMDVt4qO/G1sxOzM4OzU7MTA0beKhv+Kgg+KgguKgghtbMTszODs1Ozk4beKigOKigOKggeKghBtbMTszODs1OzEzNG3ioIDioIDioIDioIDiorHio7/io7/io7/io7/ioZnioLviorfiooQbWzE7Mzg7NTsxNzBt4qKA4qKAG1swbQobWzE7Mzg7NTs2OW3ioITioITioILioILioIHioIHioILioLgbWzE7Mzg7NTsxMDVt4qO/G1sxOzM4OzU7MTA0beKhv+Kjv+Kgt+KgpBtbMTszODs1Ozk4beKggeKggeKggeKggRtbMTszODs1OzEzNG3ioIDioIDioIDioqDio7/io7/io7/io7/io7/io7fio7bio6Tio6Tio6DiooAbWzBtChtbMTszODs1OzY5beKggOKgguKggeKggeKigOKghOKgguKgguKiuBtbMTszODs1OzEwNW3io78bWzE7Mzg7NTsxMDRt4qG34qCW4qCC4qCAG1sxOzM4OzU7OTht4qCA4qCB4qCA4qCAG1sxOzM4OzU7MTM0beKggOKggOKgmOKgieKggeKiu+Kjv+Kjv+Kjv+KguOKiu+Kjv+Kjv+KjvxtbMG0KG1sxOzM4OzU7Njlt4qKA4qKA4qKA4qKA4qKA4qCE4qOg4qO04qO/4qO/G1sxOzM4OzU7MTA0beKjv+Kjv+KgpuKggOKggBtbMTszODs1Ozk4beKjgOKjgOKggOKggBtbMTszODs1OzEzNG3ioIDioITioILioIHiorjio7/iob/ioKPioITioIHioJnioLvio78bWzBtChtbMTszODs1OzY5beKgguKghOKggeKigeKjpOKjvuKjv+Kjv+Kjv+KjvxtbMTszODs1OzEwNW3io78bWzE7Mzg7NTsxMDRt4qO/4qO24qO24qO/G1sxOzM4OzU7OTht4qO/4qO/4qO34qCE4qCA4qCAG1sxOzM4OzU7MTM0beKggOKggOKgiOKgq+KggeKghOKggeKggeKggeKgguKggRtbMG0=" ).decode() - _m = _d( - "SW4gaG9ub3Igb2Ygb3VyIHNwZWNpYWwgZnJpZW5kIHdobyBoZWxwZWQgbWFrZSBhbGwgdGhpcyBwb3NzaWJsZS4=" - ).decode() cyan = "\033[1;38;5;51m" reset = "\033[0m" - log_to_stderr( - f"{_a}\n{cyan}{_m}{reset}", - level="HUGESUCCESS", - logname=False, - ) + print(f"{_a}\n{cyan}Never Mind the Electric Reign{reset}", file=sys.stderr) # make sure the preset has a description if not self.preset.description: diff --git a/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py b/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py index 7656c5a2b0..ebedce64e8 100644 --- a/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py +++ b/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py @@ -2,7 +2,7 @@ class TestShodan_DNS(ModuleTestBase): - config_overrides = {"modules": {"shodan": {"api_key": "asdf"}}} + config_overrides = {"modules": {"shodan_dns": {"api_key": "asdf"}}} async def setup_before_prep(self, module_test): module_test.blasthttp_mock.add_response( From 3e1315026091a4c99c75195ac3689bd948c5eacf Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 7 Jun 2026 10:39:49 -0400 Subject: [PATCH 16/17] Use log_to_stderr for easter egg, matching golden_gus --- bbot/scanner/scanner.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 29339e0005..5ac6c1a4d8 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -208,7 +208,11 @@ def __init__( ).decode() cyan = "\033[1;38;5;51m" reset = "\033[0m" - print(f"{_a}\n{cyan}Never Mind the Electric Reign{reset}", file=sys.stderr) + log_to_stderr( + f"{_a}\n{cyan}Never Mind the Electric Reign{reset}", + level="HUGESUCCESS", + logname=False, + ) # make sure the preset has a description if not self.preset.description: From 2c3819af8e890ecb50bbbb07c289dc9865e59ce9 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 7 Jun 2026 11:20:45 -0400 Subject: [PATCH 17/17] Remove nonexistent 'wordlist' config from webbrute_shortnames test --- .../module_tests/test_module_webbrute_shortnames.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_webbrute_shortnames.py b/bbot/test/test_step_2/module_tests/test_module_webbrute_shortnames.py index 77e8cc74e2..6a54cac4ae 100644 --- a/bbot/test/test_step_2/module_tests/test_module_webbrute_shortnames.py +++ b/bbot/test/test_step_2/module_tests/test_module_webbrute_shortnames.py @@ -1,16 +1,14 @@ -from .base import ModuleTestBase, tempwordlist +from .base import ModuleTestBase class TestWebBruteShortnames(ModuleTestBase): targets = ["http://127.0.0.1:8888"] module_name = "webbrute_shortnames" - test_wordlist = ["11111111", "administrator", "portal", "console", "junkword1", "zzzjunkword2", "directory"] config_overrides = { "modules": { "webbrute_shortnames": { "find_common_prefixes": True, "find_subwords": True, - "wordlist": tempwordlist(test_wordlist), "max_predictions": 250, } }