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