blackops-cli 0.1.5__py3-none-any.whl

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.
@@ -0,0 +1,358 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 CommonHuman-Lab
3
+ """Self-contained HTML report generator for CommonHuman-Lab scanners.
4
+
5
+ Stdlib only — no external dependencies.
6
+
7
+ Usage::
8
+
9
+ from blackops_cli.report_html import render_html
10
+
11
+ html = render_html(
12
+ results=[result.to_dict()],
13
+ tool_name="StingXSS",
14
+ tool_version="0.1.6",
15
+ )
16
+ with open("report.html", "w", encoding="utf-8") as fh:
17
+ fh.write(html)
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import html as _html
22
+ from datetime import datetime, timezone
23
+
24
+ __all__ = ["render_html"]
25
+
26
+ _SEV_COLORS: dict[str, str] = {
27
+ "critical": "#dc2626",
28
+ "high": "#ea580c",
29
+ "medium": "#ca8a04",
30
+ "low": "#16a34a",
31
+ "info": "#2563eb",
32
+ }
33
+
34
+ _CSS = """
35
+ * { box-sizing: border-box; margin: 0; padding: 0; }
36
+ body { font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
37
+ background: #f1f5f9; color: #0f172a; line-height: 1.5; }
38
+ header { background: #0f172a; color: #f8fafc; padding: 1.5rem 2rem; }
39
+ header h1 { font-size: 1.4rem; font-weight: 700; }
40
+ header p { font-size: 0.85rem; opacity: 0.6; margin-top: 0.25rem; }
41
+ .run { background: #fff; margin: 1.5rem 2rem; border-radius: 8px;
42
+ box-shadow: 0 1px 3px rgba(0,0,0,.1); overflow: hidden; }
43
+ .run-header { padding: 1rem 1.5rem; border-bottom: 1px solid #e2e8f0; }
44
+ .run-header h2 { font-size: 1rem; font-weight: 600; word-break: break-all; }
45
+ .meta { display: grid; grid-template-columns: repeat(auto-fill,minmax(160px,1fr));
46
+ gap: .75rem; padding: 1rem 1.5rem; background: #f8fafc;
47
+ border-bottom: 1px solid #e2e8f0; }
48
+ .meta-item .label { color: #64748b; font-size: .7rem; text-transform: uppercase;
49
+ letter-spacing: .05em; }
50
+ .meta-item .value { font-weight: 600; font-size: .85rem; }
51
+ table { width: 100%; border-collapse: collapse; font-size: .85rem; }
52
+ th { background: #f8fafc; text-align: left; padding: .6rem 1rem;
53
+ border-bottom: 2px solid #e2e8f0; font-size: .75rem;
54
+ text-transform: uppercase; letter-spacing: .05em; color: #64748b; }
55
+ td { padding: .6rem 1rem; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
56
+ tr:last-child td { border-bottom: none; }
57
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px;
58
+ font-size: .7rem; font-weight: 600; color: #fff; }
59
+ .sev-bar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
60
+ padding: .75rem 1.5rem; border-bottom: 1px solid #e2e8f0;
61
+ background: #fff; }
62
+ .sev-chip { display: inline-flex; align-items: center; gap: .35rem;
63
+ padding: .3rem .75rem; border-radius: 20px; font-size: .75rem;
64
+ font-weight: 700; color: #fff; letter-spacing: .03em; }
65
+ .sev-chip .chip-count { font-size: .9rem; }
66
+ .sev-label { font-size: .7rem; text-transform: uppercase; color: #94a3b8;
67
+ letter-spacing: .05em; margin-right: .25rem; }
68
+ .no-findings { padding: 1.5rem; color: #64748b; text-align: center; font-size: .9rem; }
69
+ .loc { color: #2563eb; word-break: break-all; font-family: monospace; font-size: .78rem; }
70
+ .kv { font-size: .78rem; margin: 1px 0; }
71
+ .k { color: #64748b; }
72
+ footer { text-align: center; padding: 1rem; font-size: .75rem; color: #94a3b8; }
73
+
74
+ /* Tabs */
75
+ .tab-bar { display: flex; gap: 0; border-bottom: 2px solid #e2e8f0; background: #f8fafc; }
76
+ .tab-btn { padding: .6rem 1.25rem; font-size: .8rem; font-weight: 600;
77
+ border: none; background: none; cursor: pointer; color: #64748b;
78
+ border-bottom: 2px solid transparent; margin-bottom: -2px;
79
+ letter-spacing: .03em; transition: color .15s, border-color .15s; }
80
+ .tab-btn:hover { color: #0f172a; }
81
+ .tab-btn.active { color: #2563eb; border-bottom-color: #2563eb; }
82
+ .tab-panel { display: none; }
83
+ .tab-panel.active { display: block; }
84
+
85
+ /* Dumped tables */
86
+ .dump-section { padding: 1.25rem 1.5rem; border-bottom: 1px solid #f1f5f9; }
87
+ .dump-section:last-child { border-bottom: none; }
88
+ .dump-title { font-size: .8rem; font-weight: 700; text-transform: uppercase;
89
+ letter-spacing: .06em; color: #0f172a; margin-bottom: .75rem; }
90
+ .dump-meta { font-size: .72rem; color: #94a3b8; margin-bottom: .6rem; }
91
+ .dump-table { width: 100%; border-collapse: collapse; font-size: .82rem; }
92
+ .dump-table th { background: #0f172a; color: #f8fafc; padding: .45rem .8rem;
93
+ text-align: left; font-size: .72rem; letter-spacing: .05em; }
94
+ .dump-table td { padding: .45rem .8rem; border-bottom: 1px solid #f1f5f9;
95
+ font-family: monospace; font-size: .78rem; word-break: break-all; }
96
+ .dump-table tr:last-child td { border-bottom: none; }
97
+ .dump-table tr:nth-child(even) td { background: #f8fafc; }
98
+ .null-val { color: #94a3b8; font-style: italic; }
99
+ """
100
+
101
+ _JS = """
102
+ function showTab(btn, panelId) {
103
+ var run = btn.closest('.run');
104
+ run.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
105
+ run.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
106
+ btn.classList.add('active');
107
+ document.getElementById(panelId).classList.add('active');
108
+ }
109
+ """
110
+
111
+ _SKIP_FIELDS = frozenset({"type", "severity", "url", "inject_url", "endpoint"})
112
+
113
+ _SEV_ORDER: dict[str, int] = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
114
+
115
+
116
+ def _esc(text: object) -> str:
117
+ return _html.escape(str(text))
118
+
119
+
120
+ def _sev_badge(sev: str) -> str:
121
+ colour = _SEV_COLORS.get(sev.lower(), "#6b7280")
122
+ return f'<span class="badge" style="background:{colour}">{_esc(sev.upper())}</span>'
123
+
124
+
125
+ def _sev_bar(findings: list[dict]) -> str:
126
+ counts: dict[str, int] = {}
127
+ for f in findings:
128
+ sev = f.get("severity", "info").lower()
129
+ counts[sev] = counts.get(sev, 0) + 1
130
+ if not counts:
131
+ return ""
132
+ chips = []
133
+ for sev in ("critical", "high", "medium", "low", "info"):
134
+ n = counts.get(sev)
135
+ if not n:
136
+ continue
137
+ colour = _SEV_COLORS.get(sev, "#6b7280")
138
+ chips.append(
139
+ f'<span class="sev-chip" style="background:{colour}">'
140
+ f'<span class="chip-count">{n}</span>'
141
+ f'&nbsp;{_esc(sev.upper())}'
142
+ f'</span>'
143
+ )
144
+ return (
145
+ f'<div class="sev-bar">'
146
+ f'<span class="sev-label">Findings</span>'
147
+ f'{"".join(chips)}'
148
+ f'</div>'
149
+ )
150
+
151
+
152
+ def _finding_url(f: dict) -> str:
153
+ return f.get("url") or f.get("inject_url") or f.get("endpoint") or ""
154
+
155
+
156
+ def _finding_details(f: dict) -> str:
157
+ parts: list[str] = []
158
+ for k, v in f.items():
159
+ if k in _SKIP_FIELDS or v is None or v == "" or v is False:
160
+ continue
161
+ val = str(v)
162
+ if len(val) > 120:
163
+ val = val[:117] + "..."
164
+ parts.append(f'<div class="kv"><span class="k">{_esc(k)}</span>: {_esc(val)}</div>')
165
+ return f'<div>{"".join(parts)}</div>'
166
+
167
+
168
+ def _render_table_dump(td: dict) -> str:
169
+ table = td.get("table", "?")
170
+ columns = td.get("columns") or []
171
+ rows = td.get("rows") or []
172
+ url = td.get("url", "")
173
+ param = td.get("parameter", "")
174
+ method = td.get("method", "")
175
+
176
+ meta_parts = []
177
+ if url:
178
+ meta_parts.append(f'<span class="loc">{_esc(url)}</span>')
179
+ if param:
180
+ meta_parts.append(f'param: <code>{_esc(param)}</code>')
181
+ if method:
182
+ meta_parts.append(f'method: <code>{_esc(method)}</code>')
183
+ meta_html = " &nbsp;·&nbsp; ".join(meta_parts)
184
+
185
+ if not columns and not rows:
186
+ body = '<div class="no-findings">No rows returned.</div>'
187
+ else:
188
+ header_cells = "".join(f"<th>{_esc(c)}</th>" for c in columns)
189
+ row_htmls = []
190
+ for row in rows:
191
+ cells = []
192
+ for cell in row:
193
+ if cell is None or cell == "":
194
+ cells.append('<td><span class="null-val">NULL</span></td>')
195
+ else:
196
+ cells.append(f"<td>{_esc(cell)}</td>")
197
+ row_htmls.append(f"<tr>{''.join(cells)}</tr>")
198
+ body = (
199
+ '<div style="overflow-x:auto">'
200
+ '<table class="dump-table">'
201
+ f"<thead><tr>{header_cells}</tr></thead>"
202
+ f"<tbody>{''.join(row_htmls)}</tbody>"
203
+ "</table>"
204
+ "</div>"
205
+ )
206
+
207
+ row_count = f"{len(rows)} row{'s' if len(rows) != 1 else ''}"
208
+ col_count = f"{len(columns)} column{'s' if len(columns) != 1 else ''}"
209
+
210
+ return (
211
+ f'<div class="dump-section">'
212
+ f'<div class="dump-title">{_esc(table)}</div>'
213
+ f'<div class="dump-meta">{row_count} &nbsp;·&nbsp; {col_count}'
214
+ + (f" &nbsp;·&nbsp; {meta_html}" if meta_html else "")
215
+ + f'</div>'
216
+ f'{body}'
217
+ f'</div>'
218
+ )
219
+
220
+
221
+ def _render_result(result: dict, idx: int) -> str:
222
+ target = result.get("target", "")
223
+ findings = result.get("findings", [])
224
+ table_dumps = result.get("table_dumps") or []
225
+ total = result.get("total_findings", len(findings))
226
+
227
+ meta_items = [
228
+ ("Duration", f'{result.get("duration_s", 0)}s'),
229
+ ("Requests", str(result.get("requests_sent", 0))),
230
+ ("Crawled URLs", str(result.get("crawled_urls", 0))),
231
+ ("Params tested", str(result.get("params_tested", 0))),
232
+ ("WAF", result.get("waf_detected") or "None"),
233
+ ("Evasion", result.get("evasion_applied") or "None"),
234
+ ("Findings", str(total)),
235
+ ]
236
+ meta_html = "".join(
237
+ f'<div class="meta-item">'
238
+ f'<div class="label">{_esc(lbl)}</div>'
239
+ f'<div class="value">{_esc(val)}</div>'
240
+ f'</div>'
241
+ for lbl, val in meta_items
242
+ )
243
+
244
+ # Findings panel
245
+ if not findings:
246
+ findings_body = '<div class="no-findings">No findings.</div>'
247
+ sev_bar_html = ""
248
+ else:
249
+ findings = sorted(findings, key=lambda f: _SEV_ORDER.get(f.get("severity", "info").lower(), 99))
250
+ rows = []
251
+ for i, f in enumerate(findings, 1):
252
+ loc = _finding_url(f)
253
+ loc_html = f'<span class="loc">{_esc(loc)}</span>' if loc else ""
254
+ rows.append(
255
+ f"<tr><td>{i}</td>"
256
+ f"<td>{_esc(f.get('type', ''))}</td>"
257
+ f"<td>{_sev_badge(f.get('severity', 'info'))}</td>"
258
+ f"<td>{loc_html}</td>"
259
+ f"<td>{_finding_details(f)}</td></tr>"
260
+ )
261
+ findings_body = (
262
+ "<table>"
263
+ "<thead><tr>"
264
+ "<th>#</th><th>Type</th><th>Severity</th>"
265
+ "<th>Location</th><th>Details</th>"
266
+ "</tr></thead>"
267
+ f"<tbody>{''.join(rows)}</tbody>"
268
+ "</table>"
269
+ )
270
+ sev_bar_html = _sev_bar(findings)
271
+
272
+ findings_panel_id = f"findings-{idx}"
273
+ tables_panel_id = f"tables-{idx}"
274
+
275
+ findings_tab_label = f"Findings ({total})"
276
+ tables_tab_label = f"Dumped Tables ({len(table_dumps)})"
277
+
278
+ # Tab bar
279
+ tables_btn_extra = "" if table_dumps else ' style="opacity:.4;cursor:default" disabled'
280
+ tab_bar = (
281
+ f'<div class="tab-bar">'
282
+ f'<button class="tab-btn active" onclick="showTab(this,\'{findings_panel_id}\')">'
283
+ f'{_esc(findings_tab_label)}</button>'
284
+ f'<button class="tab-btn"{tables_btn_extra} onclick="showTab(this,\'{tables_panel_id}\')">'
285
+ f'{_esc(tables_tab_label)}</button>'
286
+ f'</div>'
287
+ )
288
+
289
+ # Tables panel
290
+ if table_dumps:
291
+ tables_body = "".join(_render_table_dump(td) for td in table_dumps)
292
+ else:
293
+ tables_body = '<div class="no-findings">No tables dumped.</div>'
294
+
295
+ return (
296
+ f'<div class="run">'
297
+ f'<div class="run-header"><h2>{idx}. {_esc(target)}</h2></div>'
298
+ f'<div class="meta">{meta_html}</div>'
299
+ f'{sev_bar_html}'
300
+ f'{tab_bar}'
301
+ f'<div id="{findings_panel_id}" class="tab-panel active">{findings_body}</div>'
302
+ f'<div id="{tables_panel_id}" class="tab-panel">{tables_body}</div>'
303
+ f'</div>'
304
+ )
305
+
306
+
307
+ def render_html(
308
+ results: list[dict],
309
+ tool_name: str = "",
310
+ tool_version: str = "",
311
+ ) -> str:
312
+ """Generate a self-contained HTML scan report.
313
+
314
+ Args:
315
+ results: List of ``ScanResult.to_dict()`` dicts, optionally with
316
+ a ``table_dumps`` key from ``dumps_to_dict()``.
317
+ tool_name: Tool name shown in the header (e.g. ``"BreachSQL"``).
318
+ tool_version: Version string (e.g. ``"0.1.6"``).
319
+
320
+ Returns:
321
+ A complete, self-contained HTML document as a string.
322
+ """
323
+ now = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
324
+
325
+ if tool_name and tool_version:
326
+ ver_label = f"{tool_name} v{tool_version}"
327
+ elif tool_name:
328
+ ver_label = tool_name
329
+ else:
330
+ ver_label = ""
331
+
332
+ title = _esc(f"{tool_name} Scan Report" if tool_name else "Scan Report")
333
+ subline = f"Generated {_esc(now)}"
334
+
335
+ if results:
336
+ runs_html = "".join(_render_result(r, i) for i, r in enumerate(results, 1))
337
+ else:
338
+ runs_html = '<div class="run"><div class="no-findings">No targets scanned.</div></div>'
339
+
340
+ footer_name = _esc(ver_label or "CommonHuman-Lab")
341
+
342
+ return (
343
+ "<!DOCTYPE html>\n"
344
+ '<html lang="en">\n'
345
+ "<head>\n"
346
+ '<meta charset="UTF-8">'
347
+ '<meta name="viewport" content="width=device-width,initial-scale=1">\n'
348
+ f"<title>{title}</title>\n"
349
+ f"<style>{_CSS}</style>\n"
350
+ f"<script>{_JS}</script>\n"
351
+ "</head>\n"
352
+ "<body>\n"
353
+ f"<header><h1>{title}</h1><p>{subline}</p></header>\n"
354
+ f"{runs_html}\n"
355
+ f"<footer>Generated by {footer_name}</footer>\n"
356
+ "</body>\n"
357
+ "</html>"
358
+ )
@@ -0,0 +1,208 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 CommonHuman-Lab
3
+ """SARIF 2.1.0 report generator for CommonHuman-Lab scanners.
4
+
5
+ Stdlib only — no external dependencies.
6
+
7
+ Usage::
8
+
9
+ from blackops_cli.report_sarif import render_sarif
10
+ import json
11
+
12
+ RULES = {
13
+ "reflected_xss": ("Reflected XSS", "XSS payload reflected in HTTP response"),
14
+ # ... one entry per finding type the tool emits
15
+ }
16
+
17
+ sarif = render_sarif(
18
+ results=[result.to_dict()],
19
+ tool_name="StingXSS",
20
+ tool_version="0.1.6",
21
+ rules=RULES,
22
+ )
23
+ with open("report.sarif", "w", encoding="utf-8") as fh:
24
+ json.dump(sarif, fh, indent=2)
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import re
30
+ import urllib.parse
31
+
32
+ __all__ = ["render_sarif"]
33
+
34
+ # Use the SchemaStore URL — SARIF2006 flags the raw GitHub URL
35
+ _SARIF_SCHEMA = "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.6.json"
36
+
37
+ _LEVEL: dict[str, str] = {
38
+ "critical": "error",
39
+ "high": "error",
40
+ "medium": "warning",
41
+ "low": "note",
42
+ "info": "none",
43
+ }
44
+
45
+ # OWASP help URIs per rule id — used as helpUri and in help.text
46
+ _HELP_URIS: dict[str, str] = {
47
+ "reflected_xss": "https://owasp.org/www-community/attacks/xss/",
48
+ "stored_xss": "https://owasp.org/www-community/attacks/xss/",
49
+ "dom_xss": "https://owasp.org/www-community/attacks/DOM_Based_XSS",
50
+ "blind_xss": "https://owasp.org/www-community/attacks/xss/",
51
+ "browser_xss": "https://owasp.org/www-community/attacks/xss/",
52
+ "graphql_xss": "https://owasp.org/www-community/attacks/xss/",
53
+ "websocket_xss": "https://owasp.org/www-community/attacks/xss/",
54
+ "clickjacking": "https://owasp.org/www-community/attacks/Clickjacking",
55
+ "cors": "https://owasp.org/www-community/vulnerabilities/CORS_OriginHeaderScrutiny",
56
+ "jsonp_some": "https://owasp.org/www-community/attacks/JSONP",
57
+ "mixed_content": "https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content",
58
+ "leaked_cookie": "https://owasp.org/www-community/controls/SecureCookieAttribute",
59
+ "open_redirect": "https://cheatsheetseries.owasp.org/cheatsheets/"
60
+ "Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html",
61
+ "hsts": "https://cheatsheetseries.owasp.org/cheatsheets/"
62
+ "HTTP_Strict_Transport_Security_Cheat_Sheet.html",
63
+ "vuln_lib": "https://owasp.org/www-project-dependency-check/",
64
+ "sri_missing": "https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity",
65
+ "crlf": "https://owasp.org/www-community/vulnerabilities/CRLF_Injection",
66
+ "xst": "https://owasp.org/www-community/vulnerabilities/Cross_Site_Tracing",
67
+ }
68
+
69
+
70
+ def _sarif_level(severity: str) -> str:
71
+ return _LEVEL.get(severity.lower(), "warning")
72
+
73
+
74
+ def _finding_uri(f: dict) -> str:
75
+ return f.get("url") or f.get("inject_url") or f.get("endpoint") or ""
76
+
77
+
78
+ def _finding_message(f: dict) -> str:
79
+ ftype = f.get("type", "finding")
80
+ loc = _finding_uri(f)
81
+ param = f.get("parameter") or f.get("field") or ""
82
+ parts = [ftype]
83
+ if param:
84
+ parts.append(f"parameter={param}")
85
+ if loc:
86
+ parts.append(loc)
87
+ return " — ".join(parts)
88
+
89
+
90
+ def _pascal(name: str) -> str:
91
+ """Convert 'Reflected XSS' / 'JSONP / SOME' → 'ReflectedXss' / 'JsonpSome'."""
92
+ return "".join(w.capitalize() for w in re.split(r"[^a-zA-Z0-9]+", name) if w)
93
+
94
+
95
+ def _safe_uri(uri: str) -> str:
96
+ """Percent-encode characters that violate RFC 3986 (e.g. payload in query)."""
97
+ try:
98
+ p = urllib.parse.urlparse(uri)
99
+ return urllib.parse.urlunparse(p._replace(
100
+ path=urllib.parse.quote(p.path, safe="/-._~"),
101
+ query=urllib.parse.quote(p.query, safe="=&+%"),
102
+ ))
103
+ except Exception:
104
+ return urllib.parse.quote(uri, safe=":/?#[]@!$&*+,;=-._~%")
105
+
106
+
107
+ def _fingerprint(f: dict) -> str:
108
+ """Stable SHA-256 fingerprint for deduplication (first 16 hex chars)."""
109
+ key = f"{f.get('type', '')}:{_finding_uri(f)}:{f.get('parameter') or f.get('field') or ''}"
110
+ return hashlib.sha256(key.encode()).hexdigest()[:16]
111
+
112
+
113
+ def _make_rule(rule_id: str, name: str, description: str) -> dict:
114
+ help_uri = _HELP_URIS.get(rule_id, "")
115
+ rule: dict = {
116
+ "id": rule_id,
117
+ "name": _pascal(name),
118
+ "shortDescription": {"text": name},
119
+ "fullDescription": {"text": description},
120
+ "help": {
121
+ "text": f"{description}. See: {help_uri}" if help_uri else description,
122
+ "markdown": (
123
+ f"{description}.\n\nSee [{help_uri}]({help_uri})"
124
+ if help_uri else description
125
+ ),
126
+ },
127
+ }
128
+ if help_uri:
129
+ rule["helpUri"] = help_uri
130
+ return rule
131
+
132
+
133
+ def _make_location(uri: str) -> dict:
134
+ safe = _safe_uri(uri)
135
+ snippet = {"text": uri}
136
+ return {
137
+ "physicalLocation": {
138
+ "artifactLocation": {"uri": safe},
139
+ "region": {"startLine": 1, "snippet": snippet},
140
+ "contextRegion": {"startLine": 1, "snippet": snippet},
141
+ },
142
+ "logicalLocations": [{"name": uri, "kind": "url", "fullyQualifiedName": uri}],
143
+ }
144
+
145
+
146
+ def render_sarif(
147
+ results: list[dict],
148
+ tool_name: str,
149
+ tool_version: str,
150
+ rules: dict[str, tuple[str, str]],
151
+ information_uri: str = "",
152
+ ) -> dict:
153
+ """Generate a SARIF 2.1.0 document from a list of scan results.
154
+
155
+ Args:
156
+ results: List of ``ScanResult.to_dict()`` dicts.
157
+ tool_name: Tool name written into ``runs[].tool.driver.name``.
158
+ tool_version: Semver string written into ``runs[].tool.driver.version``.
159
+ rules: Mapping of ``rule_id → (name, description)`` — one entry
160
+ per finding type the tool can emit. Rule IDs should match
161
+ the ``"type"`` field on each finding.
162
+ information_uri: Optional URI for the tool's homepage / docs.
163
+
164
+ Returns:
165
+ A ``dict`` representing a valid SARIF 2.1.0 document. Serialise with
166
+ ``json.dump(sarif, fh, indent=2)``.
167
+ """
168
+ rule_index: dict[str, int] = {}
169
+ sarif_rules = [
170
+ (rule_index.setdefault(rid, i) or None, # side-effect: populate index
171
+ _make_rule(rid, name, desc))
172
+ for i, (rid, (name, desc)) in enumerate(rules.items())
173
+ ]
174
+ sarif_rules_list = [r for _, r in sarif_rules]
175
+
176
+ sarif_results = [
177
+ {
178
+ "ruleId": f.get("type", "unknown"),
179
+ "ruleIndex": rule_index.get(f.get("type", "unknown"), 0),
180
+ "level": _sarif_level(f.get("severity", "medium")),
181
+ "message": {"text": _finding_message(f)},
182
+ "locations": [_make_location(_finding_uri(f))],
183
+ "partialFingerprints": {"primaryLocationLineHash/v1": _fingerprint(f)},
184
+ }
185
+ for result in results
186
+ for f in result.get("findings", [])
187
+ ]
188
+
189
+ driver: dict = {
190
+ "name": tool_name,
191
+ "fullName": f"{tool_name} Security Scanner",
192
+ "version": tool_version,
193
+ "rules": sarif_rules_list,
194
+ }
195
+ if information_uri:
196
+ driver["informationUri"] = information_uri
197
+
198
+ return {
199
+ "$schema": _SARIF_SCHEMA,
200
+ "version": "2.1.0",
201
+ "runs": [
202
+ {
203
+ "automationDetails": {"id": f"{tool_name}/"},
204
+ "tool": {"driver": driver},
205
+ "results": sarif_results,
206
+ }
207
+ ],
208
+ }
@@ -0,0 +1,92 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 CommonHuman-Lab
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ __all__ = ["ScanResultBase"]
11
+
12
+
13
+ @dataclass
14
+ class ScanResultBase:
15
+ """Common scan metadata and thread-safe append helpers.
16
+
17
+ Tool-specific ScanResult classes inherit from this and add their own
18
+ finding lists and to_dict() implementation. Call _base_dict() inside
19
+ to_dict() to get the pre-populated common fields.
20
+
21
+ Field ordering ensures dataclass inheritance works: the only required
22
+ field (target) has no default; every subsequent field has a default,
23
+ so child classes can freely append defaulted fields.
24
+ """
25
+
26
+ target: str
27
+ started_at: float = field(default_factory=time.time)
28
+ finished_at: float = 0.0
29
+ duration_s: float = 0.0
30
+ waf_detected: str | None = None
31
+ evasion_applied: str | None = None
32
+ crawled_urls: int = 0
33
+ params_tested: int = 0
34
+ requests_sent: int = 0
35
+ log: list[str] = field(default_factory=list)
36
+ errors: list[str] = field(default_factory=list)
37
+ _lock: threading.Lock = field(
38
+ default_factory=threading.Lock,
39
+ repr=False,
40
+ compare=False,
41
+ )
42
+
43
+ # ------------------------------------------------------------------
44
+ # Lifecycle
45
+ # ------------------------------------------------------------------
46
+
47
+ def finish(self) -> "ScanResultBase":
48
+ self.finished_at = time.time()
49
+ self.duration_s = round(self.finished_at - self.started_at, 2)
50
+ return self
51
+
52
+ # ------------------------------------------------------------------
53
+ # Thread-safe appends
54
+ # ------------------------------------------------------------------
55
+
56
+ def _append(self, attr: str, item: Any) -> None:
57
+ with self._lock:
58
+ getattr(self, attr).append(item)
59
+
60
+ def append_error(self, msg: str) -> None:
61
+ self._append("errors", msg)
62
+
63
+ def append_log(self, msg: str) -> None:
64
+ self._append("log", msg)
65
+
66
+ # ------------------------------------------------------------------
67
+ # Properties
68
+ # ------------------------------------------------------------------
69
+
70
+ @property
71
+ def success(self) -> bool:
72
+ """False only when there are errors and zero findings."""
73
+ return not bool(self.errors) or bool(getattr(self, "total_findings", 0))
74
+
75
+ # ------------------------------------------------------------------
76
+ # Serialisation helpers
77
+ # ------------------------------------------------------------------
78
+
79
+ def _base_dict(self) -> dict[str, Any]:
80
+ """Return common fields for use in a tool's own to_dict()."""
81
+ return {
82
+ "success": self.success,
83
+ "target": self.target,
84
+ "duration_s": self.duration_s,
85
+ "waf_detected": self.waf_detected,
86
+ "evasion_applied": self.evasion_applied,
87
+ "crawled_urls": self.crawled_urls,
88
+ "params_tested": self.params_tested,
89
+ "requests_sent": self.requests_sent,
90
+ "errors": self.errors,
91
+ "log": self.log,
92
+ }