Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
8618892
preset validation phase 1
TheTechromancer Apr 23, 2026
ca4e742
don't duplicate defaults in code
TheTechromancer Apr 24, 2026
7578d1d
preset validation wip
TheTechromancer Apr 27, 2026
393ae72
ruffed
TheTechromancer Apr 27, 2026
58c159d
preset tests
TheTechromancer Apr 28, 2026
39c7f2c
fix tests
TheTechromancer Apr 29, 2026
20af84e
cleanup, test fixes
TheTechromancer Apr 29, 2026
4972c12
fix tests, again
TheTechromancer Apr 30, 2026
9c1ea26
small improvements, rename deep_update -> deep_merge
TheTechromancer Apr 30, 2026
bce3e4b
remove env var interpolation docs
TheTechromancer Apr 30, 2026
e48e570
bring back baddns case insensitivity
TheTechromancer May 1, 2026
eca363b
allow field validators
TheTechromancer May 1, 2026
3aa43ed
remove redundant validation/sanization logic
TheTechromancer May 1, 2026
6bc7928
preset schema
TheTechromancer May 4, 2026
d04ef70
mandatory and sensitive fields
TheTechromancer May 5, 2026
20cd441
add tests
TheTechromancer May 5, 2026
7bc8c44
Merge branch 'dev' into preset-validation
liquidsec May 13, 2026
b10514e
preset-validation: fix crashes, wire validator into YAML load, exit n…
aconite33 May 18, 2026
02cdec6
preset-validation: revert cli.py exit-code change
aconite33 May 18, 2026
c77dc55
preset-validation: validate interactsh_server as FQDN or IP
aconite33 May 19, 2026
a74e065
Loosen retirejs test matching against upstream advisory drift
liquidsec May 18, 2026
2d15ac6
preset-validation: move validate_fqdn_or_ip to helpers/validators.py
aconite33 May 19, 2026
7e8705f
leakix: make api key mandatory; drop dead conditional in subdomainradar
liquidsec May 20, 2026
fe27b84
Merge pull request #3112 from blacklanternsecurity/leakix-mandatory-key
liquidsec May 21, 2026
feb73fc
Merge pull request #3105 from blacklanternsecurity/preset-validation-…
liquidsec May 21, 2026
1a119e8
Merge branch 'dev' into preset-validation
liquidsec May 21, 2026
2bc1f4b
preset-validation: hard-fail on legacy options dict; version preload …
liquidsec May 21, 2026
bacc671
Merge pull request #3119 from blacklanternsecurity/legacy-options-har…
liquidsec May 21, 2026
e5263a0
Merge branch 'dev' into preset-validation
liquidsec May 21, 2026
45df6a5
Merge remote-tracking branch 'origin/dev' into preset-validation
ausmaster May 22, 2026
fdd5e6b
Merge branch 'dev' into preset-validation
liquidsec Jun 2, 2026
ed845de
Fix --module-help to read options from pydantic Config
liquidsec Jun 3, 2026
5be71e6
Guard validate_preset against non-string module entries
liquidsec Jun 3, 2026
25d9f8f
Regenerate docs; drop references to removed modules
liquidsec Jun 3, 2026
7aa4ddc
Fix preset validation rejecting valid config
liquidsec Jun 3, 2026
cc563ca
Keep date-shaped CLI config values as strings
liquidsec Jun 3, 2026
ed9fea8
Drop dead pydantic-settings env machinery
liquidsec Jun 3, 2026
abde3f4
Parse string-typed CLI config values losslessly
liquidsec Jun 3, 2026
0a0600c
Fix non-blocking review findings (N2, N3, N6, N8, N12)
liquidsec Jun 3, 2026
103eea8
Merge branch 'dev' into preset-validation
liquidsec Jun 4, 2026
caefde9
Type-directed config coercion via preload-built type index
liquidsec Jun 4, 2026
143556b
Make validation+coercion a precondition for bake() (inversion)
ausmaster Jun 5, 2026
a41da70
Migrate tests to the validate-before-bake contract
ausmaster Jun 5, 2026
12fa9c7
Lets... get rid of validation .from_dict() cause validation and coerc…
ausmaster Jun 5, 2026
df10fa9
Cleanly split preset validation between from_dict and validate().
ausmaster Jun 5, 2026
c32332e
Add 'recursive' adjective; fix easter egg triggers
liquidsec Jun 6, 2026
f3fe87f
Clean up comments; log coercion errors instead of silencing
liquidsec Jun 6, 2026
2d1b1a9
Migrate stale module names in test.conf to current names
liquidsec Jun 6, 2026
c5dd67e
Fix module test config leak from DEFAULT_CONFIG singleton
liquidsec Jun 6, 2026
ae1d212
Remove dead config_overrides from aspnet_bin_exposure test
liquidsec Jun 7, 2026
eaff230
Accept list[str] for api_key config; don't stringify collections duri…
liquidsec Jun 7, 2026
5494e16
Coerce PathLike to str for union fields during config coercion
liquidsec Jun 7, 2026
c263de3
Remove 'github' template from test config_overrides
liquidsec Jun 7, 2026
9473828
Fix gowitness test: use deps.behavior instead of force_deps
liquidsec Jun 7, 2026
c91cd6d
Fix nuclei test: move interactsh_disable to top-level config
liquidsec Jun 7, 2026
d0ec063
Update easter egg dedication; fix shodan_dns test config key
liquidsec Jun 7, 2026
3e13150
Use log_to_stderr for easter egg, matching golden_gus
liquidsec Jun 7, 2026
2c3819a
Remove nonexistent 'wordlist' config from webbrute_shortnames test
liquidsec Jun 7, 2026
c4e4168
Merge pull request #3148 from blacklanternsecurity/pr3058-validate-be…
liquidsec Jun 8, 2026
edcd7c4
Coerce config via pydantic TypeAdapter instead of type-name parsing
ausmaster Jun 8, 2026
be1d3b9
Merge pull request #3156 from blacklanternsecurity/coerce-value-typea…
liquidsec Jun 8, 2026
6e06458
Merge branch 'dev' into preset-validation
liquidsec Jun 8, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ For details, see [Configuration](https://www.blacklanternsecurity.com/bbot/Stabl
- [Setting Up a Dev Environment](https://www.blacklanternsecurity.com/bbot/Stable/dev/dev_environment)
- [BBOT Internal Architecture](https://www.blacklanternsecurity.com/bbot/Stable/dev/architecture)
- [How to Write a BBOT Module](https://www.blacklanternsecurity.com/bbot/Stable/dev/module_howto)
- [Validating & Inspecting Presets](https://www.blacklanternsecurity.com/bbot/Stable/dev/preset_validation)
- [Unit Tests](https://www.blacklanternsecurity.com/bbot/Stable/dev/tests)
- [Discord Bot Example](https://www.blacklanternsecurity.com/bbot/Stable/dev/discord_bot)
- **Code Reference**
Expand Down
2 changes: 2 additions & 0 deletions bbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +157,7 @@ async def _main():
print(row)
return

preset.validate()
baked_preset = preset.bake()

# --current-preset / --current-preset-full
Expand Down
27 changes: 18 additions & 9 deletions bbot/core/config/files.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import sys
import yaml
from pathlib import Path
from omegaconf import OmegaConf

from .merge import deep_merge
from ...logger import log_to_stderr
from ...errors import ConfigLoadError

Expand All @@ -18,24 +19,32 @@ class BBOTConfigFiles:
def __init__(self, core):
self.core = core

def _get_config(self, filename, name="config"):
def _get_config(self, filename, name="config") -> dict:
filename = Path(filename).resolve()
if not filename.exists():
return {}
try:
conf = OmegaConf.load(str(filename))
with open(filename) as f:
conf = yaml.safe_load(f) or {}
if not isinstance(conf, dict):
raise ConfigLoadError(
f"Error parsing config at {filename}: expected a YAML mapping at the top level, "
f"got {type(conf).__name__}"
)
cli_silent = any(x in sys.argv for x in ("-s", "--silent"))
if __name__ == "__main__" and not cli_silent:
log_to_stderr(f"Loaded {name} from {filename}")
return conf
except ConfigLoadError:
raise
except Exception as e:
if filename.exists():
raise ConfigLoadError(f"Error parsing config at {filename}:\n\n{e}")
return OmegaConf.create()
raise ConfigLoadError(f"Error parsing config at {filename}:\n\n{e}")

def get_custom_config(self):
return OmegaConf.merge(
def get_custom_config(self) -> dict:
return deep_merge(
self._get_config(self.config_filename, name="config"),
self._get_config(self.secrets_filename, name="secrets"),
)

def get_default_config(self):
def get_default_config(self) -> dict:
return self._get_config(self.defaults_filename, name="defaults")
94 changes: 94 additions & 0 deletions bbot/core/config/merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""
Deep-merge helpers replacing omegaconf's merge semantics.

`deep_merge(a, b)` returns a new dict that is `a` with `b` merged in: nested
dicts are merged recursively, leaf values (and lists) from `b` replace those in
`a`. This matches `OmegaConf.merge(a, b)` for BBOT's preset layering use case.
"""

from __future__ import annotations

from copy import deepcopy
from typing import Any


def deep_merge(base: dict[str, Any] | None, *updates: dict[str, Any] | None) -> dict[str, Any]:
"""
Deep-merge one or more update dicts into a copy of `base`. Last wins on
leaf conflicts; lists are replaced wholesale (not concatenated).

The returned dict shares no mutable state with the inputs — nested dicts,
lists, and other mutable values are deep-copied as they're carried over.
"""
result: dict[str, Any] = deepcopy(base) if base else {}
for update in updates:
if not update:
continue
for k, v in update.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = deepcopy(v)
return result


def dotted_get(data: dict[str, Any], path: str, default: Any = None) -> Any:
"""
Look up a dotted path in a nested dict.

Note: keys containing literal `.` are not addressable (no escape syntax).

>>> dotted_get({"a": {"b": {"c": 1}}}, "a.b.c")
1
>>> dotted_get({"a": 1}, "a.b.c", default="x")
'x'
"""
cursor: Any = data
for part in path.split("."):
if not isinstance(cursor, dict) or part not in cursor:
return default
cursor = cursor[part]
return cursor


def dotted_set(data: dict[str, Any], path: str, value: Any) -> None:
"""
Set a dotted path in a nested dict, creating intermediate dicts as needed.

Non-dict intermediates are silently replaced. This is intentional —
callers (CLI parsing) feed the result through pydantic validation, which
surfaces any resulting type mismatch.

>>> d = {}
>>> dotted_set(d, "a.b.c", 1)
>>> d
{'a': {'b': {'c': 1}}}
"""
parts = path.split(".")
cursor = data
for part in parts[:-1]:
if part not in cursor or not isinstance(cursor[part], dict):
cursor[part] = {}
cursor = cursor[part]
cursor[parts[-1]] = value


def iter_dotted_paths(data: dict[str, Any], prefix: str = "") -> list[str]:
"""
Return every dotted leaf path in a nested dict. Empty dicts are treated
as leaves (so they round-trip through dotted_get/dotted_set).

>>> iter_dotted_paths({"a": 1, "b": {"c": 2}})
['a', 'b.c']
"""
paths: list[str] = []
for k, v in data.items():
path = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict) and v:
paths.extend(iter_dotted_paths(v, path))
else:
paths.append(path)
return paths


__all__ = ["deep_merge", "dotted_get", "dotted_set", "iter_dotted_paths"]
Loading
Loading