@intentsolutionsio/penetration-tester 2.0.0 → 3.0.4
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 +8 -3
- package/README.md +8 -0
- package/commands/pentest.md +5 -0
- package/package.json +8 -3
- package/skills/analyzing-tls-config/SKILL.md +221 -0
- package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
- package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
- package/skills/analyzing-tls-config/references/THEORY.md +128 -0
- package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
- package/skills/auditing-cors-policy/SKILL.md +186 -0
- package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
- package/skills/auditing-cors-policy/references/THEORY.md +142 -0
- package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
- package/skills/auditing-npm-dependencies/SKILL.md +254 -0
- package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
- package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
- package/skills/auditing-python-dependencies/SKILL.md +251 -0
- package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
- package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
- package/skills/checking-http-security-headers/SKILL.md +176 -0
- package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
- package/skills/checking-http-security-headers/references/THEORY.md +137 -0
- package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
- package/skills/checking-license-compliance/SKILL.md +225 -0
- package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
- package/skills/checking-license-compliance/references/THEORY.md +152 -0
- package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
- package/skills/composing-vulnerability-report/SKILL.md +212 -0
- package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
- package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
- package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
- package/skills/confirming-pentest-authorization/SKILL.md +247 -0
- package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
- package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
- package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
- package/skills/defining-pentest-scope/SKILL.md +227 -0
- package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
- package/skills/defining-pentest-scope/references/THEORY.md +170 -0
- package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
- package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
- package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
- package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
- package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
- package/skills/detecting-debug-endpoints/SKILL.md +207 -0
- package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
- package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
- package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
- package/skills/detecting-directory-listing/SKILL.md +206 -0
- package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
- package/skills/detecting-directory-listing/references/THEORY.md +203 -0
- package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
- package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
- package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
- package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
- package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
- package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
- package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
- package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
- package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
- package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
- package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
- package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
- package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
- package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
- package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
- package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
- package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
- package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
- package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
- package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
- package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
- package/skills/detecting-weak-cryptography/SKILL.md +147 -0
- package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
- package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
- package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
- package/skills/fingerprinting-server-software/SKILL.md +191 -0
- package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
- package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
- package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
- package/skills/generating-executive-summary/SKILL.md +261 -0
- package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
- package/skills/generating-executive-summary/references/THEORY.md +195 -0
- package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
- package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
- package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
- package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
- package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
- package/skills/performing-penetration-testing/SKILL.md +282 -190
- package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
- package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
- package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
- package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
- package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
- package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
- package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
- package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
- package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
- package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
- package/skills/recording-pentest-engagement/SKILL.md +253 -0
- package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
- package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
- package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
- package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
- package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
- package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
- package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
- package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
- package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
- package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
- package/skills/tracing-transitive-vulnerabilities/scripts/trace_vulns.py +484 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tracing-transitive-vulnerabilities — walk the dep graph + intersect with CVEs.
|
|
3
|
+
|
|
4
|
+
For an npm or Python project, build the full dependency graph (via npm ls or
|
|
5
|
+
pipdeptree), intersect with an audit JSON file (or run the audit itself), and
|
|
6
|
+
emit Findings reporting:
|
|
7
|
+
|
|
8
|
+
- Per-CVE paths from direct deps to the vulnerable package
|
|
9
|
+
- Per-direct-dep CVE counts (leverage analysis)
|
|
10
|
+
- Highest-leverage upgrade recommendation
|
|
11
|
+
- Unreachable findings (no fix in any reachable version)
|
|
12
|
+
- Deep-transitive findings (depth >=3)
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 trace_vulns.py PATH [--output FILE] [--format json|jsonl|markdown]
|
|
16
|
+
[--min-severity sev] [--audit-input FILE]
|
|
17
|
+
[--min-depth N] [--leverage-only]
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
# --- lib/ import -------------------------------------------------------------
|
|
32
|
+
_LIB_ROOT = Path(__file__).resolve().parents[3]
|
|
33
|
+
sys.path.insert(0, str(_LIB_ROOT))
|
|
34
|
+
|
|
35
|
+
from lib.finding import Finding, Severity # noqa: E402
|
|
36
|
+
from lib import report # noqa: E402
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
SKILL_ID = "tracing-transitive-vulnerabilities"
|
|
40
|
+
CATEGORY = "transitive-trace"
|
|
41
|
+
CWE_DEFAULT = "CWE-1395" # Dependency on Vulnerable Third-Party Component
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- Project type detection --------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect_project_type(directory: Path) -> str:
|
|
48
|
+
if (directory / "package.json").exists() and (directory / "node_modules").is_dir():
|
|
49
|
+
return "npm"
|
|
50
|
+
if (
|
|
51
|
+
(directory / "pyproject.toml").exists()
|
|
52
|
+
or (directory / "requirements.txt").exists()
|
|
53
|
+
or list(directory.glob(".venv/lib/python*/site-packages"))
|
|
54
|
+
):
|
|
55
|
+
return "python"
|
|
56
|
+
return "unknown"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- npm graph walking ------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_npm_graph(directory: Path) -> dict[str, list[str]]:
|
|
63
|
+
"""Return {package_name: [list of parent package names]} for the project.
|
|
64
|
+
|
|
65
|
+
Uses `npm ls --json --all` which emits the full installed tree.
|
|
66
|
+
"""
|
|
67
|
+
if not shutil.which("npm"):
|
|
68
|
+
return {}
|
|
69
|
+
try:
|
|
70
|
+
proc = subprocess.run( # noqa: S603
|
|
71
|
+
["npm", "ls", "--json", "--all"],
|
|
72
|
+
cwd=str(directory),
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=120,
|
|
76
|
+
check=False,
|
|
77
|
+
)
|
|
78
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
79
|
+
return {}
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(proc.stdout or "{}")
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
parents: dict[str, set[str]] = defaultdict(set)
|
|
86
|
+
direct: set[str] = set()
|
|
87
|
+
root = data
|
|
88
|
+
|
|
89
|
+
def walk(node: dict[str, Any], parent_name: str | None) -> None:
|
|
90
|
+
deps = node.get("dependencies") or {}
|
|
91
|
+
for child_name, child_node in deps.items():
|
|
92
|
+
if parent_name is None:
|
|
93
|
+
direct.add(child_name)
|
|
94
|
+
else:
|
|
95
|
+
parents[child_name].add(parent_name)
|
|
96
|
+
walk(child_node or {}, child_name)
|
|
97
|
+
|
|
98
|
+
walk(root, None)
|
|
99
|
+
# Direct deps have no parents in the graph (their "parent" is the root project).
|
|
100
|
+
for d in direct:
|
|
101
|
+
parents.setdefault(d, set())
|
|
102
|
+
return {pkg: sorted(p) for pkg, p in parents.items()}, sorted(direct)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- Python graph walking ---------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_python_graph(directory: Path) -> tuple[dict[str, list[str]], list[str]]:
|
|
109
|
+
"""Return ({pkg: [parents]}, [direct]) using pipdeptree or pip show fallback."""
|
|
110
|
+
parents: dict[str, set[str]] = defaultdict(set)
|
|
111
|
+
direct: set[str] = set()
|
|
112
|
+
|
|
113
|
+
if shutil.which("pipdeptree"):
|
|
114
|
+
try:
|
|
115
|
+
proc = subprocess.run( # noqa: S603
|
|
116
|
+
["pipdeptree", "--json-tree"],
|
|
117
|
+
cwd=str(directory),
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
timeout=120,
|
|
121
|
+
check=False,
|
|
122
|
+
)
|
|
123
|
+
data = json.loads(proc.stdout or "[]")
|
|
124
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
125
|
+
data = []
|
|
126
|
+
|
|
127
|
+
def walk(node: dict[str, Any], parent: str | None) -> None:
|
|
128
|
+
name = node.get("package_name") or node.get("key") or ""
|
|
129
|
+
if not name:
|
|
130
|
+
return
|
|
131
|
+
if parent is None:
|
|
132
|
+
direct.add(name)
|
|
133
|
+
else:
|
|
134
|
+
parents[name].add(parent)
|
|
135
|
+
for dep in node.get("dependencies", []) or []:
|
|
136
|
+
walk(dep, name)
|
|
137
|
+
|
|
138
|
+
for top in data:
|
|
139
|
+
walk(top, None)
|
|
140
|
+
else:
|
|
141
|
+
# Fallback: pip show recursion (slower; less reliable for cycles).
|
|
142
|
+
if not (shutil.which("pip") or shutil.which("pip3")):
|
|
143
|
+
return {}, []
|
|
144
|
+
pip_bin = "pip" if shutil.which("pip") else "pip3"
|
|
145
|
+
# Identify direct deps from requirements.txt or installed packages.
|
|
146
|
+
# This branch is best-effort; pipdeptree is recommended.
|
|
147
|
+
req = directory / "requirements.txt"
|
|
148
|
+
names: list[str] = []
|
|
149
|
+
if req.exists():
|
|
150
|
+
for line in req.read_text(errors="replace").splitlines():
|
|
151
|
+
line = line.split("#", 1)[0].strip()
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
name = (
|
|
155
|
+
line.split("==")[0]
|
|
156
|
+
.split(">=")[0]
|
|
157
|
+
.split("<=")[0]
|
|
158
|
+
.split("~=")[0]
|
|
159
|
+
.split("!=")[0]
|
|
160
|
+
.split("[")[0]
|
|
161
|
+
.strip()
|
|
162
|
+
)
|
|
163
|
+
if name:
|
|
164
|
+
names.append(name)
|
|
165
|
+
else:
|
|
166
|
+
try:
|
|
167
|
+
proc = subprocess.run( # noqa: S603
|
|
168
|
+
[pip_bin, "list", "--format=json"],
|
|
169
|
+
capture_output=True,
|
|
170
|
+
text=True,
|
|
171
|
+
timeout=60,
|
|
172
|
+
check=False,
|
|
173
|
+
)
|
|
174
|
+
names = [p.get("name", "") for p in json.loads(proc.stdout or "[]")]
|
|
175
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
176
|
+
names = []
|
|
177
|
+
for n in names:
|
|
178
|
+
direct.add(n)
|
|
179
|
+
# We can't reliably recurse into transitive parents without pipdeptree;
|
|
180
|
+
# report direct-only graph and trust the audit tool to flag the rest.
|
|
181
|
+
|
|
182
|
+
for d in direct:
|
|
183
|
+
parents.setdefault(d, set())
|
|
184
|
+
return {pkg: sorted(p) for pkg, p in parents.items()}, sorted(direct)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- Path tracing -----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def trace_paths(package: str, parents_map: dict[str, list[str]], direct: set[str]) -> list[list[str]]:
|
|
191
|
+
"""Return all paths from a direct dep to the given package.
|
|
192
|
+
|
|
193
|
+
Each path is a list of package names from direct (index 0) to target (last).
|
|
194
|
+
Handles cycles by breaking on revisit.
|
|
195
|
+
"""
|
|
196
|
+
if package in direct:
|
|
197
|
+
return [[package]]
|
|
198
|
+
results: list[list[str]] = []
|
|
199
|
+
visiting: set[str] = set()
|
|
200
|
+
|
|
201
|
+
def dfs(node: str, path: list[str]) -> None:
|
|
202
|
+
if node in visiting:
|
|
203
|
+
return # cycle break
|
|
204
|
+
visiting.add(node)
|
|
205
|
+
parents = parents_map.get(node, [])
|
|
206
|
+
if not parents:
|
|
207
|
+
results.append([node, *path] if path else [node])
|
|
208
|
+
else:
|
|
209
|
+
for parent in parents:
|
|
210
|
+
dfs(parent, [node, *path])
|
|
211
|
+
visiting.discard(node)
|
|
212
|
+
|
|
213
|
+
dfs(package, [])
|
|
214
|
+
# Filter: keep only paths that start with a direct dep (or whose head is direct).
|
|
215
|
+
filtered: list[list[str]] = []
|
|
216
|
+
for p in results:
|
|
217
|
+
if p and p[0] in direct:
|
|
218
|
+
filtered.append(p)
|
|
219
|
+
return filtered or [[package]]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- Audit input handling ---------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_audit_findings(audit_input: Path) -> list[dict[str, Any]]:
|
|
226
|
+
text = audit_input.read_text(encoding="utf-8")
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(text)
|
|
229
|
+
except json.JSONDecodeError:
|
|
230
|
+
return []
|
|
231
|
+
if isinstance(data, dict) and "findings" in data:
|
|
232
|
+
return data["findings"]
|
|
233
|
+
if isinstance(data, list):
|
|
234
|
+
return data
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def extract_package_name_from_finding(finding: dict[str, Any]) -> str | None:
|
|
239
|
+
"""Pull the affected package out of a finding's evidence."""
|
|
240
|
+
evidence = finding.get("evidence", {})
|
|
241
|
+
if isinstance(evidence, dict):
|
|
242
|
+
for key in ("package", "name"):
|
|
243
|
+
if key in evidence:
|
|
244
|
+
return str(evidence[key])
|
|
245
|
+
target = finding.get("target", "")
|
|
246
|
+
if "::" in target:
|
|
247
|
+
return target.split("::")[-1].split("@", 1)[0]
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --- Trace emission ---------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def build_trace_findings(
|
|
255
|
+
audit_findings: list[dict[str, Any]],
|
|
256
|
+
parents_map: dict[str, list[str]],
|
|
257
|
+
direct: set[str],
|
|
258
|
+
min_depth: int,
|
|
259
|
+
) -> list[Finding]:
|
|
260
|
+
out: list[Finding] = []
|
|
261
|
+
leverage: dict[str, int] = defaultdict(int)
|
|
262
|
+
|
|
263
|
+
for af in audit_findings:
|
|
264
|
+
pkg = extract_package_name_from_finding(af)
|
|
265
|
+
if not pkg:
|
|
266
|
+
continue
|
|
267
|
+
# Skip findings that are operational/info rather than CVE-bearing.
|
|
268
|
+
severity_str = af.get("severity", "info")
|
|
269
|
+
if severity_str == "info":
|
|
270
|
+
continue
|
|
271
|
+
paths = trace_paths(pkg, parents_map, direct)
|
|
272
|
+
depth = min((len(p) - 1) for p in paths) if paths else 0
|
|
273
|
+
if depth < min_depth:
|
|
274
|
+
continue
|
|
275
|
+
path_strs = [" → ".join(p) for p in paths]
|
|
276
|
+
direct_ancestors = sorted({p[0] for p in paths if p})
|
|
277
|
+
|
|
278
|
+
for ancestor in direct_ancestors:
|
|
279
|
+
leverage[ancestor] += 1
|
|
280
|
+
|
|
281
|
+
# Severity logic: deep + critical = HIGH (blast radius unclear);
|
|
282
|
+
# broad reachability (>=5 paths) bumps severity by one tier.
|
|
283
|
+
base_sev = Severity(severity_str)
|
|
284
|
+
if depth >= 3 and base_sev.numeric >= 4:
|
|
285
|
+
new_sev = base_sev
|
|
286
|
+
elif depth >= 3:
|
|
287
|
+
new_sev = max(base_sev, Severity.HIGH, key=lambda s: s.numeric)
|
|
288
|
+
elif len(paths) >= 5:
|
|
289
|
+
new_sev = max(base_sev, Severity.HIGH, key=lambda s: s.numeric)
|
|
290
|
+
else:
|
|
291
|
+
new_sev = base_sev
|
|
292
|
+
|
|
293
|
+
evidence_items: list[tuple[str, Any]] = [
|
|
294
|
+
("package", pkg),
|
|
295
|
+
("depth", depth),
|
|
296
|
+
("path_count", len(paths)),
|
|
297
|
+
("direct_ancestors", ", ".join(direct_ancestors)),
|
|
298
|
+
("paths_sample", " | ".join(path_strs[:3])),
|
|
299
|
+
("original_severity", severity_str),
|
|
300
|
+
("original_cve", af.get("cve_id", "")),
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
title = (
|
|
304
|
+
f"Transitive vuln in {pkg} (depth {depth}, "
|
|
305
|
+
f"{len(paths)} path{'s' if len(paths) != 1 else ''}, "
|
|
306
|
+
f"orig severity {severity_str})"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if len(direct_ancestors) == 1:
|
|
310
|
+
remediation = (
|
|
311
|
+
f"All paths flow through {direct_ancestors[0]}.\n"
|
|
312
|
+
f"1. Check `npm view {direct_ancestors[0]} versions` (or pip equiv) "
|
|
313
|
+
f"for a release that floors {pkg} above the vulnerable range.\n"
|
|
314
|
+
f"2. Bump {direct_ancestors[0]} to that version.\n"
|
|
315
|
+
f"3. Re-run trace to confirm clearance."
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
remediation = (
|
|
319
|
+
f"Reachable via {len(direct_ancestors)} direct deps: "
|
|
320
|
+
f"{', '.join(direct_ancestors[:5])}.\n"
|
|
321
|
+
f"1. Consider a root-level `overrides`/equivalent block forcing {pkg} "
|
|
322
|
+
f"to a safe version.\n"
|
|
323
|
+
f"2. Re-run trace after applying the override to verify clearance.\n"
|
|
324
|
+
f"3. If override produces resolution conflicts, surgical per-parent "
|
|
325
|
+
f"override is the next step."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
out.append(
|
|
329
|
+
Finding(
|
|
330
|
+
skill_id=SKILL_ID,
|
|
331
|
+
title=title,
|
|
332
|
+
severity=new_sev,
|
|
333
|
+
target=pkg,
|
|
334
|
+
detail=(
|
|
335
|
+
f"Package: {pkg}\n"
|
|
336
|
+
f"Depth from nearest direct dep: {depth}\n"
|
|
337
|
+
f"Reachable paths: {len(paths)}\n"
|
|
338
|
+
f"Direct ancestors: {', '.join(direct_ancestors)}\n"
|
|
339
|
+
f"Original severity: {severity_str}\n"
|
|
340
|
+
f"Path samples:\n " + "\n ".join(path_strs[:5])
|
|
341
|
+
),
|
|
342
|
+
remediation=remediation,
|
|
343
|
+
cve_id=af.get("cve_id"),
|
|
344
|
+
cwe_id=CWE_DEFAULT,
|
|
345
|
+
references=tuple(af.get("references") or []),
|
|
346
|
+
evidence=tuple(evidence_items),
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Add leverage report findings — INFO severity, one per direct dep with >0 reachable CVEs.
|
|
351
|
+
if leverage:
|
|
352
|
+
top_leverage = sorted(leverage.items(), key=lambda x: -x[1])[:5]
|
|
353
|
+
for ancestor, count in top_leverage:
|
|
354
|
+
if count < 1:
|
|
355
|
+
continue
|
|
356
|
+
severity = Severity.INFO
|
|
357
|
+
out.append(
|
|
358
|
+
Finding(
|
|
359
|
+
skill_id=SKILL_ID,
|
|
360
|
+
title=f"Direct dep {ancestor} is ancestor for {count} transitive CVE(s)",
|
|
361
|
+
severity=severity,
|
|
362
|
+
target=ancestor,
|
|
363
|
+
detail=(
|
|
364
|
+
f"Direct dep `{ancestor}` is the closest direct ancestor for "
|
|
365
|
+
f"{count} transitive CVE finding(s). Bumping `{ancestor}` to "
|
|
366
|
+
f"a version that floors the affected transitive deps above "
|
|
367
|
+
f"their fix versions may clear multiple findings at once."
|
|
368
|
+
),
|
|
369
|
+
remediation=(
|
|
370
|
+
f"1. Check `{ancestor}`'s changelog for transitive-dep updates.\n"
|
|
371
|
+
f"2. Bump `{ancestor}` to the latest semver-compatible version.\n"
|
|
372
|
+
f"3. Re-run trace to count remaining transitive CVEs."
|
|
373
|
+
),
|
|
374
|
+
evidence=(
|
|
375
|
+
("ancestor", ancestor),
|
|
376
|
+
("cve_count", count),
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# --- CLI ---------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
387
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
388
|
+
p.add_argument("path", help="Project root")
|
|
389
|
+
p.add_argument("--output", default=None)
|
|
390
|
+
p.add_argument("--format", default="markdown", choices=["json", "jsonl", "markdown"])
|
|
391
|
+
p.add_argument(
|
|
392
|
+
"--min-severity",
|
|
393
|
+
default="info",
|
|
394
|
+
choices=["info", "low", "medium", "high", "critical"],
|
|
395
|
+
)
|
|
396
|
+
p.add_argument(
|
|
397
|
+
"--audit-input",
|
|
398
|
+
default=None,
|
|
399
|
+
help="Pre-computed audit JSON from auditing-npm-dependencies or auditing-python-dependencies",
|
|
400
|
+
)
|
|
401
|
+
p.add_argument("--min-depth", type=int, default=0, help="Only emit findings at >= this depth")
|
|
402
|
+
p.add_argument(
|
|
403
|
+
"--leverage-only",
|
|
404
|
+
action="store_true",
|
|
405
|
+
help="Emit only the leverage-analysis findings (skip per-CVE traces)",
|
|
406
|
+
)
|
|
407
|
+
return p
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _filter_min_severity(findings: list[Finding], min_sev: str) -> list[Finding]:
|
|
411
|
+
floor = Severity(min_sev).numeric
|
|
412
|
+
return [f for f in findings if f.severity.numeric >= floor]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main(argv: list[str] | None = None) -> int:
|
|
416
|
+
args = _build_arg_parser().parse_args(argv)
|
|
417
|
+
directory = Path(args.path).resolve()
|
|
418
|
+
|
|
419
|
+
project_type = detect_project_type(directory)
|
|
420
|
+
if project_type == "npm":
|
|
421
|
+
graph_result = build_npm_graph(directory)
|
|
422
|
+
parents_map, direct_list = graph_result if isinstance(graph_result, tuple) else ({}, [])
|
|
423
|
+
elif project_type == "python":
|
|
424
|
+
parents_map, direct_list = build_python_graph(directory)
|
|
425
|
+
else:
|
|
426
|
+
f = Finding(
|
|
427
|
+
skill_id=SKILL_ID,
|
|
428
|
+
title="project type not detected",
|
|
429
|
+
severity=Severity.INFO,
|
|
430
|
+
target=str(directory),
|
|
431
|
+
detail="Neither package.json+node_modules nor a Python project was found.",
|
|
432
|
+
remediation="Run from a project root containing installed dependencies.",
|
|
433
|
+
)
|
|
434
|
+
report.emit([f], args.output, args.format, scan_target=str(directory))
|
|
435
|
+
return 2
|
|
436
|
+
|
|
437
|
+
if not args.audit_input:
|
|
438
|
+
f = Finding(
|
|
439
|
+
skill_id=SKILL_ID,
|
|
440
|
+
title="no audit input provided",
|
|
441
|
+
severity=Severity.INFO,
|
|
442
|
+
target=str(directory),
|
|
443
|
+
detail=(
|
|
444
|
+
"This skill expects --audit-input pointing at a JSON file produced by "
|
|
445
|
+
"auditing-npm-dependencies or auditing-python-dependencies.\n"
|
|
446
|
+
"Run that audit first, then re-run this skill."
|
|
447
|
+
),
|
|
448
|
+
remediation=(
|
|
449
|
+
"Run the appropriate audit skill with --format json --output /tmp/audit.json, "
|
|
450
|
+
"then re-run this skill with --audit-input /tmp/audit.json."
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
report.emit([f], args.output, args.format, scan_target=str(directory))
|
|
454
|
+
return 2
|
|
455
|
+
|
|
456
|
+
audit_findings = load_audit_findings(Path(args.audit_input))
|
|
457
|
+
direct_set = set(direct_list)
|
|
458
|
+
|
|
459
|
+
findings = build_trace_findings(audit_findings, parents_map, direct_set, args.min_depth)
|
|
460
|
+
|
|
461
|
+
if args.leverage_only:
|
|
462
|
+
findings = [f for f in findings if "ancestor for" in f.title]
|
|
463
|
+
|
|
464
|
+
if not findings:
|
|
465
|
+
findings = [
|
|
466
|
+
Finding(
|
|
467
|
+
skill_id=SKILL_ID,
|
|
468
|
+
title="no transitive CVE findings to trace",
|
|
469
|
+
severity=Severity.INFO,
|
|
470
|
+
target=str(directory),
|
|
471
|
+
detail=(
|
|
472
|
+
"The audit input produced no CVE-bearing findings (or all findings "
|
|
473
|
+
"were below the requested --min-depth)."
|
|
474
|
+
),
|
|
475
|
+
remediation="No action required.",
|
|
476
|
+
)
|
|
477
|
+
]
|
|
478
|
+
findings = _filter_min_severity(findings, args.min_severity)
|
|
479
|
+
report.emit(findings, args.output, args.format, scan_target=str(directory))
|
|
480
|
+
return report.exit_code(findings)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
if __name__ == "__main__":
|
|
484
|
+
sys.exit(main())
|