Skip to content
Open
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
47 changes: 43 additions & 4 deletions desloppify/languages/python/detectors/bandit_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``**/<basename>/**`` (and a sibling
``<basename>`` 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,
Expand All @@ -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
``**/<name>/**`` 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``.
Expand All @@ -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()))
Expand Down
30 changes: 30 additions & 0 deletions desloppify/tests/detectors/test_external_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``**/<name>/**`` 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."""
Expand Down