Skip to content

preset-validation: fix crashes, wire validator into YAML load, validate interactsh_server - #3105

Merged
liquidsec merged 5 commits into
preset-validationfrom
preset-validation-fixes
May 21, 2026
Merged

preset-validation: fix crashes, wire validator into YAML load, validate interactsh_server#3105
liquidsec merged 5 commits into
preset-validationfrom
preset-validation-fixes

Conversation

@aconite33

@aconite33 aconite33 commented May 18, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #3058 addressing the issues found in this fuzz comment, plus an additional FQDN check uncovered while testing.

What this PR does

Crash fixes in validate_preset()

  • _classify_loc now guards against short loc tuples. validate_preset({"config": {"modules": ["nuclei"]}}) used to raise IndexError; it now returns a clean error.
  • The module_dirs pre-pass now skips non-list shapes. validate_preset({"module_dirs": "/tmp/foo"}) used to iterate characters and surface as PermissionError; it now returns a clean type error.
  • The modules-list post-pass now skips non-list shapes. validate_preset({"modules": "nuclei"}) used to emit one correct type error plus six bogus per-character lookups; it now emits just the one type error.

Better suggestions for top-level typos

_format_msg now draws extra_forbidden suggestions from PresetSchema field names when len(loc) == 1. Before, top-level typos consulted the dotted config-path universe and produced things like:

  • modlues: → "Did you mean 'module_dirs'?"
  • flgas: → "Did you mean 'file_blobs'?"
  • targest: → "Did you mean 'aggregate'?"

After:

  • modlues: → "Did you mean 'modules'?"
  • flgas: → "Did you mean 'flags'?"
  • targest: → "Did you mean 'target'?"

YAML preset files now run through the validator

validate_preset() is now called from Preset.from_dict(), which covers from_yaml_file, from_yaml_string, and include: directives. Previously the validator only ran for -c CLI args, so typos in -p preset.yml were silently accepted and bad values crashed later inside bake() (e.g., scope.strict: "yesplease"TypeError from radixtarget; scope: as a list → AttributeError).

interactsh_server is validated as an FQDN or IP

Found while fuzzing: a domain typed without its TLD (a single-label value with no dot) used to pass preset-load validation and then surface as "Failed to register with an interactsh server" warnings from every interactsh-using module mid-scan. A new validate_fqdn_or_ip helper attached as a field_validator to BBOTConfig.interactsh_server rejects this up front. Accepts None, empty string, any IPv4/IPv6, or a hostname containing at least one dot. Rejects single-label values like localhost.

The helper lives in bbot/core/helpers/validators.py alongside validate_host, validate_port, validate_url, and friends. Module authors who want the same check on their own class Config fields can opt in with one line:

from bbot.core.helpers.validators import validate_fqdn_or_ip
from pydantic import field_validator

class Config(BaseModuleConfig):
    host: str = Field("127.0.0.1", description="...")
    _validate_host = field_validator("host")(validate_fqdn_or_ip)

Other fields surveyed but left alone (each carries some compatibility risk for users with internal endpoints, worth its own discussion): web.http_proxy, output module URLs (splunk, elastic, slack, discord, webhook, rabbitmq), database hosts (postgres, mysql, mongo, neo4j), and the baddns custom_nameservers list typing. Each can opt in to validate_fqdn_or_ip (or a future URL validator) in its own module.

flatten_config skips None (latent bug)

While wiring the FQDN validator, surfaced a separate bug in bbot/scanner/preset/environ.py: flatten_config was doing str(v) on every value, which wrote the literal string "None" into BBOT_* env vars for any null field. Pydantic-settings then merged that back into the config dict during validation, defeating any field validator that expects a real value. Now skipped.

validate_preset_file() no longer leaks FileNotFoundError

Catches FileNotFoundError, OSError, and yaml.YAMLError and returns a single PresetValidationError instead.

webbrute schema bumped to accept lists

