Skip to content

Commit 601a2ca

Browse files
Jean-Michel Rogeroclaude
andcommitted
DRY: Clean Code refactoring — eliminate duplication across scanners, platforms, and tests
- Extract BasePlatformPolicy with shared home_conda_dirs() and _first_existing_dir() - Unify 3 cache walkers into _scan_cache_dir() with configurable search targets - Add subprocess_utils.run_safe() eliminating 4 identical try/except blocks - Add config.read_if_contains() replacing repeated read-then-check patterns - Add scanner_check() context manager combining header + findings tracking - Inline trivial _add_persistence() and _add_cache_finding() wrappers - Name _SEPARATOR_WIDTH constant in formatting.py - Add shared test helpers: mock_run_safe, mock_subprocess_run, mock_tool_available - Bump version to 0.8.1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 08a4b8f commit 601a2ca

19 files changed

Lines changed: 322 additions & 291 deletions

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Changelog
22

3+
## 0.8.1 — 2026-04-02
4+
5+
### Changed
6+
- **DRY: Platform base class**`BasePlatformPolicy` provides default `home_conda_dirs()` and `_first_existing_dir()` helper. Linux and Darwin inherit defaults; Windows overrides casing.
7+
- **DRY: Cache scanner** — Three near-identical cache walkers (`pip`, `npm`, `pnpm`) unified into `_scan_cache_dir()` with configurable search targets.
8+
- **DRY: Subprocess helper** — New `subprocess_utils.run_safe()` replaces 4 identical `subprocess.run` try/except blocks across `persistence_scanner`, `ioc_windows`.
9+
- **DRY: File read helper** — New `config.read_if_contains()` replaces repeated read-then-check patterns in `persistence_scanner`, `history_scanner`.
10+
- **DRY: Scanner boilerplate** — New `scanner_check()` context manager combines `print_check_header()` + `track_findings()` into a single call.
11+
- **DRY: Inlined wrappers** — Removed trivial `_add_persistence()` and `_add_cache_finding()` one-liner delegates.
12+
- **DRY: Test helpers** — Shared `mock_run_safe`, `mock_subprocess_run`, `mock_tool_available`, `scan_results` fixture in `conftest.py`.
13+
- Named magic constant `_SEPARATOR_WIDTH` in `formatting.py`.
14+
- 355 tests, all passing.
15+
16+
## 0.8.0 — 2026-04-01
17+
18+
### Added
19+
- **Evidence scoring** — 4-tier confidence (LOW/MEDIUM/HIGH/CRITICAL) replaces binary clean/compromised verdict. New `Confidence` enum, `FindingCategory` enum, `Finding` dataclass, and `scoring.py` module.
20+
- **AST-based Python detection**`ast_scanner.py` uses `ast.parse()` to find real imports and attribute access. Eliminates false positives from string literals, regex patterns, and comments. Falls back to regex on `SyntaxError`.
21+
- **Structured socket parsing**`network_scanner.py` parses `ss`/`lsof` output into typed `ConnectionRecord` structs. C2 detection reports process name, PID, and executable path (Linux `/proc` enrichment).
22+
- **Persistence scanner**`persistence_scanner.py` checks crontab, shell rc files, systemd user services, XDG autostart, `/tmp` scripts, and macOS LaunchAgents.
23+
- **Cache scanner**`cache_scanner.py` checks pip, npm, and pnpm caches for traces of compromised packages. Ecosystem-gated.
24+
- **History scanner**`history_scanner.py` searches `.bash_history` and `.zsh_history` for `pip install`/`npm install`/`yarn add`/`pnpm add` commands.
25+
- **Lockfile version extraction**`yarn.lock` and `pnpm-lock.yaml` phantom dep reports now include the resolved version (e.g., `phantom:plain-crypto-js@4.2.1`).
26+
- 62 new tests. **348 tests total.**
27+
28+
### Changed
29+
- C2 connection check uses structured parsing instead of IP substring matching.
30+
- Python source scanning uses AST first, regex as fallback (no change for JS/TS).
31+
- `ScanResults` gains a `findings` list alongside existing `iocs` (backward compatible).
32+
333
## 0.7.0 — 2026-04-01
434

