diff --git a/desloppify/languages/python/detectors/bandit_adapter.py b/desloppify/languages/python/detectors/bandit_adapter.py index 361daed3f..94112cd90 100644 --- a/desloppify/languages/python/detectors/bandit_adapter.py +++ b/desloppify/languages/python/detectors/bandit_adapter.py @@ -185,6 +185,43 @@ def _to_security_entry( } +def _exclude_globs(exclude_dirs: list[str]) -> list[str]: + """Expand absolute exclude paths into bandit-friendly glob patterns. + + Bandit's ``--exclude`` is matched against filenames it discovers during + its recursive walk. When desloppify hands it absolute directory paths + (e.g. ``/repo/.claude``), bandit only filters files whose name *equals* + one of those paths — directories *under* the exclude (e.g. + ``/repo/.claude/worktrees/foo.py``) still get scanned because their + full filename does not equal the exclude string. + + Expanding each absolute path into ``**//**`` (and a sibling + ```` form for the directory itself) ensures bandit prunes the + whole subtree the way the rest of desloppify already does. The + original absolute paths are kept as a belt-and-braces fallback. + """ + expanded: list[str] = [] + seen: set[str] = set() + for raw in exclude_dirs: + if not raw: + continue + if raw not in seen: + expanded.append(raw) + seen.add(raw) + # Walk every path component and emit a glob; this catches both + # top-level (``.claude``) and nested (``.claude/worktrees``) excludes + # without requiring callers to know the layout. + parts = [p for p in raw.replace("\\", "/").split("/") if p] + for part in parts: + if not part or part in {"*", "**"}: + continue + for candidate in (f"**/{part}", f"**/{part}/**"): + if candidate not in seen: + expanded.append(candidate) + seen.add(candidate) + return expanded + + def detect_with_bandit( path: Path, zone_map: FileZoneMap | None, @@ -197,9 +234,11 @@ def detect_with_bandit( Parameters ---------- exclude_dirs: - Absolute directory paths to pass to bandit's ``--exclude`` flag. - When non-empty, bandit will skip these directories during its - recursive scan. + Absolute directory paths to skip. Each path is expanded into a + ``**//**`` glob (in addition to the original absolute path) + before being passed to bandit's ``--exclude`` flag, so that nested + files under the exclude are pruned, not just the directory entry + itself. skip_tests: Bandit test IDs to suppress via ``--skip`` (e.g. ``["B101", "B601"]``). Allows users to disable entire rule families from ``config.json``. @@ -214,7 +253,7 @@ def detect_with_bandit( "--quiet", ] if exclude_dirs: - cmd.extend(["--exclude", ",".join(exclude_dirs)]) + cmd.extend(["--exclude", ",".join(_exclude_globs(exclude_dirs))]) if skip_tests: cmd.extend(["--skip", ",".join(skip_tests)]) cmd.append(str(path.resolve())) diff --git a/desloppify/tests/detectors/test_external_adapters.py b/desloppify/tests/detectors/test_external_adapters.py index f2f366925..710c0feff 100644 --- a/desloppify/tests/detectors/test_external_adapters.py +++ b/desloppify/tests/detectors/test_external_adapters.py @@ -487,6 +487,36 @@ def _capture_run(cmd, **kwargs): assert "--exclude" not in captured_cmd + def test_exclude_dirs_expanded_to_glob_for_nested_pruning(self): + """REGRESSION: bandit's --exclude must prune nested files, not just dirs. + + When given a bare absolute path like ``/project/.claude``, bandit + only filters files whose full name equals that string; files + beneath the directory are still scanned. ``detect_with_bandit`` + must therefore expand each path into ``**//**`` globs. + """ + captured: list[str] = [] + + def _capture_run(cmd, **kwargs): + captured.extend(cmd) + mock_result = MagicMock() + mock_result.stdout = self._bandit_result([]) + return mock_result + + with patch("subprocess.run", side_effect=_capture_run): + detect_with_bandit( + Path("/project"), + zone_map=None, + exclude_dirs=["/project/.claude", "/project/.claude/worktrees"], + ) + + idx = captured.index("--exclude") + exclude_value = captured[idx + 1] + assert "/project/.claude" in exclude_value + assert "**/.claude" in exclude_value + assert "**/.claude/**" in exclude_value + assert "**/worktrees/**" in exclude_value + class TestBanditExcludeIntegration: """Verify PythonConfig passes exclusion dirs to bandit."""