@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,540 @@
1
+ #!/usr/bin/env python3
2
+ """mapping-findings-to-owasp-top10 — enrich findings with OWASP categories.
3
+
4
+ Reads findings JSONL/JSON files, applies a deterministic rule table to assign
5
+ each finding to an OWASP Top 10 (2021) category, writes back an enriched
6
+ JSONL, and produces a per-category coverage report. Emits operational
7
+ Findings via lib/finding.py for any parse / unmapped issue.
8
+
9
+ Usage:
10
+ python3 map_owasp.py PATH [--source FILE] [--enrich-output FILE]
11
+ [--coverage-output FILE] [--overrides FILE]
12
+ [--output FILE] [--format json|jsonl|markdown]
13
+ [--min-severity sev]
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from collections import Counter, defaultdict
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ # --- lib/ import -------------------------------------------------------------
26
+ _LIB_ROOT = Path(__file__).resolve().parents[3]
27
+ sys.path.insert(0, str(_LIB_ROOT))
28
+
29
+ from lib.finding import Finding, Severity # noqa: E402
30
+ from lib import report # noqa: E402
31
+
32
+ try:
33
+ import yaml # type: ignore[import-not-found]
34
+
35
+ _HAS_PYYAML = True
36
+ except ImportError:
37
+ yaml = None
38
+ _HAS_PYYAML = False
39
+
40
+
41
+ SKILL_ID = "mapping-findings-to-owasp-top10"
42
+ CATEGORY = "owasp-mapping"
43
+
44
+ # OWASP Top 10 (2021) categories
45
+ OWASP_CATEGORIES = {
46
+ "A01": "A01:2021 — Broken Access Control",
47
+ "A02": "A02:2021 — Cryptographic Failures",
48
+ "A03": "A03:2021 — Injection",
49
+ "A04": "A04:2021 — Insecure Design",
50
+ "A05": "A05:2021 — Security Misconfiguration",
51
+ "A06": "A06:2021 — Vulnerable and Outdated Components",
52
+ "A07": "A07:2021 — Identification and Authentication Failures",
53
+ "A08": "A08:2021 — Software and Data Integrity Failures",
54
+ "A09": "A09:2021 — Security Logging and Monitoring Failures",
55
+ "A10": "A10:2021 — Server-Side Request Forgery",
56
+ }
57
+
58
+ # Skill-ID → OWASP category default mapping (deterministic).
59
+ # These are coarse defaults; detail-keyword rules below can override.
60
+ SKILL_TO_OWASP: dict[str, str] = {
61
+ # Cluster 1 — Network/Transport
62
+ "analyzing-tls-config": "A02",
63
+ "detecting-ssl-cert-issues": "A02",
64
+ "auditing-cors-policy": "A01",
65
+ "checking-http-security-headers": "A05",
66
+ "probing-dangerous-http-methods": "A05",
67
+ # Cluster 2 — Information disclosure
68
+ "detecting-exposed-secrets-files": "A02",
69
+ "detecting-debug-endpoints": "A05",
70
+ "fingerprinting-server-software": "A06",
71
+ "detecting-directory-listing": "A05",
72
+ # Cluster 3 — Static analysis
73
+ "scanning-for-hardcoded-secrets": "A02",
74
+ "detecting-sql-injection-patterns": "A03",
75
+ "detecting-command-injection-patterns": "A03",
76
+ "detecting-eval-exec-usage": "A03",
77
+ "detecting-insecure-deserialization": "A08",
78
+ "detecting-weak-cryptography": "A02",
79
+ # Cluster 4 — Dependencies
80
+ "auditing-npm-dependencies": "A06",
81
+ "auditing-python-dependencies": "A06",
82
+ "checking-license-compliance": "A06",
83
+ "tracing-transitive-vulnerabilities": "A06",
84
+ # Cluster 5 — Governance (not application-vulnerability findings;
85
+ # mapped to A04 Insecure Design when classifying for coverage purposes)
86
+ "confirming-pentest-authorization": "A04",
87
+ "defining-pentest-scope": "A04",
88
+ "recording-pentest-engagement": "A09",
89
+ }
90
+
91
+ # CWE → OWASP A0X mapping (subset; canonical OWASP cross-walk).
92
+ CWE_TO_OWASP = {
93
+ "CWE-22": "A01", # Path traversal
94
+ "CWE-23": "A01",
95
+ "CWE-200": "A01",
96
+ "CWE-285": "A01",
97
+ "CWE-639": "A01",
98
+ "CWE-26": "A02", # Cryptographic
99
+ "CWE-261": "A02",
100
+ "CWE-296": "A02",
101
+ "CWE-310": "A02",
102
+ "CWE-319": "A02",
103
+ "CWE-321": "A02",
104
+ "CWE-326": "A02",
105
+ "CWE-327": "A02",
106
+ "CWE-352": "A03",
107
+ "CWE-77": "A03", # Command injection
108
+ "CWE-78": "A03",
109
+ "CWE-79": "A03", # XSS
110
+ "CWE-89": "A03", # SQL injection
111
+ "CWE-94": "A03",
112
+ "CWE-1021": "A04",
113
+ "CWE-209": "A05", # information leak via error msg
114
+ "CWE-548": "A05", # directory listing
115
+ "CWE-1004": "A05",
116
+ "CWE-1104": "A06", # vulnerable third-party
117
+ "CWE-1395": "A06",
118
+ "CWE-287": "A07", # auth
119
+ "CWE-306": "A07",
120
+ "CWE-307": "A07",
121
+ "CWE-345": "A08",
122
+ "CWE-502": "A08", # insecure deserialization
123
+ "CWE-829": "A08",
124
+ "CWE-117": "A09", # log injection
125
+ "CWE-778": "A09", # insufficient logging
126
+ "CWE-918": "A10", # SSRF
127
+ }
128
+
129
+ # Detail-keyword fallback rules. Tuple of (keyword, category).
130
+ DETAIL_KEYWORDS: list[tuple[str, str]] = [
131
+ ("sql injection", "A03"),
132
+ ("command injection", "A03"),
133
+ ("xss", "A03"),
134
+ ("cross-site scripting", "A03"),
135
+ ("deserialization", "A08"),
136
+ ("ssrf", "A10"),
137
+ ("server-side request forgery", "A10"),
138
+ ("tls", "A02"),
139
+ ("ssl", "A02"),
140
+ ("cipher", "A02"),
141
+ ("md5", "A02"),
142
+ ("sha1", "A02"),
143
+ ("hardcoded secret", "A02"),
144
+ ("hardcoded credential", "A02"),
145
+ ("api key", "A02"),
146
+ ("path traversal", "A01"),
147
+ ("directory traversal", "A01"),
148
+ ("cors", "A01"),
149
+ ("dependency", "A06"),
150
+ ("transitive", "A06"),
151
+ ("cve-", "A06"),
152
+ ("authentication", "A07"),
153
+ ("session", "A07"),
154
+ ("brute force", "A07"),
155
+ ("debug", "A05"),
156
+ ("misconfiguration", "A05"),
157
+ ("header", "A05"),
158
+ ("logging", "A09"),
159
+ ("monitoring", "A09"),
160
+ ("authorization", "A04"),
161
+ ("scope", "A04"),
162
+ ]
163
+
164
+
165
+ # --- Helpers ----------------------------------------------------------------
166
+
167
+
168
+ def _f(
169
+ severity: Severity,
170
+ title: str,
171
+ target: str,
172
+ detail: str,
173
+ remediation: str,
174
+ evidence: tuple[tuple[str, Any], ...] = (),
175
+ ) -> Finding:
176
+ return Finding(
177
+ skill_id=SKILL_ID,
178
+ title=title,
179
+ severity=severity,
180
+ target=target,
181
+ detail=detail,
182
+ remediation=remediation,
183
+ evidence=evidence,
184
+ )
185
+
186
+
187
+ # --- Source loading ---------------------------------------------------------
188
+
189
+
190
+ def discover_sources(root: Path, explicit: list[str]) -> list[Path]:
191
+ if explicit:
192
+ return [Path(p).resolve() for p in explicit]
193
+ out: list[Path] = []
194
+ findings_dir = root / "findings"
195
+ if findings_dir.is_dir():
196
+ out.extend(sorted(findings_dir.glob("**/*.json")))
197
+ out.extend(sorted(findings_dir.glob("**/*.jsonl")))
198
+ return [p for p in out if "all-with-owasp" not in p.name]
199
+
200
+
201
+ def load_records(path: Path) -> tuple[list[dict[str, Any]], str | None]:
202
+ try:
203
+ text = path.read_text(encoding="utf-8")
204
+ except OSError as e:
205
+ return [], f"read failed: {e}"
206
+ text = text.strip()
207
+ if not text:
208
+ return [], None
209
+ out: list[dict[str, Any]] = []
210
+ if path.suffix == ".jsonl":
211
+ for line in text.splitlines():
212
+ line = line.strip()
213
+ if not line:
214
+ continue
215
+ try:
216
+ out.append(json.loads(line))
217
+ except json.JSONDecodeError as e:
218
+ return out, f"line parse error: {e}"
219
+ return out, None
220
+ try:
221
+ data = json.loads(text)
222
+ except json.JSONDecodeError as e:
223
+ return [], f"parse error: {e}"
224
+ if isinstance(data, list):
225
+ return [r for r in data if isinstance(r, dict)], None
226
+ if isinstance(data, dict) and "findings" in data:
227
+ return [r for r in data["findings"] if isinstance(r, dict)], None
228
+ return [], None
229
+
230
+
231
+ # --- Overrides --------------------------------------------------------------
232
+
233
+
234
+ def load_overrides(path: Path) -> list[dict[str, Any]]:
235
+ if not path.exists():
236
+ return []
237
+ text = path.read_text(encoding="utf-8")
238
+ if _HAS_PYYAML:
239
+ data = yaml.safe_load(text) or []
240
+ return data if isinstance(data, list) else []
241
+ # No YAML lib — refuse rather than misparse
242
+ return []
243
+
244
+
245
+ def apply_override(record: dict[str, Any], override: dict[str, Any]) -> bool:
246
+ skill = record.get("skill_id", "")
247
+ detail = record.get("detail", "")
248
+ if "skill_id" in override and override["skill_id"] != skill:
249
+ return False
250
+ if "detail_contains" in override and override["detail_contains"] not in detail:
251
+ return False
252
+ return True
253
+
254
+
255
+ # --- Mapping logic ----------------------------------------------------------
256
+
257
+
258
+ def classify(record: dict[str, Any], overrides: list[dict[str, Any]]) -> tuple[str | None, str]:
259
+ """Return (owasp_code, rule_that_matched). owasp_code is None if unmapped."""
260
+ # 1. Engagement-specific overrides
261
+ for ov in overrides:
262
+ if apply_override(record, ov):
263
+ cat = ov.get("owasp_category", "")
264
+ code = cat.split(":", 1)[0].strip() if cat else None
265
+ if code in OWASP_CATEGORIES:
266
+ return code, f"override: {ov.get('reason', 'no reason given')}"
267
+
268
+ # 2. CWE-based mapping
269
+ cwe = record.get("cwe_id")
270
+ if cwe and cwe in CWE_TO_OWASP:
271
+ return CWE_TO_OWASP[cwe], f"cwe-mapping: {cwe}"
272
+
273
+ # 3. Skill-ID default
274
+ skill = record.get("skill_id", "")
275
+ if skill in SKILL_TO_OWASP:
276
+ return SKILL_TO_OWASP[skill], f"skill-default: {skill}"
277
+
278
+ # 4. Detail-keyword fallback
279
+ detail = (record.get("detail", "") + " " + record.get("title", "")).lower()
280
+ for kw, code in DETAIL_KEYWORDS:
281
+ if kw in detail:
282
+ return code, f"keyword: {kw}"
283
+
284
+ return None, "no rule matched"
285
+
286
+
287
+ # --- Coverage report --------------------------------------------------------
288
+
289
+
290
+ def render_coverage(
291
+ counts_by_cat: dict[str, list[dict[str, Any]]],
292
+ engagement_id: str,
293
+ unmapped: list[dict[str, Any]],
294
+ ) -> str:
295
+ lines = [
296
+ f"# OWASP Top 10 (2021) Coverage — {engagement_id}",
297
+ "",
298
+ "| Category | Count | Critical | High | Medium | Low | Info |",
299
+ "|---|---|---|---|---|---|---|",
300
+ ]
301
+ for code in sorted(OWASP_CATEGORIES.keys()):
302
+ bucket = counts_by_cat.get(code, [])
303
+ sev_counts = Counter(r.get("severity", "info") for r in bucket)
304
+ lines.append(
305
+ f"| **{OWASP_CATEGORIES[code]}** "
306
+ f"| {len(bucket)} "
307
+ f"| {sev_counts.get('critical', 0)} "
308
+ f"| {sev_counts.get('high', 0)} "
309
+ f"| {sev_counts.get('medium', 0)} "
310
+ f"| {sev_counts.get('low', 0)} "
311
+ f"| {sev_counts.get('info', 0)} |"
312
+ )
313
+ if unmapped:
314
+ lines.append("")
315
+ lines.append(f"## Unmapped findings ({len(unmapped)})")
316
+ lines.append("")
317
+ for r in unmapped[:20]:
318
+ lines.append(f"- `{r.get('skill_id', '?')}` — {r.get('title', '')}")
319
+ if len(unmapped) > 20:
320
+ lines.append(f"- … and {len(unmapped) - 20} more")
321
+ lines.append("")
322
+ lines.append("## Per-category findings detail")
323
+ for code in sorted(OWASP_CATEGORIES.keys()):
324
+ bucket = counts_by_cat.get(code, [])
325
+ if not bucket:
326
+ continue
327
+ lines.append("")
328
+ lines.append(f"### {OWASP_CATEGORIES[code]} — {len(bucket)} finding(s)")
329
+ for r in sorted(bucket, key=lambda x: x.get("title", "")):
330
+ sev = r.get("severity", "info").upper()
331
+ lines.append(f"- **[{sev}]** `{r.get('skill_id', '?')}` — {r.get('title', '')}")
332
+ return "\n".join(lines)
333
+
334
+
335
+ # --- Engagement-id detection ------------------------------------------------
336
+
337
+
338
+ def detect_engagement_id(root: Path) -> str:
339
+ roe = root / "roe.yaml"
340
+ if not roe.exists():
341
+ return root.name
342
+ try:
343
+ for line in roe.read_text(encoding="utf-8").splitlines():
344
+ line = line.strip()
345
+ if line.startswith("engagement_id:"):
346
+ return line.split(":", 1)[1].strip().strip('"').strip("'")
347
+ except OSError:
348
+ pass
349
+ return root.name
350
+
351
+
352
+ # --- CLI ---------------------------------------------------------------------
353
+
354
+
355
+ def _build_arg_parser() -> argparse.ArgumentParser:
356
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
357
+ p.add_argument("path", help="Engagement directory")
358
+ p.add_argument("--source", action="append", default=[])
359
+ p.add_argument("--enrich-output", default=None)
360
+ p.add_argument("--coverage-output", default=None)
361
+ p.add_argument("--overrides", default=None)
362
+ p.add_argument("--output", default=None)
363
+ p.add_argument("--format", default="markdown", choices=["json", "jsonl", "markdown"])
364
+ p.add_argument(
365
+ "--min-severity",
366
+ default="info",
367
+ choices=["info", "low", "medium", "high", "critical"],
368
+ )
369
+ return p
370
+
371
+
372
+ def _filter_min_severity(findings: list[Finding], min_sev: str) -> list[Finding]:
373
+ floor = Severity(min_sev).numeric
374
+ return [f for f in findings if f.severity.numeric >= floor]
375
+
376
+
377
+ def main(argv: list[str] | None = None) -> int:
378
+ args = _build_arg_parser().parse_args(argv)
379
+ root = Path(args.path).resolve()
380
+ if not root.exists():
381
+ f = _f(
382
+ Severity.CRITICAL,
383
+ f"engagement path missing: {root}",
384
+ str(root),
385
+ f"PATH `{root}` does not exist.",
386
+ "Verify the engagement directory and re-run.",
387
+ )
388
+ report.emit([f], args.output, args.format, scan_target=str(root))
389
+ return 1
390
+
391
+ sources = discover_sources(root, args.source)
392
+ if not sources:
393
+ f = _f(
394
+ Severity.HIGH,
395
+ "no findings sources",
396
+ str(root),
397
+ f"No findings files under `{root}`.",
398
+ "Run cluster 1-4 skills first.",
399
+ )
400
+ report.emit([f], args.output, args.format, scan_target=str(root))
401
+ return 1
402
+
403
+ overrides = load_overrides(Path(args.overrides).resolve()) if args.overrides else []
404
+
405
+ op_findings: list[Finding] = []
406
+ enriched: list[dict[str, Any]] = []
407
+ by_cat: dict[str, list[dict[str, Any]]] = defaultdict(list)
408
+ unmapped: list[dict[str, Any]] = []
409
+
410
+ for src in sources:
411
+ records, err = load_records(src)
412
+ if err:
413
+ op_findings.append(
414
+ _f(
415
+ Severity.HIGH,
416
+ f"source unparseable: {src.name}",
417
+ str(src),
418
+ err,
419
+ "Fix the source or exclude.",
420
+ )
421
+ )
422
+ continue
423
+ for rec in records:
424
+ code, rule = classify(rec, overrides)
425
+ if code:
426
+ category_str = OWASP_CATEGORIES[code]
427
+ rec["owasp_category"] = category_str
428
+ by_cat[code].append(rec)
429
+ else:
430
+ rec["owasp_category"] = "UNMAPPED"
431
+ unmapped.append(rec)
432
+ op_findings.append(
433
+ _f(
434
+ Severity.INFO,
435
+ f"unmapped: {rec.get('title', '')[:80]}",
436
+ str(src),
437
+ f"Skill `{rec.get('skill_id', '?')}` produced a finding the rule table couldn't classify.",
438
+ "Extend the rule table or accept as cross-cutting.",
439
+ evidence=(
440
+ ("skill", rec.get("skill_id", "")),
441
+ ("title", rec.get("title", "")),
442
+ ),
443
+ )
444
+ )
445
+ enriched.append(rec)
446
+
447
+ # Write enriched output
448
+ enrich_path = (
449
+ Path(args.enrich_output).resolve() if args.enrich_output else root / "findings" / "all-with-owasp.jsonl"
450
+ )
451
+ if str(enrich_path) != "/dev/null":
452
+ try:
453
+ enrich_path.parent.mkdir(parents=True, exist_ok=True)
454
+ with open(enrich_path, "w", encoding="utf-8") as fh:
455
+ for rec in enriched:
456
+ fh.write(json.dumps(rec) + "\n")
457
+ except OSError as e:
458
+ op_findings.append(
459
+ _f(
460
+ Severity.HIGH,
461
+ f"cannot write enriched output: {enrich_path}",
462
+ str(enrich_path),
463
+ f"OSError: {e}",
464
+ "Resolve permissions and re-run.",
465
+ )
466
+ )
467
+
468
+ # Coverage report
469
+ coverage_path = (
470
+ Path(args.coverage_output).resolve() if args.coverage_output else root / "reports" / "owasp-coverage.md"
471
+ )
472
+ try:
473
+ coverage_path.parent.mkdir(parents=True, exist_ok=True)
474
+ engagement_id = detect_engagement_id(root)
475
+ coverage_path.write_text(render_coverage(by_cat, engagement_id, unmapped), encoding="utf-8")
476
+ # Coverage-quality assessment
477
+ covered_codes = sum(1 for code in OWASP_CATEGORIES if by_cat.get(code))
478
+ if covered_codes == 10:
479
+ op_findings.append(
480
+ _f(
481
+ Severity.INFO,
482
+ "engagement covers all 10 OWASP categories",
483
+ str(coverage_path),
484
+ "At least one finding in each of A01-A10.",
485
+ "Broad-coverage engagement; report includes complete OWASP narrative.",
486
+ evidence=(("categories_covered", covered_codes),),
487
+ )
488
+ )
489
+ elif covered_codes < 5:
490
+ op_findings.append(
491
+ _f(
492
+ Severity.MEDIUM,
493
+ f"engagement covers only {covered_codes} of 10 OWASP categories",
494
+ str(coverage_path),
495
+ f"Findings landed in only {covered_codes} categories. Either the "
496
+ f"engagement scope was narrow OR the rule table didn't recognize "
497
+ f"findings that should map to additional categories.",
498
+ "If scope was narrow, document in the engagement summary. Otherwise extend the rule table.",
499
+ evidence=(("categories_covered", covered_codes),),
500
+ )
501
+ )
502
+ else:
503
+ op_findings.append(
504
+ _f(
505
+ Severity.INFO,
506
+ f"OWASP coverage report written: {coverage_path.name}",
507
+ str(coverage_path),
508
+ f"Coverage: {covered_codes}/10 categories.",
509
+ "No action required.",
510
+ )
511
+ )
512
+ except OSError as e:
513
+ op_findings.append(
514
+ _f(
515
+ Severity.HIGH,
516
+ f"cannot write coverage report: {coverage_path}",
517
+ str(coverage_path),
518
+ f"OSError: {e}",
519
+ "Resolve permissions and re-run.",
520
+ )
521
+ )
522
+
523
+ if not op_findings:
524
+ op_findings = [
525
+ _f(
526
+ Severity.INFO,
527
+ "OWASP mapping complete",
528
+ str(root),
529
+ f"{len(enriched)} findings annotated; {len(unmapped)} unmapped.",
530
+ "No action required.",
531
+ )
532
+ ]
533
+
534
+ op_findings = _filter_min_severity(op_findings, args.min_severity)
535
+ report.emit(op_findings, args.output, args.format, scan_target=str(root))
536
+ return report.exit_code(op_findings)
537
+
538
+
539
+ if __name__ == "__main__":
540
+ sys.exit(main())