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.
- blackops_cli/__init__.py +62 -0
- blackops_cli/colour.py +29 -0
- blackops_cli/entrypoint.py +94 -0
- blackops_cli/logging.py +88 -0
- blackops_cli/output.py +202 -0
- blackops_cli/prompts.py +56 -0
- blackops_cli/report_html.py +358 -0
- blackops_cli/report_sarif.py +208 -0
- blackops_cli/reporter.py +92 -0
- blackops_cli/severity.py +95 -0
- blackops_cli-0.1.5.dist-info/METADATA +19 -0
- blackops_cli-0.1.5.dist-info/RECORD +14 -0
- blackops_cli-0.1.5.dist-info/WHEEL +4 -0
- blackops_cli-0.1.5.dist-info/licenses/LICENSE +661 -0
|
@@ -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' {_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 = " · ".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} · {col_count}'
|
|
214
|
+
+ (f" · {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
|
+
}
|
blackops_cli/reporter.py
ADDED
|
@@ -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
|
+
}
|