Skip to content

Commit 42c82a8

Browse files
Robcs01Botclaude
andcommitted
feat(report): per-scan summary of skipped paths
Walking system roots as a non-root user routinely hits inaccessible sub-trees and unreadable files. Each occurrence is correct best-effort behaviour, but in aggregate the operator should see how much of the filesystem was actually inspected — a partial scan from missing privileges is not a clean scan. New `skip_report.py` module with a `SkipReport` singleton that the walk / read helpers append to whenever they swallow PermissionError or OSError. Renderer in report.py prints a yellow block at the end of the scan listing the first few paths in each category and the total counts, with a hint to re-run with elevated privileges. Silent when no paths were skipped. Instrumented sites: * config.pruned_walk — now uses os.walk's onerror callback so depth-N permission denials are recorded, not just top-level root denials (os.walk silently drops per-subdirectory errors without onerror set). * config.read_if_contains * git_repo_index._find_repo_roots (same onerror treatment) and its targeted file readers (description, refs/heads, packed-refs, workflow files). * persistence_scanner (config-dir glob, shell-rc read, /tmp iter). * ioc_scanner (known-paths exists check, walk-files hash-read). Intentionally not instrumented: * subprocess_utils.run_safe — tool-availability is already loud where the operator needs to know. * network_scanner /proc/$PID/exe reads — PermissionError there is the normal case for other users' PIDs and would flood the summary on any multi-user host. * json.JSONDecodeError / ast.SyntaxError on third-party files — different category (parse failure of code we don't own), not a "skipped path" in the user-facing sense. Test isolation: new autouse fixture in conftest.py resets the singleton before and after every test. Live PermissionError tests guard with `pytest.skip` when running as root, since POSIX permission bits don't apply. Smoke test on a real Linux machine: 65 paths recorded (mostly /opt/containerd, /var/cache/*, /var/log/journal/*) — all expected for a non-root scan. 408 tests pass (was 398). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 89d7999 commit 42c82a8

10 files changed

Lines changed: 382 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
### Added
6+
- **Per-scan skip summary.** New `skip_report.py` module with a `SkipReport` singleton that the walk / read helpers append to whenever they swallow a `PermissionError` or `OSError`. Instrumented sites: `config.pruned_walk` (with `onerror` callback so depth-N denials are caught, not just the top-level root), `config.read_if_contains`, `git_repo_index._find_repo_roots` and its targeted file readers, `persistence_scanner` (config-dir, shell-rc, /tmp iteration), and `ioc_scanner` (known-paths exists check + walk-files hash-read). A new `print_skip_summary` block at the end of the report tells the operator how many paths were not inspected and shows the first few of each category, so a partial scan from missing privileges is no longer indistinguishable from a clean scan. Silent when no paths were skipped. Test isolation handled by an autouse fixture in `tests/conftest.py` that resets the singleton between tests.
7+
58
### Changed
69
- **Fail loud on malformed threat profiles.** `_load_from_dir` no longer swallows `KeyError` / `tomllib.TOMLDecodeError` / `re.error` from individual profiles. Any broken TOML — missing required field, syntax error, or invalid regex — now raises `InvalidThreatProfileError` with the offending file path and stops the scan. A user who wrote a profile expects it to be active; silently logging at WARNING and continuing was the kind of stealth-failure mode that §8 (Fail Fast, Fail Loud) explicitly warns against.
710
- **Regex compilation moved from scan time to load time.** `GitArtifactsIOC.workflow_name_regexes` and `branch_name_regexes` are now `tuple[re.Pattern[str], ...]` (was `tuple[str, ...]`). `_parse_git_artifacts` compiles each pattern; a malformed pattern raises `re.error` with the field name and the offending pattern. The `aggregate_indicators` function no longer needs `_compile_into` or any exception handling — patterns are pre-validated.

scan_supply_chain/config.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from collections.abc import Generator
1313
from pathlib import Path
1414

15+
from .skip_report import note_permission_error, note_read_error
16+
1517
logger = logging.getLogger(__name__)
1618

1719
# Directories always skipped during any filesystem walk
@@ -59,19 +61,36 @@ def read_if_contains(path: Path, keyword: str) -> str | None:
5961
return None
6062
try:
6163
text = path.read_text(errors="ignore")
62-
except (PermissionError, OSError):
63-
logger.debug("Cannot read %s", path)
64+
except PermissionError:
65+
note_permission_error(path)
66+
return None
67+
except OSError as exc:
68+
note_read_error(path, type(exc).__name__)
6469
return None
6570
return text if keyword in text else None
6671

6772

6873
def pruned_walk(
6974
root: Path, skip_dirs: frozenset[str]
7075
) -> Generator[tuple[str, list[str], list[str]], None, None]:
71-
"""os.walk with directory pruning and PermissionError handling."""
76+
"""os.walk with directory pruning and skip-report instrumentation.
77+
78+
Per-directory ``OSError``s (typically ``PermissionError`` on
79+
inaccessible sub-trees) are routed through the ``onerror`` callback
80+
so each one is recorded individually — by default ``os.walk``
81+
silently drops them.
82+
"""
83+
84+
def _on_error(exc: OSError) -> None:
85+
path = Path(exc.filename) if exc.filename else root
86+
if isinstance(exc, PermissionError):
87+
note_permission_error(path)
88+
else:
89+
note_read_error(path, type(exc).__name__)
90+
7291
try:
73-
for dirpath, dirnames, filenames in os.walk(root):
92+
for dirpath, dirnames, filenames in os.walk(root, onerror=_on_error):
7493
dirnames[:] = [d for d in dirnames if d not in skip_dirs]
7594
yield dirpath, dirnames, filenames
7695
except PermissionError:
77-
logger.debug("Permission denied walking %s", root)
96+
note_permission_error(root)

scan_supply_chain/git_repo_index.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from pathlib import Path
2525

2626
from .config import GIT_REPO_WALK_SKIP_DIRS
27+
from .skip_report import note_permission_error, note_read_error
2728
from .subprocess_utils import run_safe
2829

2930
logger = logging.getLogger(__name__)
@@ -74,14 +75,24 @@ def _find_repo_roots(roots: Iterable[str]) -> Iterator[Path]:
7475
"""Yield the parent directory of each ``.git`` directory found.
7576
7677
Does not descend into ``.git/`` itself. Prunes heavy/uninteresting
77-
sibling trees per ``GIT_REPO_WALK_SKIP_DIRS``.
78+
sibling trees per ``GIT_REPO_WALK_SKIP_DIRS``. Per-subdirectory
79+
permission errors are routed through ``onerror`` into the
80+
skip-report so the post-scan summary reflects them.
7881
"""
82+
83+
def _on_error(exc: OSError) -> None:
84+
path = Path(exc.filename) if exc.filename else Path("<unknown>")
85+
if isinstance(exc, PermissionError):
86+
note_permission_error(path)
87+
else:
88+
note_read_error(path, type(exc).__name__)
89+
7990
for raw in roots:
8091
root_path = Path(raw)
8192
if not root_path.is_dir():
8293
continue
8394
try:
84-
for dirpath, dirnames, _ in os.walk(root_path):
95+
for dirpath, dirnames, _ in os.walk(root_path, onerror=_on_error):
8596
dirnames[:] = [d for d in dirnames if d not in GIT_REPO_WALK_SKIP_DIRS]
8697
if ".git" in dirnames:
8798
yield Path(dirpath)
@@ -91,7 +102,7 @@ def _find_repo_roots(roots: Iterable[str]) -> Iterator[Path]:
91102
# per outer repo is enough for the anti-worm scan.
92103
dirnames.remove(".git")
93104
except PermissionError:
94-
logger.debug("Permission denied walking %s", root_path)
105+
note_permission_error(root_path)
95106

96107

97108
# ── Per-repo snapshot ───────────────────────────────────────────────────
@@ -113,7 +124,14 @@ def _read_description(git_dir: Path) -> str:
113124
desc_path = git_dir / "description"
114125
try:
115126
return desc_path.read_text(errors="ignore").strip()
116-
except (FileNotFoundError, PermissionError, OSError):
127+
except FileNotFoundError:
128+
# Missing description file is normal — don't record.
129+
return ""
130+
except PermissionError:
131+
note_permission_error(desc_path)
132+
return ""
133+
except OSError as exc:
134+
note_read_error(desc_path, type(exc).__name__)
117135
return ""
118136

119137

@@ -125,8 +143,10 @@ def _read_local_branches(git_dir: Path) -> Iterator[str]:
125143
for entry in heads_dir.rglob("*"):
126144
if entry.is_file():
127145
yield entry.relative_to(heads_dir).as_posix()
128-
except (PermissionError, OSError):
129-
logger.debug("Cannot read %s", heads_dir)
146+
except PermissionError:
147+
note_permission_error(heads_dir)
148+
except OSError as exc:
149+
note_read_error(heads_dir, type(exc).__name__)
130150

131151
packed = git_dir / "packed-refs"
132152
if packed.is_file():
@@ -141,8 +161,10 @@ def _read_local_branches(git_dir: Path) -> Iterator[str]:
141161
prefix = "refs/heads/"
142162
if ref.startswith(prefix):
143163
yield ref[len(prefix):]
144-
except (PermissionError, OSError):
145-
logger.debug("Cannot read %s", packed)
164+
except PermissionError:
165+
note_permission_error(packed)
166+
except OSError as exc:
167+
note_read_error(packed, type(exc).__name__)
146168

147169

148170
def _list_workflow_files(repo_root: Path) -> Iterator[Path]:
@@ -154,8 +176,10 @@ def _list_workflow_files(repo_root: Path) -> Iterator[Path]:
154176
for entry in workflows.iterdir():
155177
if entry.is_file() and entry.suffix in _WORKFLOW_SUFFIXES:
156178
yield entry
157-
except (PermissionError, OSError):
158-
logger.debug("Cannot read %s", workflows)
179+
except PermissionError:
180+
note_permission_error(workflows)
181+
except OSError as exc:
182+
note_read_error(workflows, type(exc).__name__)
159183

160184

161185
def _read_recent_emails(repo_root: Path, git_available: bool) -> Iterator[str]:

scan_supply_chain/ioc_scanner.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
print_ioc_found,
2323
)
2424
from .models import ScanResults
25+
from .skip_report import note_permission_error, note_read_error
2526

2627
if TYPE_CHECKING:
2728
from .ecosystem_base import EcosystemPlugin
@@ -55,7 +56,7 @@ def _check_known_paths(
5556
results.iocs.append(str(path))
5657
found = True
5758
except PermissionError:
58-
logger.debug("Permission denied checking %s", path)
59+
note_permission_error(path)
5960
if not found:
6061
print_clean()
6162

@@ -90,9 +91,14 @@ def _scan_walk_files(
9091
digest = hashlib.sha256(file_path.read_bytes()).hexdigest()
9192
if digest not in known_hashes:
9293
continue
93-
except (PermissionError, OSError):
94-
# Can't read — still report as suspicious
95-
pass
94+
except PermissionError:
95+
# Can't read — still report as suspicious,
96+
# but record the path for the post-scan
97+
# summary so the operator knows why no hash
98+
# comparison happened.
99+
note_permission_error(file_path)
100+
except OSError as exc:
101+
note_read_error(file_path, type(exc).__name__)
96102
print_ioc_found(str(file_path))
97103
results.iocs.append(str(file_path))
98104
found = True

scan_supply_chain/persistence_scanner.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from .config import read_if_contains
2323
from .models import FindingCategory, ScanResults, scanner_check
24+
from .skip_report import note_permission_error, note_read_error
2425
from .subprocess_utils import run_safe
2526

2627
logger = logging.getLogger(__name__)
@@ -96,8 +97,10 @@ def _check_config_dir(
9697
str(config_file),
9798
2,
9899
)
99-
except (PermissionError, OSError):
100-
logger.debug("Cannot read %s", directory)
100+
except PermissionError:
101+
note_permission_error(directory)
102+
except OSError as exc:
103+
note_read_error(directory, type(exc).__name__)
101104

102105

103106
# ── Individual checkers ─────────────────────────────────────────────────
@@ -141,8 +144,10 @@ def _check_shell_rc(results: ScanResults, search_terms: Sequence[str]) -> None:
141144
str(rc_path),
142145
2,
143146
)
144-
except (PermissionError, OSError):
145-
logger.debug("Cannot read %s", rc_path)
147+
except PermissionError:
148+
note_permission_error(rc_path)
149+
except OSError as exc:
150+
note_read_error(rc_path, type(exc).__name__)
146151

147152

148153
def _check_tmp_scripts(results: ScanResults, package: str) -> None:
@@ -162,8 +167,10 @@ def _check_tmp_scripts(results: ScanResults, package: str) -> None:
162167
_check_tmp_python_file(results, f, package)
163168
elif f.suffix in (".sh", ".bash"):
164169
_check_tmp_shell_file(results, f, package)
165-
except (PermissionError, OSError):
166-
logger.debug("Cannot read /tmp")
170+
except PermissionError:
171+
note_permission_error(tmp)
172+
except OSError as exc:
173+
note_read_error(tmp, type(exc).__name__)
167174

168175

169176
def _check_tmp_python_file(results: ScanResults, path: Path, package: str) -> None:

scan_supply_chain/report.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .formatting import BOLD, GREEN, RED, RESET, YELLOW, print_separator
88
from .models import Confidence, ConfigReference, ScanResults, SourceReference
99
from .scoring import compute_confidence
10+
from .skip_report import SkipReport
1011

1112
if TYPE_CHECKING:
1213
from .threat_profile import ThreatProfile
@@ -278,6 +279,62 @@ def print_anti_worm_report(results: ScanResults) -> None:
278279
print()
279280

280281

282+
# ── Skip-report renderer ────────────────────────────────────────────────
283+
284+
285+
_SKIP_SUMMARY_HEAD = 5
286+
287+
288+
def print_skip_summary(report: SkipReport) -> None:
289+
"""Render the per-scan skip report. Silent when empty.
290+
291+
Filesystem paths the scanner couldn't walk or read are listed here
292+
so the operator knows how much of the disk was actually inspected —
293+
a partial scan from missing privileges is not a clean scan.
294+
"""
295+
if report.is_empty:
296+
return
297+
298+
print_separator()
299+
print(
300+
f"\n{YELLOW}{BOLD}Skipped {report.total} path(s) during this scan{RESET}\n"
301+
)
302+
303+
if report.permission_errors:
304+
print(
305+
f" {YELLOW}{BOLD}Permission denied "
306+
f"({len(report.permission_errors)}):{RESET}"
307+
)
308+
_print_path_head(sorted(report.permission_errors))
309+
310+
if report.read_errors:
311+
print(
312+
f" {YELLOW}{BOLD}Read errors "
313+
f"({len(report.read_errors)}):{RESET}"
314+
)
315+
for path, reason in sorted(report.read_errors.items())[:_SKIP_SUMMARY_HEAD]:
316+
print(f" {path} ({reason})")
317+
remaining = len(report.read_errors) - _SKIP_SUMMARY_HEAD
318+
if remaining > 0:
319+
print(f" ... and {remaining} more")
320+
print()
321+
322+
print(
323+
f" {YELLOW}Hint: re-run with elevated privileges to cover paths "
324+
f"the current user cannot access.{RESET}\n"
325+
)
326+
327+
328+
def _print_path_head(paths: list) -> None:
329+
"""Print the first few paths plus a count of the rest."""
330+
for p in paths[:_SKIP_SUMMARY_HEAD]:
331+
print(f" {p}")
332+
remaining = len(paths) - _SKIP_SUMMARY_HEAD
333+
if remaining > 0:
334+
print(f" ... and {remaining} more")
335+
print()
336+
337+
281338
# ── Multi-threat summary ────────────────────────────────────────────────
282339

283340

scan_supply_chain/scanner.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
print_anti_worm_report,
2727
print_config_refs,
2828
print_multi_threat_summary,
29+
print_skip_summary,
2930
print_source_refs,
3031
)
3132
from .search_roots import build_search_roots, deduplicate_roots
33+
from .skip_report import get_current_report, reset_current_report
3234
from .source_scanner import scan_source_and_configs
3335
from .threat_profile import (
3436
ThreatProfile,
@@ -183,6 +185,9 @@ def main():
183185
print("No threat profiles found. Nothing to scan.")
184186
sys.exit(0)
185187

188+
# Fresh skip-report for this scan — instrumented helpers append to it.
189+
reset_current_report()
190+
186191
policy = detect_platform()
187192

188193
print_banner(__version__)
@@ -222,10 +227,12 @@ def main():
222227
)
223228
all_results.append((threat, results))
224229

225-
# Final combined report — anti-worm section first, then per-threat
230+
# Final combined report — anti-worm section first, then per-threat,
231+
# then the skip-report telling the operator what coverage was missed.
226232
print()
227233
print_anti_worm_report(anti_worm_results)
228234
print_multi_threat_summary(all_results)
235+
print_skip_summary(get_current_report())
229236

230237
any_compromised = any(not r.is_clean for _, r in all_results)
231238
any_worm_signals = not anti_worm_results.is_clean

0 commit comments

Comments
 (0)