extensions was declared str but the bundled web/dirbust-heavy.yml preset passes a YAML list, and the runtime feeds it through chain_lists which handles both. The strict YAML validation surfaced this. Schema is now Union[str, list[str]].

CLI exit code: unchanged from baseline

An earlier version of this PR tried to make bbot exit 1 on validation failure. That broke test_cli_customheaders, which asserts _main() returns None on invalid --custom-headers input, and the failure cascaded across the test-distros matrix and Python 3.14. Reverted in commit 02cdec64e. The CLI keeps the existing convention: validation errors are logged ([ERRR] or [WARN]) and _main returns None. Users still see the error; the exit code stays 0 like every other arg-failure path in BBOT.

Tests

  • Ten new cases in test_validate_preset.py covering each fix, including the single-label-hostname FQDN repro.
  • Four existing tests in test_presets.py updated. They used fake module names (testpreset1...testpreset5, asdf) as opaque config markers; the stricter validator now rejects those, so the tests use real module names with the universal module_timeout field.
  • All 40 tests in test_validate_preset.py + test_presets.py + test_config.py + test_cli.py::test_cli_customheaders pass locally.
  • Lint and format clean.

Fuzz battery before/after

Surface Before After
YAML preset typos silently accepted clean error, useful suggestions
config.modules as list IndexError clean error
module_dirs as string PermissionError on /run/docker clean type error
modules: "nuclei" type error + 6 bogus cascade single type error
Top-level typos bad suggestions from wrong pool suggestions from PresetSchema
validate_preset_file("/missing") FileNotFoundError raised clean error returned
interactsh_server as a single-label hostname (missing TLD) runtime "Failed to register" cascade from every module rejected at preset-load with a clean error

