@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.
@@ -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()