@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.
Files changed (112) hide show
  1. package/.claude-plugin/plugin.json +8 -3
  2. package/README.md +8 -0
  3. package/commands/pentest.md +5 -0
  4. package/package.json +8 -3
  5. package/skills/analyzing-tls-config/SKILL.md +221 -0
  6. package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
  7. package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
  8. package/skills/analyzing-tls-config/references/THEORY.md +128 -0
  9. package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
  10. package/skills/auditing-cors-policy/SKILL.md +186 -0
  11. package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
  12. package/skills/auditing-cors-policy/references/THEORY.md +142 -0
  13. package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
  14. package/skills/auditing-npm-dependencies/SKILL.md +254 -0
  15. package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
  16. package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
  17. package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
  18. package/skills/auditing-python-dependencies/SKILL.md +251 -0
  19. package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
  20. package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
  21. package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
  22. package/skills/checking-http-security-headers/SKILL.md +176 -0
  23. package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
  24. package/skills/checking-http-security-headers/references/THEORY.md +137 -0
  25. package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
  26. package/skills/checking-license-compliance/SKILL.md +225 -0
  27. package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
  28. package/skills/checking-license-compliance/references/THEORY.md +152 -0
  29. package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
  30. package/skills/composing-vulnerability-report/SKILL.md +212 -0
  31. package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
  32. package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
  33. package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
  34. package/skills/confirming-pentest-authorization/SKILL.md +247 -0
  35. package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
  36. package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
  37. package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
  38. package/skills/defining-pentest-scope/SKILL.md +227 -0
  39. package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
  40. package/skills/defining-pentest-scope/references/THEORY.md +170 -0
  41. package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
  42. package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
  43. package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
  44. package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
  45. package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
  46. package/skills/detecting-debug-endpoints/SKILL.md +207 -0
  47. package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
  48. package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
  49. package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
  50. package/skills/detecting-directory-listing/SKILL.md +206 -0
  51. package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
  52. package/skills/detecting-directory-listing/references/THEORY.md +203 -0
  53. package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
  54. package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
  55. package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
  56. package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
  57. package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
  58. package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
  59. package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
  60. package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
  61. package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
  62. package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
  63. package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
  64. package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
  65. package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
  66. package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
  67. package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
  68. package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
  69. package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
  70. package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
  71. package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
  72. package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
  73. package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
  74. package/skills/detecting-weak-cryptography/SKILL.md +147 -0
  75. package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
  76. package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
  77. package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
  78. package/skills/fingerprinting-server-software/SKILL.md +191 -0
  79. package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
  80. package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
  81. package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
  82. package/skills/generating-executive-summary/SKILL.md +261 -0
  83. package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
  84. package/skills/generating-executive-summary/references/THEORY.md +195 -0
  85. package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
  86. package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
  87. package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
  88. package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
  89. package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
  90. package/skills/performing-penetration-testing/SKILL.md +282 -190
  91. package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
  92. package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
  93. package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
  94. package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
  95. package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
  96. package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
  97. package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
  98. package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
  99. package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
  100. package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
  101. package/skills/recording-pentest-engagement/SKILL.md +253 -0
  102. package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
  103. package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
  104. package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
  105. package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
  106. package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
  107. package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
  108. package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
  109. package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
  110. package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
  111. package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
  112. 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