Commits

  • b10514e17 — Original crash, typo-suggestion, YAML-load wiring, and webbrute-schema fixes.
  • 02cdec64e — Revert the cli.py exit-code change to match the existing test framework convention.
  • c77dc5588interactsh_server FQDN validation + flatten_config None skip + value_error formatter polish + tests.
  • a74e065a9 — Cherry-pick of @liquidsec's d423533f4 from retirejs-test-version-range-brittleness. Unrelated to this PR — retire.js's vulnerability DB drifted upstream (CVE-2020-11022 affected-versions range moved from [1.2.0 ... to [1.12.0 ...), breaking the brittle full-string assertion in test_module_retirejs. Picked the existing fix in to get CI green. If that branch lands on preset-validation first, this commit becomes a no-op merge.
  • 2d15ac687 — Mechanical move of validate_fqdn_or_ip from bbot/core/config/models.py to bbot/core/helpers/validators.py so it lives next to BBOT's other public validators. No behavior change.

Known item left alone

Pydantic v2 lax bool coercion still accepts "yes" / "true" / "1" / "on" etc. for scope.strict. Flagged in the original review as P1. Not addressed here because adding strict=True to every bool field is a broader change worth its own discussion.

…onzero on bad config

- validate.py: guard _classify_loc against short loc tuples. config.modules
  given as a list (e.g., {"config": {"modules": ["nuclei"]}}) previously
  hit IndexError in the loc classifier.
- validate.py: skip module_dirs pre-pass when the value is not a list.
  A string module_dirs (e.g., "/tmp/foo") used to iterate characters and
  trigger filesystem calls, surfacing as PermissionError.
- validate.py: skip the modules-list post-pass when the value is not a
  list, so `modules: "nuclei"` no longer cascades into six bogus
  per-character suggestions on top of the real type error.
- validate.py: route len(loc)==1 extra_forbidden suggestions through
  PresetSchema field names so `modlues:` suggests `modules` instead of
  `module_dirs`, `flgas:` suggests `flags`, etc.
- validate.py: validate_preset_file catches FileNotFoundError, OSError,
  and yaml.YAMLError and returns a clean error instead of raising.
- preset.py: call validate_preset() at the top of from_dict so typos in
  YAML preset files (loaded via -p or include:) surface up front instead
  of flowing through to bake() as raw TypeError or AttributeError.
- cli.py: BBOTArgumentError and BBOTError handlers in _main return False
  and log at ERROR level; main() exits 1 on falsy return. Validation
  failures now produce a non-zero exit so CI and scripts can detect them.
- webbrute: extensions accepts str or list[str]. Matches the chain_lists
  runtime contract and the bundled web/dirbust-heavy.yml preset, which
  passes a YAML list.
- tests: eight new cases in test_validate_preset.py covering each fix. Four
  existing tests in test_presets.py updated to use real module names
  (the prior fake `testpreset1`...`testpreset5` markers are now caught
  by the stricter validator).
@aconite33
aconite33 requested review from ausmaster and liquidsec May 18, 2026 22:35
@github-actions

github-actions Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

📊 Performance Benchmark Report

Comparing preset-validation (baseline) vs preset-validation-fixes (current)

📈 Detailed Results (All Benchmarks)

📋 Complete results for all benchmarks - includes both significant and insignificant changes

🧪 Test Name 📏 Base 📏 Current 📈 Change 🎯 Status
Bloom Filter Dns Mutation Tracking Performance 4.38ms 4.33ms -1.1%
Bloom Filter Large Scale Dns Brute Force 17.70ms 17.71ms +0.0%
Large Closest Match Lookup 360.97ms 357.69ms -0.9%
Realistic Closest Match Workload 189.11ms 191.10ms +1.1%
Event Memory Medium Scan 1781 B/event 1780 B/event -0.1%
Event Memory Large Scan 1768 B/event 1768 B/event -0.0%
Event Validation Full Scan Startup Small Batch 383.27ms 379.27ms -1.0%
Event Validation Full Scan Startup Large Batch 548.99ms 542.13ms -1.3%
Make Event Autodetection Small 31.61ms 31.51ms -0.3%
Make Event Autodetection Large 322.64ms 320.49ms -0.7%
Make Event Explicit Types 14.19ms 14.12ms -0.5%
Excavate Single Thread Small 3.818s 3.904s +2.2%
Excavate Single Thread Large 9.351s 9.577s +2.4%
Excavate Parallel Tasks Small 3.959s 4.016s +1.4%
Excavate Parallel Tasks Large 6.416s 6.459s +0.7%
Is Ip Performance 3.24ms 3.24ms +0.2%
Make Ip Type Performance 11.64ms 11.80ms +1.4%
Mixed Ip Operations 4.57ms 4.57ms -0.1%
Memory Use Web Crawl 632.7 MB 677.3 MB +7.0%
Memory Use Subdomain Enum 35.0 MB 35.0 MB +0.0%
Memory Use Deep Chain 8.9 MB 8.9 MB +0.0%
Memory Use Parallel Chains 23.1 MB 23.7 MB +2.7%
Scan Throughput 100 3.623s 3.591s -0.9%
Scan Throughput 1000 28.819s 28.919s +0.3%
Typical Queue Shuffle 63.56µs 66.39µs +4.5%
Priority Queue Shuffle 723.34µs 742.49µs +2.6%

🎯 Performance Summary

No significant performance changes detected (all changes <10%)


🐍 Python Version 3.11.15

The prior commit changed _main()'s BBOTArgumentError and BBOTError
handlers to `return False` and added a `sys.exit(1)` in main() so bbot
would exit non-zero on validation failure. That broke
test_cli_customheaders, which asserts `result is None` from `_main()`
on invalid `--custom-headers` input, and the failure cascaded across
all test-distros jobs and Python 3.14 in CI.

Adhere to the existing convention: log the error and return None.
Real-CLI exit code stays 0 on validation failures, matching every
other CLI-arg failure path in BBOT. Users still see the [ERRR] or
[WARN] line; scripts that need to detect failure can grep stderr or
check log output.
@codecov

codecov Bot commented May 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.68293% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 90%. Comparing base (7bc8c44) to head (2d15ac6).
⚠️ Report is 8 commits behind head on preset-validation.

Files with missing lines Patch % Lines
bbot/scanner/preset/validate.py 77% 9 Missing ⚠️
Additional details and impacted files
@@                Coverage Diff                @@
##           preset-validation   #3105   +/-   ##
=================================================
+ Coverage                 90%     90%   +1%     
=================================================
  Files                    452     452           
  Lines                  39341   39437   +96     
=================================================
+ Hits                   35235   35326   +91     
- Misses                  4106    4111    +5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@aconite33 aconite33 changed the title preset-validation: fix crashes, wire validator into YAML load, exit nonzero on bad config preset-validation: fix crashes, wire validator into YAML load, validate interactsh_server May 19, 2026
aconite33 and others added 2 commits May 18, 2026 20:27
A domain typed without its TLD (a single-label value with no dot) used
to pass preset-load validation, then surface as "Failed to register
with an interactsh server" warnings deep in the scan from every module
that uses interactsh. Catch the typo up front instead.

- models.py: add a shared _validate_fqdn_or_ip helper. Accepts None,
  empty string, any IP (v4/v6), or a hostname containing at least one
  dot that passes is_dns_name. Rejects single-label values like
  'localhost'.
- models.py: attach the helper as a field_validator to
  BBOTConfig.interactsh_server.
- environ.py: skip None values in flatten_config. str(None) was writing
  the literal "None" into BBOT_INTERACTSH_SERVER and other BBOT_* env
  vars, which pydantic-settings then merges back into the config dict
  during validation and trips the new FQDN check on every test that
  follows. Also a latent bug independent of this validator: any field
  that defaults to null was getting "None" injected on round-trip.
- validate.py: handle the pydantic 'value_error' kind in _format_msg so
  ValueError messages raised inside a field_validator surface cleanly
  ("not a valid FQDN..." rather than "Value error, not a valid FQDN...").
- tests: cover accept-or-reject behavior for interactsh_server,
  including a single-label hostname case.

Follow-up candidates flagged during the field survey (not addressed in
this commit): web.http_proxy (URL), output module URLs (splunk,
elastic, slack, discord, webhook, rabbitmq), database hosts (postgres,
mysql, mongo, neo4j), and the baddns custom_nameservers list typing.
Worth their own discussion since each carries some compatibility risk
for users with nonstandard internal endpoints.
@aconite33
aconite33 force-pushed the preset-validation-fixes branch from e56370f to a74e065 Compare May 19, 2026 02:28
The FQDN-or-IP helper landed in `bbot/core/config/models.py` next to
the pydantic schemas, but BBOT already has a dedicated home for public
validators: `bbot/core/helpers/validators.py`, which holds
`validate_host`, `validate_port`, `validate_url`, `validate_email`, and
friends. Putting the new helper anywhere else fragments the validator
API surface and makes the function harder to find for module authors
who want to opt in.

Mechanical move only, no behavior change:

- validators.py: define `validate_fqdn_or_ip` next to `validate_host`,
  with a docstring noting the relationship to `validate_host` (this one
  is stricter and does not normalize, so a pydantic field_validator
  sees the input unchanged).
- models.py: drop the local definition. Import the helper from
  `bbot.core.helpers.validators` and keep the existing field_validator
  binding on `BBOTConfig.interactsh_server`.
- models.py.__all__: remove the entry. The helper is no longer defined
  here.

Module authors who want FQDN-or-IP validation on their own
`class Config` fields can now do:

    from bbot.core.helpers.validators import validate_fqdn_or_ip
    from pydantic import field_validator

    class Config(BaseModuleConfig):
        host: str = Field("127.0.0.1", description="...")
        _validate_host = field_validator("host")(validate_fqdn_or_ip)
@liquidsec
liquidsec merged commit feb73fc into preset-validation May 21, 2026
18 checks passed
@liquidsec
liquidsec deleted the preset-validation-fixes branch May 21, 2026 15:06
@liquidsec liquidsec mentioned this pull request Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants