diff --git a/bbot/scanner/preset/path.py b/bbot/scanner/preset/path.py index dd4bd2558a..9e5acb48e5 100644 --- a/bbot/scanner/preset/path.py +++ b/bbot/scanner/preset/path.py @@ -16,6 +16,7 @@ class PresetPath: def __init__(self): self.paths = [DEFAULT_PRESET_PATH] + self._listable = {DEFAULT_PRESET_PATH} def find(self, filename): filename_path = Path(filename).expanduser() @@ -49,13 +50,17 @@ def find(self, filename): def __str__(self): return ":".join([str(s) for s in self.paths]) - def add_path(self, path): + def add_path(self, path, listable=False): path = Path(path).expanduser().resolve() # skip if already in paths if path in self.paths: + if listable: + self._listable.add(path) return # skip if path is a subdirectory of any path in paths if any(path.is_relative_to(p) for p in self.paths): + if listable: + self._listable.add(path) return # skip if path is not a directory if not path.is_dir(): @@ -65,6 +70,12 @@ def add_path(self, path): # but never remove the default preset path self.paths = [p for p in self.paths if p == DEFAULT_PRESET_PATH or not p.is_relative_to(path)] self.paths.insert(0, path) + if listable: + self._listable.add(path) + + @property + def listable_paths(self): + return [p for p in self.paths if p in self._listable] def find_file(self, filename): """Search known preset paths for a file of any type (e.g. target lists). diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 8512619e0e..b31e1a6160 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -1053,8 +1053,8 @@ def all_presets(self): """ Recursively find all the presets and return them as a dictionary """ - # first, add local preset dir to PRESET_PATH - PRESET_PATH.add_path(self.preset_dir) + # first, add local preset dir to PRESET_PATH (listable so -lp enumerates it) + PRESET_PATH.add_path(self.preset_dir, listable=True) # ensure local preset directory exists mkdir(self.preset_dir) @@ -1062,7 +1062,7 @@ def all_presets(self): global DEFAULT_PRESETS if DEFAULT_PRESETS is None: presets = {} - for preset_path in PRESET_PATH: + for preset_path in PRESET_PATH.listable_paths: for ext in ("yml", "yaml"): # for every yaml file for original_filename in preset_path.rglob(f"**/*.{ext}"): diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index e1406bd5c5..504fe32993 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -1374,3 +1374,71 @@ def test_malformed_yaml_config_file(tmp_path): malformed.write_text("web:\n http_rate_limit: 100\n bad_key: value\n") with pytest.raises(ConfigLoadError, match="YAML syntax error"): BBOTConfigFiles._get_config(None, str(malformed)) + + +def test_all_presets_ignores_non_preset_yaml(tmp_path): + """Regression test for https://github.com/blacklanternsecurity/bbot/issues/3189 + + When -p loads a preset from an arbitrary directory (e.g. $HOME), that + directory must NOT be searched by all_presets / -lp. Otherwise every + .yml file under it (Ansible collections, CI configs, etc.) is parsed + and produces warning spam. + """ + import bbot.scanner.preset.preset as preset_mod + from bbot.scanner.preset.path import PresetPath + + # create a valid preset in tmp_path (simulates ~/my_preset.yml) + preset_file = tmp_path / "my_preset.yml" + preset_file.write_text("description: test preset\nmodules:\n - sslcert\n") + + # create non-preset yaml files nearby (simulates Ansible, CI, etc.) + junk_dir = tmp_path / "ansible" + junk_dir.mkdir() + (junk_dir / "playbook.yml").write_text("hosts: all\ntasks: []\n") + (tmp_path / "ci.yml").write_text("on: push\njobs: {}\n") + + # save and replace global state so we get a clean PRESET_PATH + orig_preset_path = preset_mod.PRESET_PATH + orig_default_presets = preset_mod.DEFAULT_PRESETS + try: + fresh_path = PresetPath() + preset_mod.PRESET_PATH = fresh_path + # also patch the path module's reference + import bbot.scanner.preset.path as path_mod + + orig_path_singleton = path_mod.PRESET_PATH + path_mod.PRESET_PATH = fresh_path + + # simulate -p /tmp/xxx/my_preset.yml: find() adds tmp_path to search paths + found = fresh_path.find(str(preset_file)) + assert found == preset_file.resolve() + # tmp_path is now in search paths (needed for include resolution) + assert tmp_path.resolve() in fresh_path.paths + + # reset the cached presets so all_presets re-enumerates + preset_mod.DEFAULT_PRESETS = None + + preset = Preset() + + # collect warnings emitted during all_presets enumeration + import logging + + warnings = [] + handler = logging.Handler() + handler.emit = lambda record: ( + warnings.append(record.getMessage()) if record.levelno >= logging.WARNING else None + ) + preset_logger = logging.getLogger("bbot.presets") + preset_logger.addHandler(handler) + try: + preset.all_presets + finally: + preset_logger.removeHandler(handler) + + # no warnings should reference the junk files from tmp_path + junk_warnings = [w for w in warnings if "playbook.yml" in w or "ci.yml" in w] + assert not junk_warnings, f"all_presets tried to parse non-preset YAML files: {junk_warnings}" + finally: + preset_mod.DEFAULT_PRESETS = orig_default_presets + preset_mod.PRESET_PATH = orig_preset_path + path_mod.PRESET_PATH = orig_path_singleton