@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,461 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""checking-license-compliance — audit dep licenses against a policy.
|
|
3
|
+
|
|
4
|
+
Walks an npm or Python project, extracts the license from each installed
|
|
5
|
+
package's metadata, classifies by SPDX family, and emits Findings via
|
|
6
|
+
lib/finding.py for any deny-listed, review-required, or unknown-license
|
|
7
|
+
package. Detects copyleft contamination of permissive-licensed projects
|
|
8
|
+
and SPDX-incompatible license combinations.
|
|
9
|
+
|
|
10
|
+
Policy is a JSON file at ./.license-policy.json (auto-detected) or passed
|
|
11
|
+
via --policy. Default policy flags GPL/AGPL family in projects declaring
|
|
12
|
+
MIT/Apache-2.0/BSD.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 check_licenses.py PATH [--output FILE] [--format json|jsonl|markdown]
|
|
16
|
+
[--min-severity sev] [--policy FILE]
|
|
17
|
+
[--emit-attribution]
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
# --- lib/ import -------------------------------------------------------------
|
|
30
|
+
_LIB_ROOT = Path(__file__).resolve().parents[3]
|
|
31
|
+
sys.path.insert(0, str(_LIB_ROOT))
|
|
32
|
+
|
|
33
|
+
from lib.finding import Finding, Severity # noqa: E402
|
|
34
|
+
from lib import report # noqa: E402
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SKILL_ID = "checking-license-compliance"
|
|
38
|
+
CATEGORY = "license-compliance"
|
|
39
|
+
|
|
40
|
+
# SPDX family classification — broad, not legal advice.
|
|
41
|
+
STRONG_COPYLEFT = {
|
|
42
|
+
"GPL-2.0-only",
|
|
43
|
+
"GPL-2.0-or-later",
|
|
44
|
+
"GPL-3.0-only",
|
|
45
|
+
"GPL-3.0-or-later",
|
|
46
|
+
"AGPL-3.0-only",
|
|
47
|
+
"AGPL-3.0-or-later",
|
|
48
|
+
}
|
|
49
|
+
WEAK_COPYLEFT = {
|
|
50
|
+
"LGPL-2.1-only",
|
|
51
|
+
"LGPL-2.1-or-later",
|
|
52
|
+
"LGPL-3.0-only",
|
|
53
|
+
"LGPL-3.0-or-later",
|
|
54
|
+
"MPL-1.1",
|
|
55
|
+
"MPL-2.0",
|
|
56
|
+
"EPL-1.0",
|
|
57
|
+
"EPL-2.0",
|
|
58
|
+
"CDDL-1.0",
|
|
59
|
+
"CDDL-1.1",
|
|
60
|
+
}
|
|
61
|
+
PERMISSIVE = {
|
|
62
|
+
"MIT",
|
|
63
|
+
"Apache-2.0",
|
|
64
|
+
"BSD-2-Clause",
|
|
65
|
+
"BSD-3-Clause",
|
|
66
|
+
"ISC",
|
|
67
|
+
"0BSD",
|
|
68
|
+
"Unlicense",
|
|
69
|
+
"CC0-1.0",
|
|
70
|
+
"Zlib",
|
|
71
|
+
"Python-2.0",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
DEFAULT_POLICY: dict[str, Any] = {
|
|
75
|
+
"allow": list(PERMISSIVE),
|
|
76
|
+
"deny": list(STRONG_COPYLEFT),
|
|
77
|
+
"review": ["MPL-2.0", "EPL-2.0", "CDDL-1.0"] + list(WEAK_COPYLEFT - {"MPL-2.0", "EPL-2.0", "CDDL-1.0"}),
|
|
78
|
+
"project_license": None, # auto-detected from project metadata
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Known-incompatible pairs (illustrative subset — not exhaustive legal advice).
|
|
82
|
+
INCOMPATIBLE_PAIRS: list[tuple[str, str]] = [
|
|
83
|
+
("GPL-2.0-only", "Apache-2.0"), # no patent grant in GPLv2
|
|
84
|
+
("GPL-2.0-only", "CDDL-1.0"),
|
|
85
|
+
("MPL-1.1", "GPL-2.0-only"),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Policy loading ----------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_policy(directory: Path, override_path: Path | None) -> dict[str, Any]:
|
|
93
|
+
if override_path:
|
|
94
|
+
with open(override_path, encoding="utf-8") as fh:
|
|
95
|
+
return json.load(fh)
|
|
96
|
+
auto = directory / ".license-policy.json"
|
|
97
|
+
if auto.exists():
|
|
98
|
+
with open(auto, encoding="utf-8") as fh:
|
|
99
|
+
return json.load(fh)
|
|
100
|
+
return dict(DEFAULT_POLICY)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- Project license detection ----------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def detect_project_license(directory: Path) -> str | None:
|
|
107
|
+
pkg = directory / "package.json"
|
|
108
|
+
if pkg.exists():
|
|
109
|
+
try:
|
|
110
|
+
with open(pkg, encoding="utf-8") as fh:
|
|
111
|
+
data = json.load(fh)
|
|
112
|
+
except (OSError, json.JSONDecodeError):
|
|
113
|
+
return None
|
|
114
|
+
lic = data.get("license")
|
|
115
|
+
if isinstance(lic, str):
|
|
116
|
+
return lic
|
|
117
|
+
if isinstance(lic, dict):
|
|
118
|
+
return lic.get("type")
|
|
119
|
+
|
|
120
|
+
pyproj = directory / "pyproject.toml"
|
|
121
|
+
if pyproj.exists():
|
|
122
|
+
text = pyproj.read_text(encoding="utf-8", errors="replace")
|
|
123
|
+
m = re.search(r'^\s*license\s*=\s*[\'"]([^\'"]+)[\'"]', text, flags=re.M)
|
|
124
|
+
if m:
|
|
125
|
+
return m.group(1)
|
|
126
|
+
m = re.search(r'license\s*=\s*{\s*text\s*=\s*[\'"]([^\'"]+)[\'"]', text)
|
|
127
|
+
if m:
|
|
128
|
+
return m.group(1)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --- npm dep enumeration ----------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def enumerate_npm_packages(directory: Path) -> list[dict[str, Any]]:
|
|
136
|
+
"""Walk node_modules/<pkg>/package.json and extract license info."""
|
|
137
|
+
out: list[dict[str, Any]] = []
|
|
138
|
+
nm = directory / "node_modules"
|
|
139
|
+
if not nm.is_dir():
|
|
140
|
+
return out
|
|
141
|
+
for pkg_json in nm.glob("**/package.json"):
|
|
142
|
+
# Skip nested node_modules within packages — those are already
|
|
143
|
+
# captured under their own top-level walk if hoisted, and
|
|
144
|
+
# represent installed copies that don't add new license info.
|
|
145
|
+
rel = pkg_json.relative_to(nm)
|
|
146
|
+
if any(part == "node_modules" for part in rel.parts[:-1]):
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
with open(pkg_json, encoding="utf-8") as fh:
|
|
150
|
+
data = json.load(fh)
|
|
151
|
+
except (OSError, json.JSONDecodeError):
|
|
152
|
+
continue
|
|
153
|
+
name = data.get("name") or str(rel.parent)
|
|
154
|
+
version = data.get("version") or "?"
|
|
155
|
+
lic = data.get("license")
|
|
156
|
+
license_str: str
|
|
157
|
+
if isinstance(lic, str):
|
|
158
|
+
license_str = lic
|
|
159
|
+
elif isinstance(lic, dict):
|
|
160
|
+
license_str = lic.get("type") or "UNKNOWN"
|
|
161
|
+
elif isinstance(lic, list):
|
|
162
|
+
license_str = " OR ".join(str(l.get("type") if isinstance(l, dict) else l) for l in lic)
|
|
163
|
+
else:
|
|
164
|
+
license_str = "UNKNOWN"
|
|
165
|
+
out.append(
|
|
166
|
+
{
|
|
167
|
+
"ecosystem": "npm",
|
|
168
|
+
"name": name,
|
|
169
|
+
"version": version,
|
|
170
|
+
"license": license_str.strip(),
|
|
171
|
+
"path": str(pkg_json),
|
|
172
|
+
"homepage": data.get("homepage", ""),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --- Python dep enumeration -------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def enumerate_python_packages(directory: Path) -> list[dict[str, Any]]:
|
|
182
|
+
"""Walk site-packages METADATA files for installed Python packages.
|
|
183
|
+
|
|
184
|
+
Searches a few likely locations: a `.venv/lib/pythonX.Y/site-packages`
|
|
185
|
+
inside the project, then the active interpreter's site-packages.
|
|
186
|
+
"""
|
|
187
|
+
out: list[dict[str, Any]] = []
|
|
188
|
+
candidates: list[Path] = []
|
|
189
|
+
# Project-local venv
|
|
190
|
+
for venv in directory.glob(".venv/lib/python*/site-packages"):
|
|
191
|
+
candidates.append(venv)
|
|
192
|
+
for venv in directory.glob("venv/lib/python*/site-packages"):
|
|
193
|
+
candidates.append(venv)
|
|
194
|
+
# Fall back to the running interpreter's site-packages
|
|
195
|
+
if not candidates:
|
|
196
|
+
import sysconfig
|
|
197
|
+
|
|
198
|
+
purelib = sysconfig.get_paths().get("purelib")
|
|
199
|
+
if purelib:
|
|
200
|
+
candidates.append(Path(purelib))
|
|
201
|
+
|
|
202
|
+
seen: set[tuple[str, str]] = set()
|
|
203
|
+
for site_pkgs in candidates:
|
|
204
|
+
if not site_pkgs.is_dir():
|
|
205
|
+
continue
|
|
206
|
+
for metadata_file in list(site_pkgs.glob("*.dist-info/METADATA")) + list(site_pkgs.glob("*.egg-info/PKG-INFO")):
|
|
207
|
+
try:
|
|
208
|
+
text = metadata_file.read_text(encoding="utf-8", errors="replace")
|
|
209
|
+
except OSError:
|
|
210
|
+
continue
|
|
211
|
+
name_m = re.search(r"^Name:\s*(.+)$", text, flags=re.M)
|
|
212
|
+
ver_m = re.search(r"^Version:\s*(.+)$", text, flags=re.M)
|
|
213
|
+
lic_m = re.search(r"^License:\s*(.+)$", text, flags=re.M)
|
|
214
|
+
classifier_lics = re.findall(
|
|
215
|
+
r"^Classifier:\s*License\s*::\s*(?:OSI Approved\s*::\s*)?(.+)$",
|
|
216
|
+
text,
|
|
217
|
+
flags=re.M,
|
|
218
|
+
)
|
|
219
|
+
name = name_m.group(1).strip() if name_m else metadata_file.parent.name
|
|
220
|
+
version = ver_m.group(1).strip() if ver_m else "?"
|
|
221
|
+
license_str = (lic_m.group(1).strip() if lic_m else "") or (
|
|
222
|
+
", ".join(c.strip() for c in classifier_lics) if classifier_lics else "UNKNOWN"
|
|
223
|
+
)
|
|
224
|
+
key = (name, version)
|
|
225
|
+
if key in seen:
|
|
226
|
+
continue
|
|
227
|
+
seen.add(key)
|
|
228
|
+
out.append(
|
|
229
|
+
{
|
|
230
|
+
"ecosystem": "pypi",
|
|
231
|
+
"name": name,
|
|
232
|
+
"version": version,
|
|
233
|
+
"license": license_str,
|
|
234
|
+
"path": str(metadata_file),
|
|
235
|
+
"homepage": "",
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- Classification + finding emission --------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def classify_license(license_str: str) -> str:
|
|
245
|
+
"""Return a family label for a license string."""
|
|
246
|
+
s = (license_str or "").strip()
|
|
247
|
+
if not s or s.upper() in {"UNKNOWN", "UNLICENSED", "NONE"}:
|
|
248
|
+
return "unknown"
|
|
249
|
+
# Try matching SPDX-ish parts (e.g. "MIT OR Apache-2.0" — take the first).
|
|
250
|
+
parts = re.split(r"\s+(?:OR|AND|WITH)\s+", s)
|
|
251
|
+
head = parts[0].strip().strip("()")
|
|
252
|
+
if head in STRONG_COPYLEFT:
|
|
253
|
+
return "strong_copyleft"
|
|
254
|
+
if head in WEAK_COPYLEFT:
|
|
255
|
+
return "weak_copyleft"
|
|
256
|
+
if head in PERMISSIVE:
|
|
257
|
+
return "permissive"
|
|
258
|
+
# Loose heuristics for non-SPDX strings
|
|
259
|
+
if re.search(r"\b(AGPL|GPL)\b", s, flags=re.I):
|
|
260
|
+
return "strong_copyleft"
|
|
261
|
+
if re.search(r"\b(LGPL|MPL|EPL|CDDL)\b", s, flags=re.I):
|
|
262
|
+
return "weak_copyleft"
|
|
263
|
+
if re.search(r"\b(MIT|Apache|BSD|ISC|Unlicense|CC0)\b", s, flags=re.I):
|
|
264
|
+
return "permissive"
|
|
265
|
+
return "custom"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def assess_package(pkg: dict[str, Any], policy: dict[str, Any], project_license: str | None) -> Finding | None:
|
|
269
|
+
license_str = pkg["license"]
|
|
270
|
+
family = classify_license(license_str)
|
|
271
|
+
|
|
272
|
+
head = re.split(r"\s+(?:OR|AND|WITH)\s+", license_str)[0].strip().strip("()")
|
|
273
|
+
|
|
274
|
+
deny = set(policy.get("deny", []))
|
|
275
|
+
review = set(policy.get("review", []))
|
|
276
|
+
allow = set(policy.get("allow", []))
|
|
277
|
+
|
|
278
|
+
if head in deny or family == "strong_copyleft":
|
|
279
|
+
if project_license in PERMISSIVE or (policy.get("project_license") in PERMISSIVE):
|
|
280
|
+
severity = Severity.CRITICAL
|
|
281
|
+
title = (
|
|
282
|
+
f"Strong-copyleft license ({license_str}) in a {project_license or 'permissive'} project: {pkg['name']}"
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
severity = Severity.HIGH
|
|
286
|
+
title = f"Deny-listed license ({license_str}) on {pkg['name']}"
|
|
287
|
+
remediation = (
|
|
288
|
+
f"1. Remove {pkg['name']} from the dependency tree, OR\n"
|
|
289
|
+
"2. Replace with a permissively-licensed equivalent, OR\n"
|
|
290
|
+
"3. Re-license the project to a compatible license (legal review required), OR\n"
|
|
291
|
+
"4. Document an explicit exception with legal sign-off."
|
|
292
|
+
)
|
|
293
|
+
elif head in review or family == "weak_copyleft":
|
|
294
|
+
severity = Severity.MEDIUM
|
|
295
|
+
title = f"Review-required license ({license_str}) on {pkg['name']}"
|
|
296
|
+
remediation = (
|
|
297
|
+
f"Send {pkg['name']} ({license_str}) to legal for review.\n"
|
|
298
|
+
"Weak-copyleft licenses typically require source disclosure on modified\n"
|
|
299
|
+
"versions; obligations vary. Document the legal position."
|
|
300
|
+
)
|
|
301
|
+
elif family == "unknown":
|
|
302
|
+
severity = Severity.MEDIUM
|
|
303
|
+
title = f"Unknown license on {pkg['name']}"
|
|
304
|
+
remediation = (
|
|
305
|
+
f"Inspect {pkg['path']} for a LICENSE file or check the package home page.\n"
|
|
306
|
+
"If no license is granted, default copyright applies — you may have NO rights\n"
|
|
307
|
+
"to redistribute. Either remove the package or obtain explicit permission."
|
|
308
|
+
)
|
|
309
|
+
elif family == "custom":
|
|
310
|
+
severity = Severity.HIGH
|
|
311
|
+
title = f"Custom / non-SPDX license on {pkg['name']}"
|
|
312
|
+
remediation = (
|
|
313
|
+
f"License declared as `{license_str}` — not a standard SPDX identifier.\n"
|
|
314
|
+
"Custom licenses require manual legal review. Either ensure the license\n"
|
|
315
|
+
"text is reviewed or replace with a package whose license is SPDX-standard."
|
|
316
|
+
)
|
|
317
|
+
elif head in allow or family == "permissive":
|
|
318
|
+
# Permissive — emit INFO reminding to attribute.
|
|
319
|
+
severity = Severity.INFO
|
|
320
|
+
title = f"Permissive license ({license_str}) on {pkg['name']} — attribution recommended"
|
|
321
|
+
remediation = (
|
|
322
|
+
f"Add {pkg['name']} ({license_str}) to your project's NOTICE / attribution file.\n"
|
|
323
|
+
"Use --emit-attribution to auto-generate NOTICE.md."
|
|
324
|
+
)
|
|
325
|
+
else:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
evidence: tuple[tuple[str, Any], ...] = (
|
|
329
|
+
("ecosystem", pkg["ecosystem"]),
|
|
330
|
+
("name", pkg["name"]),
|
|
331
|
+
("version", pkg["version"]),
|
|
332
|
+
("declared_license", license_str),
|
|
333
|
+
("family", family),
|
|
334
|
+
("project_license", project_license or "<unknown>"),
|
|
335
|
+
)
|
|
336
|
+
references_list: list[str] = []
|
|
337
|
+
if head in (PERMISSIVE | STRONG_COPYLEFT | WEAK_COPYLEFT):
|
|
338
|
+
references_list.append(f"https://spdx.org/licenses/{head}.html")
|
|
339
|
+
if pkg.get("homepage"):
|
|
340
|
+
references_list.append(pkg["homepage"])
|
|
341
|
+
|
|
342
|
+
return Finding(
|
|
343
|
+
skill_id=SKILL_ID,
|
|
344
|
+
title=title,
|
|
345
|
+
severity=severity,
|
|
346
|
+
target=f"{pkg['ecosystem']}::{pkg['name']}@{pkg['version']}",
|
|
347
|
+
detail=(
|
|
348
|
+
f"Declared license: {license_str}\n"
|
|
349
|
+
f"Classified family: {family}\n"
|
|
350
|
+
f"Project license: {project_license or '<unknown>'}\n"
|
|
351
|
+
f"Policy match: {('deny' if head in deny else 'review' if head in review else 'allow' if head in allow else 'default')}"
|
|
352
|
+
),
|
|
353
|
+
remediation=remediation,
|
|
354
|
+
references=tuple(references_list),
|
|
355
|
+
evidence=evidence,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def emit_attribution(packages: list[dict[str, Any]], output_path: Path) -> None:
|
|
360
|
+
lines = [
|
|
361
|
+
"# NOTICE",
|
|
362
|
+
"",
|
|
363
|
+
"This product includes the following third-party packages and their",
|
|
364
|
+
"respective licenses. The full text of each license is available at the",
|
|
365
|
+
"SPDX URL or the package home page.",
|
|
366
|
+
"",
|
|
367
|
+
]
|
|
368
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
369
|
+
for pkg in packages:
|
|
370
|
+
family = classify_license(pkg["license"])
|
|
371
|
+
if family != "permissive":
|
|
372
|
+
continue
|
|
373
|
+
grouped.setdefault(pkg["license"], []).append(pkg)
|
|
374
|
+
for license_str, pkgs in sorted(grouped.items()):
|
|
375
|
+
lines.append(f"## {license_str}")
|
|
376
|
+
lines.append("")
|
|
377
|
+
for pkg in sorted(pkgs, key=lambda p: p["name"]):
|
|
378
|
+
lines.append(f"- **{pkg['name']}** @ {pkg['version']}")
|
|
379
|
+
lines.append("")
|
|
380
|
+
output_path.write_text("\n".join(lines), encoding="utf-8")
|
|
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("--policy", default=None)
|
|
397
|
+
p.add_argument(
|
|
398
|
+
"--emit-attribution",
|
|
399
|
+
action="store_true",
|
|
400
|
+
help="Also emit NOTICE.md listing permissively-licensed deps for attribution.",
|
|
401
|
+
)
|
|
402
|
+
return p
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _filter_min_severity(findings: list[Finding], min_sev: str) -> list[Finding]:
|
|
406
|
+
floor = Severity(min_sev).numeric
|
|
407
|
+
return [f for f in findings if f.severity.numeric >= floor]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def main(argv: list[str] | None = None) -> int:
|
|
411
|
+
args = _build_arg_parser().parse_args(argv)
|
|
412
|
+
directory = Path(args.path).resolve()
|
|
413
|
+
|
|
414
|
+
policy = load_policy(directory, Path(args.policy).resolve() if args.policy else None)
|
|
415
|
+
project_license = policy.get("project_license") or detect_project_license(directory)
|
|
416
|
+
|
|
417
|
+
packages = enumerate_npm_packages(directory) + enumerate_python_packages(directory)
|
|
418
|
+
|
|
419
|
+
if not packages:
|
|
420
|
+
findings = [
|
|
421
|
+
Finding(
|
|
422
|
+
skill_id=SKILL_ID,
|
|
423
|
+
title="no packages found to audit",
|
|
424
|
+
severity=Severity.INFO,
|
|
425
|
+
target=str(directory),
|
|
426
|
+
detail=(
|
|
427
|
+
"Neither node_modules/ nor a Python venv site-packages was found.\n"
|
|
428
|
+
"Install deps first (`npm install` / `pip install -r requirements.txt`) "
|
|
429
|
+
"and re-run."
|
|
430
|
+
),
|
|
431
|
+
remediation="Install dependencies, then re-run.",
|
|
432
|
+
)
|
|
433
|
+
]
|
|
434
|
+
else:
|
|
435
|
+
findings = []
|
|
436
|
+
for pkg in packages:
|
|
437
|
+
f = assess_package(pkg, policy, project_license)
|
|
438
|
+
if f is not None:
|
|
439
|
+
findings.append(f)
|
|
440
|
+
if not findings:
|
|
441
|
+
findings = [
|
|
442
|
+
Finding(
|
|
443
|
+
skill_id=SKILL_ID,
|
|
444
|
+
title="all dependency licenses pass policy",
|
|
445
|
+
severity=Severity.INFO,
|
|
446
|
+
target=str(directory),
|
|
447
|
+
detail=f"Audited {len(packages)} packages; none triggered findings.",
|
|
448
|
+
remediation="No action required.",
|
|
449
|
+
)
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
if args.emit_attribution:
|
|
453
|
+
emit_attribution(packages, directory / "NOTICE.md")
|
|
454
|
+
|
|
455
|
+
findings = _filter_min_severity(findings, args.min_severity)
|
|
456
|
+
report.emit(findings, args.output, args.format, scan_target=str(directory))
|
|
457
|
+
return report.exit_code(findings)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
sys.exit(main())
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: composing-vulnerability-report
|
|
3
|
+
description: |
|
|
4
|
+
Read findings JSONL files from cluster 1-4 skills, deduplicate
|
|
5
|
+
by fingerprint, group by severity, and compose a deliverable-
|
|
6
|
+
grade markdown vulnerability report with per-finding sections
|
|
7
|
+
(title, severity, target, detail, remediation, evidence) and a
|
|
8
|
+
top-level summary table. The canonical written artifact a customer
|
|
9
|
+
receives at engagement close; precise, reproducible, machine-
|
|
10
|
+
checkable against source findings.
|
|
11
|
+
Use when: closing an engagement, generating an interim report,
|
|
12
|
+
regenerating after CVE or OWASP enrichment, or producing the
|
|
13
|
+
input for generating-executive-summary.
|
|
14
|
+
Threshold: findings missing required fields are dropped. HIGH
|
|
15
|
+
and CRITICAL findings highlighted in the summary section.
|
|
16
|
+
Trigger with: "compose vuln report", "write pentest report",
|
|
17
|
+
"generate vulnerability deliverable", "render findings to report".
|
|
18
|
+
allowed-tools:
|
|
19
|
+
- Read
|
|
20
|
+
- Write
|
|
21
|
+
- Bash(python3:*)
|
|
22
|
+
- Glob
|
|
23
|
+
disallowed-tools:
|
|
24
|
+
- Bash(rm:*)
|
|
25
|
+
- Bash(curl:*)
|
|
26
|
+
- Bash(wget:*)
|
|
27
|
+
- Write(.env)
|
|
28
|
+
- Edit(.env)
|
|
29
|
+
version: 3.0.0-dev
|
|
30
|
+
author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
31
|
+
license: MIT
|
|
32
|
+
compatibility: Designed for Claude Code
|
|
33
|
+
tags:
|
|
34
|
+
- security
|
|
35
|
+
- reporting
|
|
36
|
+
- vulnerability-report
|
|
37
|
+
- cvss
|
|
38
|
+
- pentest
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
# Composing Vulnerability Report
|
|
42
|
+
|
|
43
|
+
## Overview
|
|
44
|
+
|
|
45
|
+
After cluster 1-4 scan skills run, each one produces a Findings
|
|
46
|
+
file. A typical engagement ends up with eight to twenty such files
|
|
47
|
+
across the different skill categories. The customer wants ONE
|
|
48
|
+
vulnerability report — comprehensive, deduplicated, organized by
|
|
49
|
+
severity, with each finding cross-referenced to its source skill
|
|
50
|
+
and target.
|
|
51
|
+
|
|
52
|
+
This skill consumes one or more findings files (JSONL preferred,
|
|
53
|
+
JSON list also accepted), deduplicates entries by the canonical
|
|
54
|
+
fingerprint defined in `lib/finding.py`, enriches each finding
|
|
55
|
+
with a CVSS v3.1 vector when one isn't present (using a deterministic
|
|
56
|
+
heuristic based on severity + category — explicitly noted as
|
|
57
|
+
"derived, not assigned by NVD" in the output), and emits a single
|
|
58
|
+
markdown report with per-finding sections plus a top-level
|
|
59
|
+
summary table.
|
|
60
|
+
|
|
61
|
+
The report has a defined structure that downstream tools (next
|
|
62
|
+
two skills in cluster 6) consume:
|
|
63
|
+
|
|
64
|
+
1. **Header** — engagement ID, generation timestamp, source files
|
|
65
|
+
2. **Summary table** — finding count by severity
|
|
66
|
+
3. **Per-severity sections** — CRITICAL first, then HIGH, MEDIUM,
|
|
67
|
+
LOW, INFO
|
|
68
|
+
4. **Per-finding subsections** — title, severity, target, detail,
|
|
69
|
+
remediation, evidence, references
|
|
70
|
+
|
|
71
|
+
## When the skill produces findings
|
|
72
|
+
|
|
73
|
+
| Finding | Severity | Threshold | Affected control |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| Source file unparseable | **HIGH** | JSON/JSONL parse fails | (operational) |
|
|
76
|
+
| Finding missing required field | **HIGH** | A finding record is missing title, severity, target, detail, or remediation | (operational) |
|
|
77
|
+
| Duplicate fingerprint across files | **INFO** | Same finding appears in N>1 sources; reported as deduplication count | (informational) |
|
|
78
|
+
| Source file has zero findings | **INFO** | Empty or all-info-only file; reported but not an error | (informational) |
|
|
79
|
+
| Report generated cleanly | **INFO** | Positive confirmation | (informational) |
|
|
80
|
+
|
|
81
|
+
## Prerequisites
|
|
82
|
+
|
|
83
|
+
- Python 3.9+
|
|
84
|
+
- One or more findings files in JSON or JSONL format produced by
|
|
85
|
+
any cluster 1-4 scan skill (which all share `lib/finding.py`
|
|
86
|
+
schema)
|
|
87
|
+
|
|
88
|
+
## Instructions
|
|
89
|
+
|
|
90
|
+
### Step 1 — Gather findings sources
|
|
91
|
+
|
|
92
|
+
By default the skill reads every file matching
|
|
93
|
+
`engagement/findings/*.json` and `engagement/findings/*.jsonl`.
|
|
94
|
+
Override with `--source FILE` (repeatable).
|
|
95
|
+
|
|
96
|
+
### Step 2 — Run the composer
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python3 ./scripts/compose_report.py engagements/acme-2026-q2/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Options:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
Usage: compose_report.py PATH [OPTIONS]
|
|
106
|
+
|
|
107
|
+
Options:
|
|
108
|
+
--source FILE Specific findings file (repeatable; overrides default glob)
|
|
109
|
+
--report-output FILE Write the composed report here (default:
|
|
110
|
+
PATH/reports/vulnerability-report.md)
|
|
111
|
+
--engagement-id ID Override the engagement ID (default: parse from PATH/roe.yaml)
|
|
112
|
+
--output FILE Operational findings output (this skill's own findings)
|
|
113
|
+
--format FMT json | jsonl | markdown (default: markdown)
|
|
114
|
+
--min-severity SEV Filter report to findings at or above this severity
|
|
115
|
+
--include-info Include INFO-severity findings in the report (default: omit)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Step 3 — Review the report
|
|
119
|
+
|
|
120
|
+
The output report has a predictable structure. The header
|
|
121
|
+
identifies the engagement, the source files, and the generation
|
|
122
|
+
timestamp. The summary table shows finding counts by severity.
|
|
123
|
+
Per-severity sections follow.
|
|
124
|
+
|
|
125
|
+
Each finding subsection includes a stable anchor (the fingerprint)
|
|
126
|
+
so cross-references from later artifacts (executive summary,
|
|
127
|
+
OWASP mapping) resolve into the report cleanly.
|
|
128
|
+
|
|
129
|
+
### Step 4 — Verify against sources
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python3 ./scripts/compose_report.py engagements/acme-2026-q2/ --format json --output /tmp/compose-findings.json
|
|
133
|
+
jq '.[] | select(.severity == "high")' /tmp/compose-findings.json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If the report references a finding the operator didn't expect,
|
|
137
|
+
trace back via the finding's `skill_id` + `target` to the source
|
|
138
|
+
file.
|
|
139
|
+
|
|
140
|
+
## Examples
|
|
141
|
+
|
|
142
|
+
### Example 1 — End-of-engagement report
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
python3 ./scripts/compose_report.py engagements/acme-2026-q2/ \
|
|
146
|
+
--report-output engagements/acme-2026-q2/reports/vulnerability-report.md
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Example 2 — Interim report mid-engagement
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
python3 ./scripts/compose_report.py engagements/acme-2026-q2/ \
|
|
153
|
+
--min-severity high \
|
|
154
|
+
--report-output engagements/acme-2026-q2/reports/interim-2026-06-15.md
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`--min-severity high` produces an interim report covering only
|
|
158
|
+
HIGH and CRITICAL findings — useful for in-engagement customer
|
|
159
|
+
syncs.
|
|
160
|
+
|
|
161
|
+
### Example 3 — Regenerate after OWASP mapping
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python3 ./scripts/compose_report.py engagements/acme-2026-q2/ \
|
|
165
|
+
--source engagements/acme-2026-q2/findings/all-findings-with-owasp.jsonl \
|
|
166
|
+
--report-output engagements/acme-2026-q2/reports/vulnerability-report-v2.md
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Re-run after `mapping-findings-to-owasp-top10` has enriched each
|
|
170
|
+
finding with its OWASP category; the regenerated report includes
|
|
171
|
+
the OWASP tag in each per-finding subsection.
|
|
172
|
+
|
|
173
|
+
## Output
|
|
174
|
+
|
|
175
|
+
JSON / JSONL / Markdown per `lib/report.py` for the skill's own
|
|
176
|
+
operational findings. The PRIMARY output is the composed
|
|
177
|
+
vulnerability report, written as standalone Markdown to the
|
|
178
|
+
`--report-output` path.
|
|
179
|
+
|
|
180
|
+
Each operational Finding includes:
|
|
181
|
+
|
|
182
|
+
- `id` — `compose::<issue>::<source-file>`
|
|
183
|
+
- `severity` — CRITICAL / HIGH / MEDIUM / INFO
|
|
184
|
+
- `category` — `report-composition`
|
|
185
|
+
- `summary` — what went wrong (or right) during composition
|
|
186
|
+
- `evidence` — source files, finding counts, dedup stats
|
|
187
|
+
|
|
188
|
+
## Error Handling
|
|
189
|
+
|
|
190
|
+
- **PATH missing or empty** → emits CRITICAL operational finding,
|
|
191
|
+
exits 1.
|
|
192
|
+
- **Source file unparseable** → emits HIGH operational finding,
|
|
193
|
+
skips the file, continues with remaining sources.
|
|
194
|
+
- **No findings files found** → emits HIGH operational finding,
|
|
195
|
+
exits 1.
|
|
196
|
+
- **Output report path not writable** → emits HIGH operational
|
|
197
|
+
finding, exits 1.
|
|
198
|
+
- **Finding missing required fields** → emits HIGH operational
|
|
199
|
+
finding, omits that record from the report, continues.
|
|
200
|
+
|
|
201
|
+
## Resources
|
|
202
|
+
|
|
203
|
+
- `references/THEORY.md` — Vulnerability-report structure history
|
|
204
|
+
(NIST SP 800-115, OWASP Testing Guide), CVSS v3.1 vector
|
|
205
|
+
composition, severity scoring tradeoffs (CVSS vs intrinsic vs
|
|
206
|
+
EPSS), finding-deduplication theory, why fingerprint-based dedup
|
|
207
|
+
beats title-based
|
|
208
|
+
- `references/PLAYBOOK.md` — Report-template variants per
|
|
209
|
+
audience (technical, executive, regulatory), per-finding
|
|
210
|
+
remediation phrasing patterns, evidence-redaction patterns
|
|
211
|
+
for distributed reports, cross-reference protocol with the
|
|
212
|
+
OWASP-mapping and exec-summary skills
|