@intentsolutionsio/penetration-tester 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/commands/pentest.md +84 -0
- package/commands/scan-headers.md +43 -0
- package/package.json +40 -0
- package/skills/performing-penetration-testing/SKILL.md +266 -0
- package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +284 -0
- package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +452 -0
- package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +365 -0
- package/skills/performing-penetration-testing/scripts/code_security_scanner.py +780 -0
- package/skills/performing-penetration-testing/scripts/dependency_auditor.py +777 -0
- package/skills/performing-penetration-testing/scripts/requirements.txt +4 -0
- package/skills/performing-penetration-testing/scripts/security_scanner.py +1166 -0
- package/skills/performing-penetration-testing/scripts/setup_pentest_env.sh +199 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified dependency vulnerability scanner.
|
|
3
|
+
|
|
4
|
+
Scans project dependencies across multiple ecosystems (npm, pip) and produces
|
|
5
|
+
a consolidated vulnerability report. Wraps native audit tools (npm audit,
|
|
6
|
+
pip-audit, pip check) and normalizes their output into a unified finding
|
|
7
|
+
format suitable for CI pipelines and human review.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 dependency_auditor.py /path/to/project [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--scanners npm,pip Comma-separated list of scanners to run
|
|
14
|
+
--min-severity low Minimum severity to report (low|moderate|high|critical)
|
|
15
|
+
--output findings.json Write JSON report to file
|
|
16
|
+
--verbose Enable verbose progress output
|
|
17
|
+
|
|
18
|
+
Exit codes:
|
|
19
|
+
0 No critical or high severity findings
|
|
20
|
+
1 Critical or high severity findings detected, or fatal error
|
|
21
|
+
|
|
22
|
+
Copyright 2026 Claude Code Plugins contributors. Licensed under MIT.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Optional
|
|
33
|
+
|
|
34
|
+
# Severity ranking for sorting and filtering. Lower number means more severe.
|
|
35
|
+
SEVERITY_RANK: dict[str, int] = {
|
|
36
|
+
"critical": 0,
|
|
37
|
+
"high": 1,
|
|
38
|
+
"moderate": 2,
|
|
39
|
+
"low": 3,
|
|
40
|
+
"info": 4,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
SUPPORTED_SCANNERS: set[str] = {"npm", "pip"}
|
|
44
|
+
FUTURE_SCANNERS: dict[str, str] = {
|
|
45
|
+
"cargo": "Cargo.toml",
|
|
46
|
+
"go": "go.mod",
|
|
47
|
+
"bundler": "Gemfile",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
SUBPROCESS_TIMEOUT: int = 60
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def log(message: str, verbose: bool = True) -> None:
|
|
54
|
+
"""Print a progress message to stderr."""
|
|
55
|
+
if verbose:
|
|
56
|
+
print(f"[*] {message}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def log_warn(message: str) -> None:
|
|
60
|
+
"""Print a warning message to stderr (always shown)."""
|
|
61
|
+
print(f"[!] {message}", file=sys.stderr)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def log_error(message: str) -> None:
|
|
65
|
+
"""Print an error message to stderr (always shown)."""
|
|
66
|
+
print(f"[-] {message}", file=sys.stderr)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Project detection
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def detect_project_type(directory: Path) -> list[str]:
|
|
74
|
+
"""Auto-detect project ecosystems by scanning for manifest files.
|
|
75
|
+
|
|
76
|
+
Checks for well-known dependency manifest files and returns a list of
|
|
77
|
+
ecosystem identifiers. Supported ecosystems produce actionable scans;
|
|
78
|
+
future-support ecosystems print an informational message.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
directory: Root directory of the project to scan.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of detected ecosystem strings (e.g. ["npm", "pip"]).
|
|
85
|
+
"""
|
|
86
|
+
detected: list[str] = []
|
|
87
|
+
|
|
88
|
+
# npm / Node.js
|
|
89
|
+
npm_markers = ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
|
|
90
|
+
if any((directory / marker).exists() for marker in npm_markers):
|
|
91
|
+
detected.append("npm")
|
|
92
|
+
|
|
93
|
+
# pip / Python
|
|
94
|
+
pip_markers = ["requirements.txt", "setup.py", "pyproject.toml", "Pipfile", "setup.cfg"]
|
|
95
|
+
if any((directory / marker).exists() for marker in pip_markers):
|
|
96
|
+
detected.append("pip")
|
|
97
|
+
|
|
98
|
+
# Future-support ecosystems
|
|
99
|
+
for ecosystem, manifest in FUTURE_SCANNERS.items():
|
|
100
|
+
if (directory / manifest).exists():
|
|
101
|
+
log_warn(
|
|
102
|
+
f"Detected {ecosystem} project ({manifest}), but {ecosystem} "
|
|
103
|
+
f"scanning is not yet implemented. Skipping."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return detected
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# npm audit
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def run_npm_audit(directory: Path) -> list[dict[str, Any]]:
|
|
114
|
+
"""Run npm audit and parse vulnerability findings.
|
|
115
|
+
|
|
116
|
+
Executes ``npm audit --json`` in the target directory and parses the
|
|
117
|
+
structured output. Handles missing npm installations and projects that
|
|
118
|
+
lack a node_modules directory.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
directory: Project root containing package.json.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of finding dicts with keys matching the unified schema.
|
|
125
|
+
"""
|
|
126
|
+
findings: list[dict[str, Any]] = []
|
|
127
|
+
|
|
128
|
+
# Check npm is available
|
|
129
|
+
npm_check = subprocess.run(
|
|
130
|
+
["npm", "--version"],
|
|
131
|
+
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT,
|
|
132
|
+
)
|
|
133
|
+
if npm_check.returncode != 0:
|
|
134
|
+
log_warn("npm is not installed or not in PATH. Skipping npm audit.")
|
|
135
|
+
return findings
|
|
136
|
+
|
|
137
|
+
# Check for package.json
|
|
138
|
+
if not (directory / "package.json").exists():
|
|
139
|
+
log_warn(f"No package.json found in {directory}. Skipping npm audit.")
|
|
140
|
+
return findings
|
|
141
|
+
|
|
142
|
+
# Warn if node_modules is missing
|
|
143
|
+
if not (directory / "node_modules").is_dir():
|
|
144
|
+
log_warn(
|
|
145
|
+
f"No node_modules directory in {directory}. "
|
|
146
|
+
f"Run 'npm install' first for accurate audit results."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
["npm", "audit", "--json"],
|
|
152
|
+
capture_output=True, text=True,
|
|
153
|
+
cwd=str(directory),
|
|
154
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
155
|
+
)
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
log_error("npm audit timed out after {SUBPROCESS_TIMEOUT}s.")
|
|
158
|
+
return findings
|
|
159
|
+
except FileNotFoundError:
|
|
160
|
+
log_warn("npm executable not found. Skipping npm audit.")
|
|
161
|
+
return findings
|
|
162
|
+
|
|
163
|
+
# npm audit returns non-zero when vulnerabilities exist, so we parse
|
|
164
|
+
# the output regardless of the return code.
|
|
165
|
+
if not result.stdout.strip():
|
|
166
|
+
log_warn("npm audit produced no output.")
|
|
167
|
+
return findings
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(result.stdout)
|
|
171
|
+
except json.JSONDecodeError as exc:
|
|
172
|
+
log_error(f"Failed to parse npm audit JSON output: {exc}")
|
|
173
|
+
return findings
|
|
174
|
+
|
|
175
|
+
findings.extend(_parse_npm_audit_v2(data))
|
|
176
|
+
if not findings:
|
|
177
|
+
findings.extend(_parse_npm_audit_v1(data))
|
|
178
|
+
|
|
179
|
+
return findings
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_npm_audit_v2(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
183
|
+
"""Parse npm audit JSON output in the v2 format (npm 7+).
|
|
184
|
+
|
|
185
|
+
The v2 format uses a top-level ``vulnerabilities`` object keyed by
|
|
186
|
+
package name.
|
|
187
|
+
"""
|
|
188
|
+
findings: list[dict[str, Any]] = []
|
|
189
|
+
vulnerabilities = data.get("vulnerabilities", {})
|
|
190
|
+
|
|
191
|
+
for pkg_name, vuln_info in vulnerabilities.items():
|
|
192
|
+
severity = vuln_info.get("severity", "moderate")
|
|
193
|
+
fix_available = vuln_info.get("fixAvailable")
|
|
194
|
+
fixed_version: Optional[str] = None
|
|
195
|
+
if isinstance(fix_available, dict):
|
|
196
|
+
fixed_version = fix_available.get("version")
|
|
197
|
+
|
|
198
|
+
# Each vulnerability entry may reference multiple advisories via "via"
|
|
199
|
+
via_entries = vuln_info.get("via", [])
|
|
200
|
+
if not via_entries:
|
|
201
|
+
findings.append({
|
|
202
|
+
"scanner": "npm",
|
|
203
|
+
"package": pkg_name,
|
|
204
|
+
"severity": _normalize_severity(severity),
|
|
205
|
+
"title": f"Vulnerability in {pkg_name}",
|
|
206
|
+
"detail": f"Severity: {severity}. Check npm audit for details.",
|
|
207
|
+
"cve": None,
|
|
208
|
+
"installed_version": vuln_info.get("range"),
|
|
209
|
+
"fixed_version": fixed_version,
|
|
210
|
+
})
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
for via in via_entries:
|
|
214
|
+
if isinstance(via, str):
|
|
215
|
+
# Indirect vulnerability reference (transitive dependency name)
|
|
216
|
+
findings.append({
|
|
217
|
+
"scanner": "npm",
|
|
218
|
+
"package": pkg_name,
|
|
219
|
+
"severity": _normalize_severity(severity),
|
|
220
|
+
"title": f"Depends on vulnerable {via}",
|
|
221
|
+
"detail": f"{pkg_name} is affected through dependency on {via}.",
|
|
222
|
+
"cve": None,
|
|
223
|
+
"installed_version": vuln_info.get("range"),
|
|
224
|
+
"fixed_version": fixed_version,
|
|
225
|
+
})
|
|
226
|
+
elif isinstance(via, dict):
|
|
227
|
+
cve = _extract_cve(via.get("url", ""))
|
|
228
|
+
findings.append({
|
|
229
|
+
"scanner": "npm",
|
|
230
|
+
"package": pkg_name,
|
|
231
|
+
"severity": _normalize_severity(via.get("severity", severity)),
|
|
232
|
+
"title": via.get("title", f"Vulnerability in {pkg_name}"),
|
|
233
|
+
"detail": via.get("title", "No description available."),
|
|
234
|
+
"cve": cve,
|
|
235
|
+
"installed_version": via.get("range") or vuln_info.get("range"),
|
|
236
|
+
"fixed_version": fixed_version,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return findings
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_npm_audit_v1(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
243
|
+
"""Parse npm audit JSON output in the v1 format (npm 6).
|
|
244
|
+
|
|
245
|
+
The v1 format uses a top-level ``advisories`` object keyed by advisory ID.
|
|
246
|
+
"""
|
|
247
|
+
findings: list[dict[str, Any]] = []
|
|
248
|
+
advisories = data.get("advisories", {})
|
|
249
|
+
|
|
250
|
+
for _adv_id, advisory in advisories.items():
|
|
251
|
+
cve_list = advisory.get("cves", [])
|
|
252
|
+
cve = cve_list[0] if cve_list else None
|
|
253
|
+
findings.append({
|
|
254
|
+
"scanner": "npm",
|
|
255
|
+
"package": advisory.get("module_name", "unknown"),
|
|
256
|
+
"severity": _normalize_severity(advisory.get("severity", "moderate")),
|
|
257
|
+
"title": advisory.get("title", "Unknown vulnerability"),
|
|
258
|
+
"detail": advisory.get("overview", "No description available."),
|
|
259
|
+
"cve": cve,
|
|
260
|
+
"installed_version": advisory.get("findings", [{}])[0].get("version"),
|
|
261
|
+
"fixed_version": advisory.get("patched_versions"),
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
return findings
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# pip-audit
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def run_pip_audit(directory: Path) -> list[dict[str, Any]]:
|
|
272
|
+
"""Run pip-audit and parse vulnerability findings.
|
|
273
|
+
|
|
274
|
+
Attempts to use pip-audit first. If not installed, tries to install it
|
|
275
|
+
via pip. If that also fails, falls back to ``pip list --outdated`` with
|
|
276
|
+
a warning that the results are not a true vulnerability scan.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
directory: Project root containing Python dependency manifests.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of finding dicts with keys matching the unified schema.
|
|
283
|
+
"""
|
|
284
|
+
findings: list[dict[str, Any]] = []
|
|
285
|
+
|
|
286
|
+
# Determine requirement file arguments
|
|
287
|
+
req_args = _pip_audit_requirement_args(directory)
|
|
288
|
+
|
|
289
|
+
# Try running pip-audit
|
|
290
|
+
pip_audit_cmd = ["pip-audit", "--format=json"] + req_args
|
|
291
|
+
try:
|
|
292
|
+
result = subprocess.run(
|
|
293
|
+
pip_audit_cmd,
|
|
294
|
+
capture_output=True, text=True,
|
|
295
|
+
cwd=str(directory),
|
|
296
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
297
|
+
)
|
|
298
|
+
return _parse_pip_audit_output(result.stdout)
|
|
299
|
+
except FileNotFoundError:
|
|
300
|
+
log_warn("pip-audit is not installed. Attempting to install it...")
|
|
301
|
+
except subprocess.TimeoutExpired:
|
|
302
|
+
log_error(f"pip-audit timed out after {SUBPROCESS_TIMEOUT}s.")
|
|
303
|
+
return findings
|
|
304
|
+
|
|
305
|
+
# Try installing pip-audit
|
|
306
|
+
install_result = subprocess.run(
|
|
307
|
+
[sys.executable, "-m", "pip", "install", "pip-audit", "--quiet"],
|
|
308
|
+
capture_output=True, text=True,
|
|
309
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
310
|
+
)
|
|
311
|
+
if install_result.returncode == 0:
|
|
312
|
+
try:
|
|
313
|
+
result = subprocess.run(
|
|
314
|
+
pip_audit_cmd,
|
|
315
|
+
capture_output=True, text=True,
|
|
316
|
+
cwd=str(directory),
|
|
317
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
318
|
+
)
|
|
319
|
+
return _parse_pip_audit_output(result.stdout)
|
|
320
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
# Fallback: pip list --outdated (not a real vulnerability scan)
|
|
324
|
+
log_warn(
|
|
325
|
+
"pip-audit unavailable. Falling back to 'pip list --outdated'. "
|
|
326
|
+
"NOTE: Outdated packages are not necessarily vulnerable."
|
|
327
|
+
)
|
|
328
|
+
return _run_pip_outdated_fallback()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _pip_audit_requirement_args(directory: Path) -> list[str]:
|
|
332
|
+
"""Build pip-audit CLI args pointing to requirement files if present."""
|
|
333
|
+
args: list[str] = []
|
|
334
|
+
req_file = directory / "requirements.txt"
|
|
335
|
+
if req_file.exists():
|
|
336
|
+
args.extend(["--requirement", str(req_file)])
|
|
337
|
+
return args
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _parse_pip_audit_output(raw_output: str) -> list[dict[str, Any]]:
|
|
341
|
+
"""Parse JSON output from pip-audit into unified findings."""
|
|
342
|
+
findings: list[dict[str, Any]] = []
|
|
343
|
+
if not raw_output.strip():
|
|
344
|
+
return findings
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
data = json.loads(raw_output)
|
|
348
|
+
except json.JSONDecodeError as exc:
|
|
349
|
+
log_error(f"Failed to parse pip-audit JSON output: {exc}")
|
|
350
|
+
return findings
|
|
351
|
+
|
|
352
|
+
# pip-audit can return either a top-level list or a dict with
|
|
353
|
+
# "dependencies" key depending on version.
|
|
354
|
+
vulns: list[dict[str, Any]] = []
|
|
355
|
+
if isinstance(data, list):
|
|
356
|
+
vulns = data
|
|
357
|
+
elif isinstance(data, dict):
|
|
358
|
+
vulns = data.get("dependencies", data.get("vulnerabilities", []))
|
|
359
|
+
|
|
360
|
+
for entry in vulns:
|
|
361
|
+
pkg_name = entry.get("name", "unknown")
|
|
362
|
+
installed = entry.get("version", entry.get("installed_version"))
|
|
363
|
+
vuln_list = entry.get("vulns", [entry] if "id" in entry else [])
|
|
364
|
+
|
|
365
|
+
for vuln in vuln_list:
|
|
366
|
+
vuln_id = vuln.get("id", "")
|
|
367
|
+
cve = vuln_id if vuln_id.startswith("CVE-") else _extract_cve(vuln_id)
|
|
368
|
+
description = vuln.get("description", vuln.get("detail", ""))
|
|
369
|
+
fix = vuln.get("fix_versions", vuln.get("fixed_version"))
|
|
370
|
+
if isinstance(fix, list):
|
|
371
|
+
fix = fix[0] if fix else None
|
|
372
|
+
|
|
373
|
+
findings.append({
|
|
374
|
+
"scanner": "pip-audit",
|
|
375
|
+
"package": pkg_name,
|
|
376
|
+
"severity": _severity_from_vuln_id(vuln_id),
|
|
377
|
+
"title": f"{vuln_id}: {pkg_name}" if vuln_id else f"Vulnerability in {pkg_name}",
|
|
378
|
+
"detail": description or "No description provided by pip-audit.",
|
|
379
|
+
"cve": cve,
|
|
380
|
+
"installed_version": installed,
|
|
381
|
+
"fixed_version": fix,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
return findings
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _run_pip_outdated_fallback() -> list[dict[str, Any]]:
|
|
388
|
+
"""Fallback: list outdated pip packages as low-severity informational."""
|
|
389
|
+
findings: list[dict[str, Any]] = []
|
|
390
|
+
try:
|
|
391
|
+
result = subprocess.run(
|
|
392
|
+
[sys.executable, "-m", "pip", "list", "--outdated", "--format=json"],
|
|
393
|
+
capture_output=True, text=True,
|
|
394
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
395
|
+
)
|
|
396
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
397
|
+
log_error("pip list --outdated failed or timed out.")
|
|
398
|
+
return findings
|
|
399
|
+
|
|
400
|
+
if not result.stdout.strip():
|
|
401
|
+
return findings
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
data = json.loads(result.stdout)
|
|
405
|
+
except json.JSONDecodeError:
|
|
406
|
+
return findings
|
|
407
|
+
|
|
408
|
+
for entry in data:
|
|
409
|
+
findings.append({
|
|
410
|
+
"scanner": "pip-audit",
|
|
411
|
+
"package": entry.get("name", "unknown"),
|
|
412
|
+
"severity": "info",
|
|
413
|
+
"title": f"Outdated package: {entry.get('name', 'unknown')}",
|
|
414
|
+
"detail": (
|
|
415
|
+
f"Installed {entry.get('version', '?')}, "
|
|
416
|
+
f"latest {entry.get('latest_version', '?')}. "
|
|
417
|
+
f"Outdated packages may contain known vulnerabilities."
|
|
418
|
+
),
|
|
419
|
+
"cve": None,
|
|
420
|
+
"installed_version": entry.get("version"),
|
|
421
|
+
"fixed_version": entry.get("latest_version"),
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
return findings
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# pip check
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def run_pip_check(directory: Path) -> list[dict[str, Any]]:
|
|
432
|
+
"""Run pip check to detect broken dependencies and version conflicts.
|
|
433
|
+
|
|
434
|
+
Parses the text output of ``pip check`` into structured findings. Broken
|
|
435
|
+
dependencies are reported as moderate severity because they may indicate
|
|
436
|
+
supply-chain manipulation or installation tampering.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
directory: Project root (used as working directory).
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
List of finding dicts with keys matching the unified schema.
|
|
443
|
+
"""
|
|
444
|
+
findings: list[dict[str, Any]] = []
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
result = subprocess.run(
|
|
448
|
+
[sys.executable, "-m", "pip", "check"],
|
|
449
|
+
capture_output=True, text=True,
|
|
450
|
+
cwd=str(directory),
|
|
451
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
452
|
+
)
|
|
453
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
454
|
+
log_error(f"pip check failed: {exc}")
|
|
455
|
+
return findings
|
|
456
|
+
|
|
457
|
+
if result.returncode == 0 and not result.stdout.strip():
|
|
458
|
+
return findings
|
|
459
|
+
|
|
460
|
+
# pip check output lines look like:
|
|
461
|
+
# packageA 1.0 requires packageB, which is not installed.
|
|
462
|
+
# packageA 1.0 has requirement packageB>=2.0, but you have packageB 1.5.
|
|
463
|
+
for line in result.stdout.strip().splitlines():
|
|
464
|
+
line = line.strip()
|
|
465
|
+
if not line:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
# Extract package name (first token)
|
|
469
|
+
parts = line.split()
|
|
470
|
+
pkg_name = parts[0] if parts else "unknown"
|
|
471
|
+
installed_version = parts[1] if len(parts) > 1 else None
|
|
472
|
+
|
|
473
|
+
findings.append({
|
|
474
|
+
"scanner": "pip-check",
|
|
475
|
+
"package": pkg_name,
|
|
476
|
+
"severity": "moderate",
|
|
477
|
+
"title": f"Dependency conflict: {pkg_name}",
|
|
478
|
+
"detail": line,
|
|
479
|
+
"cve": None,
|
|
480
|
+
"installed_version": installed_version,
|
|
481
|
+
"fixed_version": None,
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
return findings
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
# Unification and deduplication
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def unify_results(findings: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
492
|
+
"""Normalize, deduplicate, and sort findings by severity.
|
|
493
|
+
|
|
494
|
+
Deduplication key is (package, cve) for findings with a CVE, or
|
|
495
|
+
(package, scanner, title) for findings without one. Results are sorted
|
|
496
|
+
with critical findings first.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
findings: Raw list of finding dicts from all scanners.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
Deduplicated and sorted list of unified finding dicts.
|
|
503
|
+
"""
|
|
504
|
+
seen: set[tuple[str, ...]] = set()
|
|
505
|
+
unified: list[dict[str, Any]] = []
|
|
506
|
+
|
|
507
|
+
for f in findings:
|
|
508
|
+
# Normalize severity
|
|
509
|
+
f["severity"] = _normalize_severity(f.get("severity", "moderate"))
|
|
510
|
+
|
|
511
|
+
# Build dedup key
|
|
512
|
+
if f.get("cve"):
|
|
513
|
+
key = (f["package"], f["cve"])
|
|
514
|
+
else:
|
|
515
|
+
key = (f["package"], f["scanner"], f["title"])
|
|
516
|
+
|
|
517
|
+
if key in seen:
|
|
518
|
+
continue
|
|
519
|
+
seen.add(key)
|
|
520
|
+
unified.append(f)
|
|
521
|
+
|
|
522
|
+
# Sort by severity rank, then package name
|
|
523
|
+
unified.sort(key=lambda f: (SEVERITY_RANK.get(f["severity"], 99), f["package"]))
|
|
524
|
+
return unified
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
# Reporting
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
def generate_report(
|
|
532
|
+
directory: Path,
|
|
533
|
+
findings: list[dict[str, Any]],
|
|
534
|
+
output_path: Optional[Path],
|
|
535
|
+
) -> None:
|
|
536
|
+
"""Generate a markdown report to stdout and optionally write JSON to file.
|
|
537
|
+
|
|
538
|
+
The report includes a summary table of findings by severity and detailed
|
|
539
|
+
listings grouped by severity level.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
directory: Scanned project directory (shown in report header).
|
|
543
|
+
findings: Unified and sorted list of finding dicts.
|
|
544
|
+
output_path: Optional path for JSON file output.
|
|
545
|
+
"""
|
|
546
|
+
# Summary counts
|
|
547
|
+
counts: dict[str, int] = {"critical": 0, "high": 0, "moderate": 0, "low": 0, "info": 0}
|
|
548
|
+
for f in findings:
|
|
549
|
+
sev = f.get("severity", "moderate")
|
|
550
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
551
|
+
|
|
552
|
+
total = len(findings)
|
|
553
|
+
|
|
554
|
+
print()
|
|
555
|
+
print("=" * 60)
|
|
556
|
+
print(" Dependency Vulnerability Audit Report")
|
|
557
|
+
print("=" * 60)
|
|
558
|
+
print(f" Project: {directory}")
|
|
559
|
+
print(f" Total findings: {total}")
|
|
560
|
+
print()
|
|
561
|
+
|
|
562
|
+
if total == 0:
|
|
563
|
+
print(" No vulnerabilities found.")
|
|
564
|
+
print()
|
|
565
|
+
print("=" * 60)
|
|
566
|
+
else:
|
|
567
|
+
# Summary table
|
|
568
|
+
print(" Severity Breakdown:")
|
|
569
|
+
print(" -------------------")
|
|
570
|
+
for sev in ("critical", "high", "moderate", "low", "info"):
|
|
571
|
+
count = counts.get(sev, 0)
|
|
572
|
+
if count > 0:
|
|
573
|
+
label = sev.upper().ljust(10)
|
|
574
|
+
print(f" {label} {count}")
|
|
575
|
+
print()
|
|
576
|
+
|
|
577
|
+
# Detailed findings grouped by severity
|
|
578
|
+
for sev in ("critical", "high", "moderate", "low", "info"):
|
|
579
|
+
sev_findings = [f for f in findings if f["severity"] == sev]
|
|
580
|
+
if not sev_findings:
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
print(f" [{sev.upper()}]")
|
|
584
|
+
print(f" {'-' * 40}")
|
|
585
|
+
for f in sev_findings:
|
|
586
|
+
print(f" Package: {f['package']}")
|
|
587
|
+
print(f" Scanner: {f['scanner']}")
|
|
588
|
+
print(f" Title: {f['title']}")
|
|
589
|
+
if f.get("cve"):
|
|
590
|
+
print(f" CVE: {f['cve']}")
|
|
591
|
+
if f.get("installed_version"):
|
|
592
|
+
print(f" Installed: {f['installed_version']}")
|
|
593
|
+
if f.get("fixed_version"):
|
|
594
|
+
print(f" Fix: {f['fixed_version']}")
|
|
595
|
+
if f.get("detail") and f["detail"] != f["title"]:
|
|
596
|
+
detail_lines = f["detail"].splitlines()
|
|
597
|
+
print(f" Detail: {detail_lines[0]}")
|
|
598
|
+
for dl in detail_lines[1:]:
|
|
599
|
+
print(f" {dl}")
|
|
600
|
+
print()
|
|
601
|
+
|
|
602
|
+
print("=" * 60)
|
|
603
|
+
|
|
604
|
+
# JSON output
|
|
605
|
+
if output_path is not None:
|
|
606
|
+
report_data = {
|
|
607
|
+
"project": str(directory),
|
|
608
|
+
"total": total,
|
|
609
|
+
"counts": counts,
|
|
610
|
+
"findings": findings,
|
|
611
|
+
}
|
|
612
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
613
|
+
output_path.write_text(json.dumps(report_data, indent=2), encoding="utf-8")
|
|
614
|
+
log(f"JSON report written to {output_path}", verbose=True)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
# Helpers
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
def _normalize_severity(severity: str) -> str:
|
|
622
|
+
"""Normalize a severity string to one of the canonical levels."""
|
|
623
|
+
s = severity.strip().lower()
|
|
624
|
+
mapping: dict[str, str] = {
|
|
625
|
+
"critical": "critical",
|
|
626
|
+
"high": "high",
|
|
627
|
+
"moderate": "moderate",
|
|
628
|
+
"medium": "moderate",
|
|
629
|
+
"low": "low",
|
|
630
|
+
"info": "info",
|
|
631
|
+
"informational": "info",
|
|
632
|
+
"none": "info",
|
|
633
|
+
}
|
|
634
|
+
return mapping.get(s, "moderate")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _extract_cve(text: str) -> Optional[str]:
|
|
638
|
+
"""Extract a CVE identifier from a string, if present."""
|
|
639
|
+
import re
|
|
640
|
+
match = re.search(r"CVE-\d{4}-\d{4,}", text)
|
|
641
|
+
return match.group(0) if match else None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _severity_from_vuln_id(vuln_id: str) -> str:
|
|
645
|
+
"""Estimate severity from a vulnerability ID prefix.
|
|
646
|
+
|
|
647
|
+
pip-audit does not always include severity, so we assign a default of
|
|
648
|
+
moderate. GHSA and PYSEC entries are treated as moderate unless the
|
|
649
|
+
caller overrides later.
|
|
650
|
+
"""
|
|
651
|
+
if not vuln_id:
|
|
652
|
+
return "moderate"
|
|
653
|
+
# CVEs and GHSAs do not encode severity in the ID. Default to moderate
|
|
654
|
+
# and let the user investigate specific IDs.
|
|
655
|
+
return "moderate"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _filter_by_severity(
|
|
659
|
+
findings: list[dict[str, Any]],
|
|
660
|
+
min_severity: str,
|
|
661
|
+
) -> list[dict[str, Any]]:
|
|
662
|
+
"""Filter findings to only include those at or above the minimum severity."""
|
|
663
|
+
threshold = SEVERITY_RANK.get(min_severity, 3)
|
|
664
|
+
return [f for f in findings if SEVERITY_RANK.get(f["severity"], 99) <= threshold]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# ---------------------------------------------------------------------------
|
|
668
|
+
# CLI
|
|
669
|
+
# ---------------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
def main() -> None:
|
|
672
|
+
"""Entry point for the dependency auditor CLI."""
|
|
673
|
+
parser = argparse.ArgumentParser(
|
|
674
|
+
description="Unified dependency vulnerability scanner.",
|
|
675
|
+
epilog="Example: python3 dependency_auditor.py ./my-project --min-severity moderate",
|
|
676
|
+
)
|
|
677
|
+
parser.add_argument(
|
|
678
|
+
"directory",
|
|
679
|
+
type=Path,
|
|
680
|
+
help="Path to the project directory to scan.",
|
|
681
|
+
)
|
|
682
|
+
parser.add_argument(
|
|
683
|
+
"--scanners",
|
|
684
|
+
type=str,
|
|
685
|
+
default=None,
|
|
686
|
+
help="Comma-separated list of scanners to run (e.g. npm,pip). Default: auto-detect.",
|
|
687
|
+
)
|
|
688
|
+
parser.add_argument(
|
|
689
|
+
"--min-severity",
|
|
690
|
+
type=str,
|
|
691
|
+
default="low",
|
|
692
|
+
choices=["critical", "high", "moderate", "low", "info"],
|
|
693
|
+
help="Minimum severity to include in the report (default: low).",
|
|
694
|
+
)
|
|
695
|
+
parser.add_argument(
|
|
696
|
+
"--output",
|
|
697
|
+
type=Path,
|
|
698
|
+
default=None,
|
|
699
|
+
help="Path to write JSON report file.",
|
|
700
|
+
)
|
|
701
|
+
parser.add_argument(
|
|
702
|
+
"--verbose",
|
|
703
|
+
action="store_true",
|
|
704
|
+
default=False,
|
|
705
|
+
help="Enable verbose progress output on stderr.",
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
args = parser.parse_args()
|
|
709
|
+
directory: Path = args.directory.resolve()
|
|
710
|
+
verbose: bool = args.verbose
|
|
711
|
+
|
|
712
|
+
if not directory.is_dir():
|
|
713
|
+
log_error(f"Directory does not exist: {directory}")
|
|
714
|
+
sys.exit(1)
|
|
715
|
+
|
|
716
|
+
# Determine which scanners to run
|
|
717
|
+
if args.scanners:
|
|
718
|
+
requested = [s.strip().lower() for s in args.scanners.split(",")]
|
|
719
|
+
unknown = [s for s in requested if s not in SUPPORTED_SCANNERS]
|
|
720
|
+
if unknown:
|
|
721
|
+
log_warn(f"Unknown scanners ignored: {', '.join(unknown)}")
|
|
722
|
+
ecosystems = [s for s in requested if s in SUPPORTED_SCANNERS]
|
|
723
|
+
else:
|
|
724
|
+
log(f"Auto-detecting project types in {directory}...", verbose)
|
|
725
|
+
ecosystems = detect_project_type(directory)
|
|
726
|
+
|
|
727
|
+
if not ecosystems:
|
|
728
|
+
log_warn(
|
|
729
|
+
f"No supported project types detected in {directory}. "
|
|
730
|
+
f"Supported: {', '.join(sorted(SUPPORTED_SCANNERS))}"
|
|
731
|
+
)
|
|
732
|
+
sys.exit(0)
|
|
733
|
+
|
|
734
|
+
log(f"Scanners to run: {', '.join(ecosystems)}", verbose)
|
|
735
|
+
|
|
736
|
+
# Collect findings from all scanners
|
|
737
|
+
all_findings: list[dict[str, Any]] = []
|
|
738
|
+
|
|
739
|
+
if "npm" in ecosystems:
|
|
740
|
+
log("Running npm audit...", verbose)
|
|
741
|
+
all_findings.extend(run_npm_audit(directory))
|
|
742
|
+
log(f"npm audit: {len(all_findings)} raw findings so far.", verbose)
|
|
743
|
+
|
|
744
|
+
if "pip" in ecosystems:
|
|
745
|
+
pip_start = len(all_findings)
|
|
746
|
+
|
|
747
|
+
log("Running pip-audit...", verbose)
|
|
748
|
+
all_findings.extend(run_pip_audit(directory))
|
|
749
|
+
|
|
750
|
+
log("Running pip check...", verbose)
|
|
751
|
+
all_findings.extend(run_pip_check(directory))
|
|
752
|
+
|
|
753
|
+
pip_count = len(all_findings) - pip_start
|
|
754
|
+
log(f"pip scanners: {pip_count} raw findings.", verbose)
|
|
755
|
+
|
|
756
|
+
# Unify and filter
|
|
757
|
+
unified = unify_results(all_findings)
|
|
758
|
+
filtered = _filter_by_severity(unified, args.min_severity)
|
|
759
|
+
|
|
760
|
+
log(
|
|
761
|
+
f"Unified: {len(unified)} total, {len(filtered)} at or above "
|
|
762
|
+
f"{args.min_severity} severity.",
|
|
763
|
+
verbose,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
# Report
|
|
767
|
+
generate_report(directory, filtered, args.output)
|
|
768
|
+
|
|
769
|
+
# Exit code: 1 if any critical or high findings remain after filtering
|
|
770
|
+
has_critical_or_high = any(
|
|
771
|
+
f["severity"] in ("critical", "high") for f in filtered
|
|
772
|
+
)
|
|
773
|
+
sys.exit(1 if has_critical_or_high else 0)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
if __name__ == "__main__":
|
|
777
|
+
main()
|