535
### Performance

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "scan-supply-chain"
3-
version = "0.7.0"
3+
version = "0.8.1"
44
description = "Supply chain compromise scanner — detects known PyPI and npm attacks via data-driven threat profiles"
55
readme = "README.md"
66
license = "MIT"

scan_supply_chain/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
Threat profiles are defined in threats/*.toml and are user-extensible.
55
"""
66

7-
__version__ = "0.7.0"
7+
__version__ = "0.8.1"

scan_supply_chain/cache_scanner.py

Lines changed: 40 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,22 @@
77
import sys
88
from pathlib import Path
99

10-
from .formatting import print_check_header
11-
from .models import FindingCategory, ScanResults, track_findings
10+
from .models import FindingCategory, ScanResults, scanner_check
1211

1312
logger = logging.getLogger(__name__)
1413

1514

1615
def scan_caches(results: ScanResults, package: str, ecosystem: str) -> None:
1716
"""Check package manager caches for traces of the compromised package."""
18-
print_check_header("package manager caches")
19-
with track_findings(results, "No cache traces found"):
17+
with scanner_check(results, "package manager caches", "No cache traces found"):
2018
if ecosystem == "pypi":
2119
_scan_pip_cache(results, package)
2220
elif ecosystem == "npm":
2321
_scan_npm_cache(results, package)
2422
_scan_pnpm_store(results, package)
2523

2624

27-
def _add_cache_finding(results: ScanResults, description: str, evidence: str) -> None:
28-
results.add_finding(FindingCategory.CACHE_TRACE, description, evidence, 1)
25+
# ── Helpers ─────────────────────────────────────────────────────────────
2926

3027

3128
def _pip_cache_dir() -> Path:
@@ -39,55 +36,55 @@ def _pip_cache_dir() -> Path:
3936
return Path.home() / ".cache" / "pip"
4037

4138

42-
def _scan_pip_cache(results: ScanResults, package: str) -> None:
43-
cache_dir = _pip_cache_dir()
39+
def _scan_cache_dir(
40+
results: ScanResults,
41+
cache_dir: Path,
42+
package: str,
43+
label: str,
44+
*,
45+
check_dirs: bool = False,
46+
check_files: bool = True,
47+
) -> None:
48+
"""Walk a cache directory for entries matching the package name."""
4449
if not cache_dir.is_dir():
4550
return
4651
try:
4752
for dirpath, dirnames, filenames in os.walk(cache_dir):
48-
for name in dirnames + filenames:
53+
items: list[str] = []
54+
if check_dirs:
55+
items.extend(dirnames)
56+
if check_files:
57+
items.extend(filenames)
58+
for name in items:
4959
if package in name.lower():
50-
_add_cache_finding(
51-
results,
52-
f"pip cache: {name}",
60+
results.add_finding(
61+
FindingCategory.CACHE_TRACE,
62+
f"{label}: {name}",
5363
os.path.join(dirpath, name),
64+
1,
5465
)
5566
return # one hit per cache is enough
5667
except (PermissionError, OSError):
57-
logger.debug("Cannot read pip cache at %s", cache_dir)
68+
logger.debug("Cannot read %s at %s", label, cache_dir)
69+
70+
71+
def _scan_pip_cache(results: ScanResults, package: str) -> None:
72+
_scan_cache_dir(
73+
results, _pip_cache_dir(), package, "pip cache",
74+
check_dirs=True, check_files=True,
75+
)
5876

5977

6078
def _scan_npm_cache(results: ScanResults, package: str) -> None:
61-
cache_dir = Path.home() / ".npm" / "_cacache"
62-
if not cache_dir.is_dir():
63-
return
64-
try:
65-
for dirpath, _, filenames in os.walk(cache_dir):
66-
for fn in filenames:
67-
if package in fn:
68-
_add_cache_finding(
69-
results,
70-
f"npm cache: {fn}",
71-
os.path.join(dirpath, fn),
72-
)
73-
return
74-
except (PermissionError, OSError):
75-
logger.debug("Cannot read npm cache")
79+
_scan_cache_dir(
80+
results, Path.home() / ".npm" / "_cacache", package, "npm cache",
81+
)
7682

7783

7884
def _scan_pnpm_store(results: ScanResults, package: str) -> None:
79-
store = Path.home() / ".local" / "share" / "pnpm" / "store"
80-
if not store.is_dir():
81-
return
82-
try:
83-
for dirpath, dirnames, _ in os.walk(store):
84-
for d in dirnames:
85-
if package in d:
86-
_add_cache_finding(
87-
results,
88-
f"pnpm store: {d}",
89-
os.path.join(dirpath, d),
90-
)
91-
return
92-
except (PermissionError, OSError):
93-
logger.debug("Cannot read pnpm store")
85+
_scan_cache_dir(
86+
results,
87+
Path.home() / ".local" / "share" / "pnpm" / "store",
88+
package, "pnpm store",
89+
check_dirs=True, check_files=False,
90+
)

scan_supply_chain/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@
4545
SOURCE_SCAN_SKIP_DIRS = _COMMON_SKIP_DIRS | {"site-packages", "node_modules"}
4646

4747

48+
def read_if_contains(path: Path, keyword: str) -> str | None:
49+
"""Read a text file if it mentions *keyword*; return text or ``None``."""
50+
if not path.is_file():
51+
return None
52+
try:
53+
text = path.read_text(errors="ignore")
54+
except (PermissionError, OSError):
55+
logger.debug("Cannot read %s", path)
56+
return None
57+
return text if keyword in text else None
58+
59+
4860
def pruned_walk(
4961
root: Path, skip_dirs: frozenset[str]
5062
) -> Generator[tuple[str, list[str], list[str]], None, None]:

scan_supply_chain/formatting.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,25 @@ def _code(escape: str) -> str:
4343
RESET = _code("\033[0m")
4444

4545

46+
_SEPARATOR_WIDTH = 63
47+
48+
4649
def print_banner(version: str = ""):
4750
ver_str = f"v{version}" if version else ""
4851
title = f"Supply Chain Compromise Scanner {ver_str}".strip()
49-
# Pad title to fill the box (59 chars inner width)
50-
padded = f" {title}" + " " * (60 - len(title) - 3)
52+
# Pad title to fill the box (inner width = _SEPARATOR_WIDTH - 3)
53+
inner = _SEPARATOR_WIDTH - 3
54+
padded = f" {title}" + " " * (inner - len(title))
5155
print(f"{CYAN}{BOLD}")
52-
print("+" + "=" * 63 + "+")
56+
print("+" + "=" * _SEPARATOR_WIDTH + "+")
5357
print(f"|{padded}|")
5458
print("| Detects known PyPI and npm supply chain attacks |")
55-
print("+" + "=" * 63 + "+")
59+
print("+" + "=" * _SEPARATOR_WIDTH + "+")
5660
print(RESET)
5761

5862

5963
def print_separator():
60-
print(f"{CYAN}{'-' * 63}{RESET}")
64+
print(f"{CYAN}{'-' * _SEPARATOR_WIDTH}{RESET}")
6165

6266

6367
def print_phase_header(number: int, title: str):

scan_supply_chain/history_scanner.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,19 @@
22

33
from __future__ import annotations
44

5-
import logging
65
from pathlib import Path
76

8-
from .formatting import print_check_header
9-
from .models import FindingCategory, ScanResults, track_findings
10-
11-
logger = logging.getLogger(__name__)
7+
from .config import read_if_contains
8+
from .models import FindingCategory, ScanResults, scanner_check
129

1310
_PYPI_INSTALL_CMDS = ("pip install", "pip3 install", "uv pip install", "uv add")
1411
_NPM_INSTALL_CMDS = ("npm install", "npm i ", "yarn add", "pnpm add", "pnpm install")
1512

1613

1714
def scan_history(results: ScanResults, package: str, ecosystem: str) -> None:
1815
"""Search shell history for install commands mentioning the package."""
19-
print_check_header("shell history for install commands")
20-
with track_findings(results, "No install commands found in shell history"):
16+
with scanner_check(results, "shell history for install commands",
17+
"No install commands found in shell history"):
2118
install_cmds = _PYPI_INSTALL_CMDS if ecosystem == "pypi" else _NPM_INSTALL_CMDS
2219

2320
home = Path.home()
@@ -32,12 +29,8 @@ def _scan_history_file(
3229
package: str,
3330
install_cmds: tuple[str, ...],
3431
) -> None:
35-
if not path.is_file():
36-
return
37-
try:
38-
text = path.read_text(errors="ignore")
39-
except (PermissionError, OSError):
40-
logger.debug("Cannot read %s", path)
32+
text = read_if_contains(path, package)
33+
if text is None:
4134
return
4235

4336
for line in text.splitlines():

scan_supply_chain/ioc_windows.py

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
"""Windows-only IOC checks: Registry Run keys, Scheduled Tasks."""
22

3-
import logging
4-
import subprocess
5-
63
from .formatting import (
74
BOLD,
85
RED,
96
RESET,
107
print_check_header,
118
print_clean,
129
)
13-
14-
logger = logging.getLogger(__name__)
10+
from .subprocess_utils import run_safe
1511

1612

1713
def _check_registry_run_keys(
@@ -28,23 +24,18 @@ def _check_registry_run_keys(
2824
r"HKLM\Software\Microsoft\Windows\CurrentVersion\Run",
2925
]
3026
for key_path in run_keys:
31-
try:
32-
output = subprocess.run(
33-
["reg", "query", key_path],
34-
capture_output=True,
35-
text=True,
36-
timeout=10,
37-
).stdout.lower()
38-
for keyword in keywords:
39-
if keyword.lower() in output:
40-
print(
41-
f" {RED}{BOLD}! SUSPICIOUS REGISTRY ENTRY "
42-
f"in {key_path} (matched: {keyword}){RESET}"
43-
)
44-
results.iocs.append(f"registry:{key_path}:{keyword}")
45-
found = True
46-
except (subprocess.TimeoutExpired, OSError):
47-
logger.debug("Failed to query registry key %s", key_path)
27+
output = run_safe(["reg", "query", key_path], timeout=10)
28+
if output is None:
29+
continue
30+
output = output.lower()
31+
for keyword in keywords:
32+
if keyword.lower() in output:
33+
print(
34+
f" {RED}{BOLD}! SUSPICIOUS REGISTRY ENTRY "
35+
f"in {key_path} (matched: {keyword}){RESET}"
36+
)
37+
results.iocs.append(f"registry:{key_path}:{keyword}")
38+
found = True
4839
if not found:
4940
print_clean("No suspicious Run key entries")
5041

@@ -58,13 +49,9 @@ def _check_scheduled_tasks(
5849
return
5950
print_check_header("Scheduled Tasks for persistence")
6051
found = False
61-
try:
62-
output = subprocess.run(
63-
["schtasks", "/query", "/fo", "CSV"],
64-
capture_output=True,
65-
text=True,
66-
timeout=15,
67-
).stdout.lower()
52+
output = run_safe(["schtasks", "/query", "/fo", "CSV"], timeout=15)
53+
if output is not None:
54+
output = output.lower()
6855
for keyword in keywords:
6956
if keyword.lower() in output:
7057
print(
@@ -73,8 +60,6 @@ def _check_scheduled_tasks(
7360
)
7461
results.iocs.append(f"schtask:{keyword}")
7562
found = True
76-
except (subprocess.TimeoutExpired, OSError):
77-
logger.debug("Failed to query scheduled tasks")
7863
if not found:
7964
print_clean("No suspicious scheduled tasks")
8065

scan_supply_chain/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,15 @@ def track_findings(
129129
from .formatting import print_clean
130130

131131
print_clean(clean_message)
132+
133+
134+
@contextmanager
135+
def scanner_check(
136+
results: ScanResults, header: str, clean_message: str
137+
) -> Generator[None, None, None]:
138+
"""Print check header, then clean_message if no findings were added."""
139+
from .formatting import print_check_header
140+
141+
print_check_header(header)
142+
with track_findings(results, clean_message):
143+
yield

0 commit comments

Comments
 (0)