diff --git a/bbot/core/config/models.py b/bbot/core/config/models.py index 5f69456b42..e9a036acd0 100644 --- a/bbot/core/config/models.py +++ b/bbot/core/config/models.py @@ -15,11 +15,13 @@ from typing import Any, Literal, Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from pydantic import Field as _PydanticField from pydantic_core import PydanticUndefined from pydantic_settings import BaseSettings, SettingsConfigDict +from bbot.core.helpers.validators import validate_fqdn_or_ip + STRICT = ConfigDict(extra="forbid") @@ -316,6 +318,8 @@ class BBOTConfig(BaseSettings): interactsh_token: Optional[str] = Field(default=None, sensitive=True) interactsh_disable: Optional[bool] = None + _validate_interactsh_server = field_validator("interactsh_server")(validate_fqdn_or_ip) + # Per-module configs — validated separately, per-module, against each # module's own `class Config(BaseModuleConfig)`. modules: Optional[dict[str, dict[str, Any]]] = None diff --git a/bbot/core/helpers/validators.py b/bbot/core/helpers/validators.py index 27c8bd0fea..577f124756 100644 --- a/bbot/core/helpers/validators.py +++ b/bbot/core/helpers/validators.py @@ -7,7 +7,7 @@ from bbot.core.helpers import regexes from bbot.errors import ValidationError from bbot.core.helpers.url import parse_url, hash_url -from bbot.core.helpers.misc import smart_encode_punycode, split_host_port, make_netloc, is_ip +from bbot.core.helpers.misc import smart_encode_punycode, split_host_port, make_netloc, is_dns_name, is_ip log = logging.getLogger("bbot.core.helpers.validators") @@ -129,6 +129,38 @@ def validate_host(host: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address] raise ValidationError(f'Invalid hostname: "{host}"') +def validate_fqdn_or_ip(host): + """Strict FQDN-or-IP check. Accepts an IPv4/IPv6 address or a hostname + containing at least one dot. Rejects single-label values (e.g. + `localhost`, or a domain typed without its TLD), so domain-shaped + config fields fail at preset-load time instead of surfacing as runtime + errors deep in a scan. + + `None` and the empty string pass through to support optional config + fields with `null` defaults. + + Unlike `validate_host`, this function does not normalize the input + (no port-stripping, lowercasing, or punycode conversion); it returns + the value as supplied. Use it as a pydantic `field_validator` on + schema fields that must be an FQDN or IP and nothing else. + + Examples: + >>> validate_fqdn_or_ip("example.com") + 'example.com' + >>> validate_fqdn_or_ip("192.168.1.1") + '192.168.1.1' + >>> validate_fqdn_or_ip("localhost") + ValueError: not a valid FQDN or IP address: 'localhost' + """ + if host is None or host == "": + return host + if is_ip(host): + return host + if isinstance(host, str) and "." in host and is_dns_name(host): + return host + raise ValueError(f"not a valid FQDN or IP address: {host!r}") + + FINDING_SEVERITY_LEVELS = ("INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL") diff --git a/bbot/modules/webbrute.py b/bbot/modules/webbrute.py index 86859b45c5..06dec5aaa3 100644 --- a/bbot/modules/webbrute.py +++ b/bbot/modules/webbrute.py @@ -1,5 +1,6 @@ import random import string +from typing import Union import blasthttp @@ -25,8 +26,9 @@ class Config(BaseModuleConfig): ) lines: int = Field(5000, description="take only the first N lines from the wordlist when finding directories") max_depth: int = Field(0, description="the maximum directory depth to attempt to solve") - extensions: str = Field( - "", description="Optionally include a list of extensions to extend the keyword with (comma separated)" + extensions: Union[str, list[str]] = Field( + "", + description="Optionally include a list of extensions to extend the keyword with (comma separated or YAML list)", ) ignore_case: bool = Field(False, description="Only put lowercase words into the wordlist") rate: int = Field(0, description="Maximum requests per second (0 = unlimited)") diff --git a/bbot/scanner/preset/environ.py b/bbot/scanner/preset/environ.py index 847c1cc4df..287fc10be9 100644 --- a/bbot/scanner/preset/environ.py +++ b/bbot/scanner/preset/environ.py @@ -69,13 +69,16 @@ def flatten_config(self, config, base="bbot"): {"modules": {"http": {"threads": 10}}} --> ("BBOT_MODULES_HTTP_THREADS", "10") Lists are skipped (they don't translate cleanly to env var values). + None values are skipped too, since `str(None)` would write the literal + string "None" into the env and round-trip back through pydantic-settings + as a string, defeating any field validator that expects a real value. """ if isinstance(config, dict): for k, v in config.items(): new_base = f"{base}_{k}" if isinstance(v, dict): yield from self.flatten_config(v, base=new_base) - elif not isinstance(v, list): + elif v is not None and not isinstance(v, list): yield (new_base.upper(), str(v)) def prepare(self): diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 3779011017..1ddaaca489 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -660,6 +660,14 @@ 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. diff --git a/bbot/scanner/preset/validate.py b/bbot/scanner/preset/validate.py index e81b8366ba..7fdfbf90d8 100644 --- a/bbot/scanner/preset/validate.py +++ b/bbot/scanner/preset/validate.py @@ -26,9 +26,25 @@ from pydantic import ValidationError +from bbot.core.config.models import PresetSchema from bbot.core.helpers.misc import get_closest_match, get_keys_in_dot_syntax +def _preset_top_level_keys() -> set[str]: + """Field names and aliases declared on PresetSchema, used for closest-match + suggestions on unknown top-level preset keys (e.g. `modlues` -> `modules`).""" + keys: set[str] = set() + for name, field in PresetSchema.model_fields.items(): + keys.add(name) + alias = getattr(field, "alias", None) + if alias: + keys.add(alias) + return keys + + +_PRESET_KEYS = _preset_top_level_keys() + + log = logging.getLogger("bbot.presets.validate") @@ -59,6 +75,10 @@ def _classify_loc(loc: tuple) -> tuple[str, str]: if len(parts) >= 2 and parts[0] == "config" and parts[1] == "modules": # Error is somewhere under config.modules.* + if len(parts) == 2: + # The `modules` mapping itself has the wrong shape (e.g. a list). + # Classify as a config-level error rather than walking deeper. + return ("config", "modules") if len(parts) == 3: # The module name itself is unknown (extra_forbidden on ModulesSchema) return ("preset", ".".join(parts)) @@ -84,6 +104,11 @@ def _format_msg(err: dict, known_modules: set | None = None, known_paths: set | # a suggestion drawn from the set of known module names. if len(loc) == 3 and loc[0] == "config" and loc[1] == "modules": return get_closest_match(field, known_modules or set(), msg="module") + # Top-level preset key (e.g. `modlues:`) — suggest from PresetSchema + # field names rather than the dotted config-path universe, so users + # get useful hints like "Did you mean 'modules'?". + if len(loc) == 1: + return get_closest_match(field, _PRESET_KEYS, msg="preset option") # For everything else, suggest from the known dotted-path universe # (`web.spier_distance` → `web.spider_distance`). if known_paths: @@ -116,6 +141,17 @@ def _format_msg(err: dict, known_modules: set | None = None, known_paths: set | return f"Expected one of {expected}, got {input_value!r}" if expected else err.get("msg", "") if kind == "missing": return f"Required option {field!r} is missing" + if kind == "value_error": + # Pydantic wraps ValueError raised inside a field_validator and prefixes + # the message with "Value error, ". Surface the original ValueError text + # so the error reads naturally. + ctx = err.get("ctx") or {} + inner = ctx.get("error") + if inner is not None: + return str(inner) + msg = err.get("msg", "") + prefix = "Value error, " + return msg[len(prefix) :] if msg.startswith(prefix) else msg # Fallback to pydantic's own message return err["msg"] if err.get("msg") else f"validation error at {path}" @@ -173,7 +209,12 @@ def validate_preset(preset_dict: Any, module_loader=None) -> list[PresetValidati preset_dict.get("module_dirs"), config_dict.get("module_dirs") if isinstance(config_dict, dict) else None, ): - for d in source or []: + # Skip non-list shapes (e.g. a string) so we don't iterate characters + # and trigger filesystem calls. The schema pass below reports the + # actual type error. + if not isinstance(source, list): + continue + for d in source: if isinstance(d, str): module_loader.add_module_dir(d) @@ -194,8 +235,13 @@ def validate_preset(preset_dict: Any, module_loader=None) -> list[PresetValidati # Module names listed in top-level `modules`/`output_modules`/`exclude_modules` # aren't covered by the composite schema (they're a list of strings, not a # nested mapping). Check them explicitly, with the same closest-match hint. + # Skip non-list values; the schema pass above already flagged the type error, + # and iterating a string here would yield bogus per-character lookups. for key in ("modules", "output_modules", "exclude_modules"): - for name in preset_dict.get(key) or []: + value = preset_dict.get(key) + if not isinstance(value, list): + continue + for name in value: if name not in known_modules: hint = get_closest_match(name, known_modules, msg="module") errors.append(PresetValidationError(where="preset", path=key, message=hint)) @@ -204,11 +250,23 @@ def validate_preset(preset_dict: Any, module_loader=None) -> list[PresetValidati def validate_preset_file(path: str | Path, **kwargs) -> list[PresetValidationError]: - """Convenience wrapper for validating a YAML preset file on disk.""" + """Convenience wrapper for validating a YAML preset file on disk. + + Returns a list of errors. A missing file or unreadable YAML is reported + as a single error rather than raised, so callers can treat all failure + modes uniformly. + """ import yaml - with open(path) as f: - data = yaml.safe_load(f) or {} + try: + with open(path) as f: + data = yaml.safe_load(f) or {} + except FileNotFoundError: + return [PresetValidationError("preset", "", f"Preset file not found: {path}")] + except OSError as e: + return [PresetValidationError("preset", "", f"Could not read preset file {path}: {e}")] + except yaml.YAMLError as e: + return [PresetValidationError("preset", "", f"Invalid YAML in {path}: {e}")] if not isinstance(data, dict): return [PresetValidationError("preset", "", f"Expected a YAML mapping, got {type(data).__name__}")] return validate_preset(data, **kwargs) diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index 7fb9ebaded..46af3374e8 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -75,7 +75,7 @@ async def test_preset_yaml(clean_default_config): verbose=False, debug=False, silent=True, - config={"preset_test_asdf": 1}, + config={"keep_scans": 42}, ) preset1 = preset1.bake() assert "evilcorp.com" in preset1.target.seeds @@ -785,16 +785,14 @@ class TestModule5(BaseModule): """ ) - preset = Preset.from_yaml_string( - """ + # should fail at preset-load time now that validation runs in from_dict + with pytest.raises(ValidationError): + Preset.from_yaml_string( + """ modules: - testmodule5 """ - ) - # should fail - with pytest.raises(ValidationError): - scan = Scanner(preset=preset) - await scan._prep() + ) preset = Preset.from_yaml_string( f""" @@ -823,6 +821,8 @@ def test_preset_include(): mkdir(custom_preset_dir_4) mkdir(custom_preset_dir_5) + # Real modules so the (now-strict) validator accepts them. We use the + # universal `module_timeout` field as an opaque marker per preset. preset_file = custom_preset_dir_1 / "preset1.yml" with open(preset_file, "w") as f: f.write( @@ -832,8 +832,8 @@ def test_preset_include(): config: modules: - testpreset1: - test: asdf + nuclei: + module_timeout: 1 """ ) @@ -846,8 +846,8 @@ def test_preset_include(): config: modules: - testpreset2: - test: fdsa + sslcert: + module_timeout: 2 """ ) @@ -862,8 +862,8 @@ def test_preset_include(): config: modules: - testpreset3: - test: qwerty + gowitness: + module_timeout: 3 """ ) @@ -876,8 +876,8 @@ def test_preset_include(): config: modules: - testpreset4: - test: zxcv + robots: + module_timeout: 4 """ ) @@ -887,26 +887,26 @@ def test_preset_include(): """ config: modules: - testpreset5: - test: hjkl + wayback: + module_timeout: 5 """ ) # with include= preset = Preset(include=[str(custom_preset_dir_1 / "preset1")]) - assert preset.config["modules"]["testpreset1"]["test"] == "asdf" - assert preset.config["modules"]["testpreset2"]["test"] == "fdsa" - assert preset.config["modules"]["testpreset3"]["test"] == "qwerty" - assert preset.config["modules"]["testpreset4"]["test"] == "zxcv" - assert preset.config["modules"]["testpreset5"]["test"] == "hjkl" + assert preset.config["modules"]["nuclei"]["module_timeout"] == 1 + assert preset.config["modules"]["sslcert"]["module_timeout"] == 2 + assert preset.config["modules"]["gowitness"]["module_timeout"] == 3 + assert preset.config["modules"]["robots"]["module_timeout"] == 4 + assert preset.config["modules"]["wayback"]["module_timeout"] == 5 # same thing but with presets= (an alias to include) preset = Preset(presets=[str(custom_preset_dir_1 / "preset1")]) - assert preset.config["modules"]["testpreset1"]["test"] == "asdf" - assert preset.config["modules"]["testpreset2"]["test"] == "fdsa" - assert preset.config["modules"]["testpreset3"]["test"] == "qwerty" - assert preset.config["modules"]["testpreset4"]["test"] == "zxcv" - assert preset.config["modules"]["testpreset5"]["test"] == "hjkl" + assert preset.config["modules"]["nuclei"]["module_timeout"] == 1 + assert preset.config["modules"]["sslcert"]["module_timeout"] == 2 + assert preset.config["modules"]["gowitness"]["module_timeout"] == 3 + assert preset.config["modules"]["robots"]["module_timeout"] == 4 + assert preset.config["modules"]["wayback"]["module_timeout"] == 5 # can't use both include= and presets= at the same time with pytest.raises(ValueError): @@ -993,8 +993,8 @@ async def test_preset_override(clean_default_config): - robots config: modules: - asdf: - option1: asdf + robots: + module_timeout: 10 """ preset_2_yaml = """ name: override2 @@ -1005,8 +1005,8 @@ async def test_preset_override(clean_default_config): - c99 config: modules: - asdf: - option1: fdsa + robots: + module_timeout: 20 """ preset_3_yaml = """ name: override3 @@ -1060,7 +1060,7 @@ async def test_preset_override(clean_default_config): assert targets == {"evilcorp1.com", "evilcorp2.com", "evilcorp3.com", "evilcorp4.com"} assert preset.config["web"]["spider_distance"] == 1 assert preset.config["web"]["spider_depth"] == 2 - assert preset.config["modules"]["asdf"]["option1"] == "fdsa" + assert preset.config["modules"]["robots"]["module_timeout"] == 20 assert set(preset.scan_modules) == {"http", "c99", "robots", "virustotal", "securitytrails"} diff --git a/bbot/test/test_step_1/test_validate_preset.py b/bbot/test/test_step_1/test_validate_preset.py index 4fe9ca4121..70a8b03e42 100644 --- a/bbot/test/test_step_1/test_validate_preset.py +++ b/bbot/test/test_step_1/test_validate_preset.py @@ -84,3 +84,95 @@ def test_validate_preset_non_dict(): errs = validate_preset(["not a dict"]) assert len(errs) == 1 assert "dict" in errs[0].message + + +def test_validate_preset_config_modules_as_list(): + """`config.modules` given as a list (wrong shape) used to crash with IndexError.""" + errs = validate_preset({"config": {"modules": ["nuclei"]}}) + assert len(errs) == 1 + assert errs[0].where == "config" + assert errs[0].path == "modules" + + +def test_validate_preset_module_dirs_as_string(): + """`module_dirs` as a string used to iterate characters and raise PermissionError.""" + errs = validate_preset({"module_dirs": "/tmp/foo"}) + assert any(e.path == "module_dirs" and "list" in e.message for e in errs) + + +def test_validate_preset_modules_as_string_no_cascade(): + """`modules: "nuclei"` (string instead of list) should NOT produce per-character lookups.""" + errs = validate_preset({"modules": "nuclei"}) + # exactly one type error, no per-character bogus suggestions + assert len(errs) == 1 + assert errs[0].path == "modules" + assert "list" in errs[0].message + + +def test_validate_preset_top_level_typo_suggests_preset_field(): + """Typos at the preset root should suggest preset field names, not config paths.""" + cases = [ + ("modlues", "modules"), + ("flgas", "flags"), + ("targest", "target"), + ("output_moduels", "output_modules"), + ] + for typo, expected in cases: + errs = validate_preset({typo: ["x"]}) + assert any(f'"{typo}"' in str(e) and f'"{expected}"' in str(e) for e in errs), ( + f"expected suggestion {expected!r} for typo {typo!r}, got: {[str(e) for e in errs]}" + ) + + +def test_validate_preset_file_missing_returns_error(): + """A missing preset path should be reported as a single error, not raised.""" + from bbot.scanner import validate_preset_file + + errs = validate_preset_file("/tmp/does-not-exist-bbot-fuzz.yml") + assert len(errs) == 1 + assert "not found" in errs[0].message.lower() + + +def test_from_dict_raises_on_typos(): + """from_dict() should reject typo'd preset dicts up front instead of letting + them flow through to bake().""" + from bbot.errors import ValidationError as BBOTValidationError + from bbot.scanner.preset import Preset + + import pytest + + with pytest.raises(BBOTValidationError) as excinfo: + Preset.from_dict({"modlues": ["nuclei"]}) + assert "modlues" in str(excinfo.value) + + +def test_from_yaml_string_raises_on_typos(): + """YAML strings carrying typos should also be rejected up front.""" + 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") + assert "strct" in str(excinfo.value) + + +def test_validate_preset_interactsh_server_accepts_valid(): + """interactsh_server accepts FQDNs, IPv4, IPv6, None, and empty string.""" + for v in ["example.com", "sub.example.com", "192.168.1.1", "::1", "", None]: + errs = validate_preset({"config": {"interactsh_server": v}}) + assert errs == [], f"expected {v!r} to validate, got: {[str(e) for e in errs]}" + + +def test_validate_preset_interactsh_server_rejects_invalid(): + """A single-label hostname (a value without any dots, e.g. a typo'd + domain where the user forgot the TLD) or a value with whitespace must + be rejected at preset-load time, before any module tries to register + with the interactsh server.""" + for v in ["badhost", "localhost", "with spaces.com"]: + errs = validate_preset({"config": {"interactsh_server": v}}) + assert len(errs) == 1, f"expected {v!r} to fail validation" + assert errs[0].path == "interactsh_server" + assert "FQDN or IP" in errs[0].message + assert repr(v) in errs[0].message diff --git a/bbot/test/test_step_2/module_tests/test_module_retirejs.py b/bbot/test/test_step_2/module_tests/test_module_retirejs.py index dfea73485e..07c1fb462b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_retirejs.py +++ b/bbot/test/test_step_2/module_tests/test_module_retirejs.py @@ -116,13 +116,14 @@ def check(self, module_test, events): descriptions = [finding.data.get("description", "") for finding in retirejs_findings] all_descriptions = "\n".join(descriptions) - # Look for specific vulnerabilities we expect to find - expected_handlebars_vuln = "Vulnerable JavaScript library detected: handlebars v4.0.5 Severity: HIGH Summary: Regular Expression Denial of Service in Handlebars JavaScript URL: http://127.0.0.1:8888/handlebars.min.js CVE(s): CVE-2019-20922 Affected versions: [4.0.0 to 4.4.5)" - expected_jquery_vuln = "Vulnerable JavaScript library detected: jquery v3.4.1 Severity: MEDIUM Summary: Regex in its jQuery.htmlPrefilter sometimes may introduce XSS JavaScript URL: http://127.0.0.1:8888/jquery-3.4.1.min.js CVE(s): CVE-2020-11022 Affected versions: [1.2.0 to 3.5.0)" - - # Verify at least one of the expected vulnerabilities is found - handlebars_found = expected_handlebars_vuln in all_descriptions - jquery_found = expected_jquery_vuln in all_descriptions + # Match on stable identifiers — upstream RetireJS revises summary and + # affected-version strings over time. + def has_vuln(library, version, cve, js_url): + needle = f"library detected: {library} v{version}" + return any(needle in d and cve in d and js_url in d for d in descriptions) + + handlebars_found = has_vuln("handlebars", "4.0.5", "CVE-2019-20922", "http://127.0.0.1:8888/handlebars.min.js") + jquery_found = has_vuln("jquery", "3.4.1", "CVE-2020-11022", "http://127.0.0.1:8888/jquery-3.4.1.min.js") assert handlebars_found and jquery_found, ( f"Expected to find specific vulnerabilities but didn't find them. Found descriptions:\n{all_descriptions}"