Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion bbot/core/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion bbot/core/helpers/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")


Expand Down
6 changes: 4 additions & 2 deletions bbot/modules/webbrute.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import random
import string
from typing import Union

import blasthttp

Expand All @@ -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)")
Expand Down
5 changes: 4 additions & 1 deletion bbot/scanner/preset/environ.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions bbot/scanner/preset/preset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 63 additions & 5 deletions bbot/scanner/preset/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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)

Expand All @@ -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))
Expand All @@ -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)
Expand Down
Loading
Loading