flagrante 0.0.1__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.
- flagrante/__init__.py +26 -0
- flagrante/classify.py +273 -0
- flagrante/cli.py +121 -0
- flagrante/html.py +362 -0
- flagrante/report.py +189 -0
- flagrante/sbom.py +207 -0
- flagrante/scan.py +62 -0
- flagrante/sources.py +273 -0
- flagrante-0.0.1.dist-info/METADATA +153 -0
- flagrante-0.0.1.dist-info/RECORD +14 -0
- flagrante-0.0.1.dist-info/WHEEL +5 -0
- flagrante-0.0.1.dist-info/entry_points.txt +2 -0
- flagrante-0.0.1.dist-info/licenses/LICENSE +202 -0
- flagrante-0.0.1.dist-info/top_level.txt +1 -0
flagrante/html.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Standalone HTML result page.
|
|
2
|
+
|
|
3
|
+
Lives inside the tool rather than the web service on purpose: the CLI, the
|
|
4
|
+
GitHub Action and the hosted scanner all render from this one function, so the
|
|
5
|
+
page a user shares can never say something different from the terminal output
|
|
6
|
+
they ran.
|
|
7
|
+
|
|
8
|
+
The page is fully self-contained -- inline CSS, no scripts, no external assets
|
|
9
|
+
beyond a font link. It can be committed to a repo as evidence, attached to a
|
|
10
|
+
ticket, or served statically, and it will still render in ten years.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import html as _html
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
from .classify import Assessment, Finding, Tier, early_warning_fields
|
|
19
|
+
|
|
20
|
+
BRAND = "Flagrante"
|
|
21
|
+
TAGLINE = "Caught in the act, not merely vulnerable"
|
|
22
|
+
SITE = "flagrante.dev"
|
|
23
|
+
|
|
24
|
+
_STYLE = """
|
|
25
|
+
:root{
|
|
26
|
+
--ground:#FBFAF9; --panel:#FFFFFF; --sunk:#F1EFED;
|
|
27
|
+
--ink:#16130F; --muted:#5E574F; --faint:#8D857B;
|
|
28
|
+
--rule:#DFDAD4; --rule-firm:#C2BAB1;
|
|
29
|
+
--caught:#B32D22; --caught-wash:#FBEAE7;
|
|
30
|
+
--elevated:#8A5B00; --elevated-wash:#FAF0DC;
|
|
31
|
+
--steady:#3F6B57;
|
|
32
|
+
}
|
|
33
|
+
@media (prefers-color-scheme:dark){
|
|
34
|
+
:root:not([data-theme="light"]){
|
|
35
|
+
--ground:#131210; --panel:#1B1917; --sunk:#221F1C;
|
|
36
|
+
--ink:#F0EBE4; --muted:#A79E93; --faint:#7A7269;
|
|
37
|
+
--rule:#2C2825; --rule-firm:#413B36;
|
|
38
|
+
--caught:#FF7A67; --caught-wash:#2E1A16;
|
|
39
|
+
--elevated:#E5A83C; --elevated-wash:#2A2113;
|
|
40
|
+
--steady:#67B394;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
:root[data-theme="dark"]{
|
|
44
|
+
--ground:#131210; --panel:#1B1917; --sunk:#221F1C;
|
|
45
|
+
--ink:#F0EBE4; --muted:#A79E93; --faint:#7A7269;
|
|
46
|
+
--rule:#2C2825; --rule-firm:#413B36;
|
|
47
|
+
--caught:#FF7A67; --caught-wash:#2E1A16;
|
|
48
|
+
--elevated:#E5A83C; --elevated-wash:#2A2113;
|
|
49
|
+
--steady:#67B394;
|
|
50
|
+
}
|
|
51
|
+
*{box-sizing:border-box}
|
|
52
|
+
body{margin:0;background:var(--ground);color:var(--ink);
|
|
53
|
+
font-family:Chivo,-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
|
|
54
|
+
font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
|
55
|
+
.wrap{max-width:840px;margin:0 auto;padding:0 22px 80px}
|
|
56
|
+
code,.mono{font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Consolas,monospace}
|
|
57
|
+
|
|
58
|
+
header.top{display:flex;align-items:baseline;justify-content:space-between;
|
|
59
|
+
gap:16px;padding:26px 0 20px;border-bottom:1px solid var(--rule);flex-wrap:wrap}
|
|
60
|
+
.brand{font-weight:800;font-size:18px;letter-spacing:-.02em;text-decoration:none;color:var(--ink)}
|
|
61
|
+
.brand span{color:var(--caught)}
|
|
62
|
+
.top .meta{font-family:"JetBrains Mono",monospace;font-size:11.5px;color:var(--faint);letter-spacing:.03em}
|
|
63
|
+
|
|
64
|
+
.subject{padding:34px 0 8px}
|
|
65
|
+
.subject h1{font-size:clamp(26px,4.4vw,38px);font-weight:800;letter-spacing:-.028em;
|
|
66
|
+
margin:0 0 6px;line-height:1.1;text-wrap:balance}
|
|
67
|
+
.subject .sub{font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--faint);letter-spacing:.03em}
|
|
68
|
+
|
|
69
|
+
.verdict{border:1px solid var(--rule-firm);border-top-width:4px;background:var(--panel);
|
|
70
|
+
padding:22px 24px;margin:26px 0 34px}
|
|
71
|
+
.verdict.caught{border-top-color:var(--caught);background:var(--caught-wash)}
|
|
72
|
+
.verdict.elevated{border-top-color:var(--elevated);background:var(--elevated-wash)}
|
|
73
|
+
.verdict .lab{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.14em;
|
|
74
|
+
text-transform:uppercase;margin-bottom:9px;color:var(--muted)}
|
|
75
|
+
.verdict.caught .lab{color:var(--caught)}
|
|
76
|
+
.verdict.elevated .lab{color:var(--elevated)}
|
|
77
|
+
.verdict p{margin:0;font-size:17.5px;line-height:1.5;max-width:60ch}
|
|
78
|
+
|
|
79
|
+
.counts{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
|
|
80
|
+
gap:1px;background:var(--rule);border:1px solid var(--rule);margin:0 0 40px}
|
|
81
|
+
.count{background:var(--panel);padding:16px 18px}
|
|
82
|
+
.count .n{font-size:30px;font-weight:800;letter-spacing:-.02em;line-height:1;display:block;
|
|
83
|
+
margin-bottom:6px;font-variant-numeric:tabular-nums}
|
|
84
|
+
.count .k{font-family:"JetBrains Mono",monospace;font-size:10.5px;letter-spacing:.06em;
|
|
85
|
+
text-transform:uppercase;color:var(--faint);display:block;line-height:1.4}
|
|
86
|
+
.count.caught .n{color:var(--caught)}
|
|
87
|
+
.count.elevated .n{color:var(--elevated)}
|
|
88
|
+
|
|
89
|
+
section.group{margin:0 0 38px}
|
|
90
|
+
.group h2{font-size:13px;font-family:"JetBrains Mono",monospace;letter-spacing:.12em;
|
|
91
|
+
text-transform:uppercase;margin:0 0 4px;font-weight:600}
|
|
92
|
+
.group .note{font-size:13.5px;color:var(--muted);margin:0 0 16px}
|
|
93
|
+
.group.caught h2{color:var(--caught)}
|
|
94
|
+
.group.elevated h2{color:var(--elevated)}
|
|
95
|
+
|
|
96
|
+
.rows{display:flex;flex-direction:column;gap:1px;background:var(--rule);
|
|
97
|
+
border:1px solid var(--rule)}
|
|
98
|
+
.row{background:var(--panel);padding:15px 18px;display:grid;
|
|
99
|
+
grid-template-columns:170px 1fr;gap:4px 20px;align-items:baseline}
|
|
100
|
+
.row .cve{font-family:"JetBrains Mono",monospace;font-size:13px;font-weight:600}
|
|
101
|
+
.row.caught .cve{color:var(--caught)}
|
|
102
|
+
.row.elevated .cve{color:var(--elevated)}
|
|
103
|
+
.row .comp{font-family:"JetBrains Mono",monospace;font-size:13px;word-break:break-all}
|
|
104
|
+
.row .why{grid-column:2;font-size:13.5px;color:var(--muted);line-height:1.5}
|
|
105
|
+
/* Unresolvable components have no CVE to sit in the first column, so they get
|
|
106
|
+
the full width instead of being squeezed into 170px. */
|
|
107
|
+
.row.solo{grid-template-columns:1fr}
|
|
108
|
+
.more{background:var(--panel);padding:12px 18px;font-family:"JetBrains Mono",monospace;
|
|
109
|
+
font-size:11.5px;color:var(--faint)}
|
|
110
|
+
|
|
111
|
+
.cascade{border:1px solid var(--rule);background:var(--panel);padding:20px 22px;margin:0 0 38px}
|
|
112
|
+
.cascade h2{font-size:13px;font-family:"JetBrains Mono",monospace;letter-spacing:.12em;
|
|
113
|
+
text-transform:uppercase;margin:0 0 4px;font-weight:600}
|
|
114
|
+
.cascade .note{font-size:13.5px;color:var(--muted);margin:0 0 16px;max-width:62ch}
|
|
115
|
+
/* Same reason as the landing's three-cell grid: three items and auto-fit
|
|
116
|
+
strands the last one beside dead space. Three-up or stacked. */
|
|
117
|
+
.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
|
118
|
+
@media (max-width:640px){.steps{grid-template-columns:1fr}}
|
|
119
|
+
.step{border-left:2px solid var(--rule-firm);padding-left:13px}
|
|
120
|
+
.step:first-child{border-left-color:var(--caught)}
|
|
121
|
+
.step .w{font-family:"JetBrains Mono",monospace;font-size:10.5px;letter-spacing:.08em;
|
|
122
|
+
text-transform:uppercase;color:var(--faint);display:block;margin-bottom:5px}
|
|
123
|
+
.step .t{font-family:"JetBrains Mono",monospace;font-size:13.5px;font-weight:600;display:block}
|
|
124
|
+
|
|
125
|
+
table.fields{width:100%;border-collapse:collapse;font-family:"JetBrains Mono",monospace;font-size:12.5px}
|
|
126
|
+
table.fields td{padding:9px 12px;border-bottom:1px solid var(--rule);vertical-align:top}
|
|
127
|
+
table.fields tr:last-child td{border-bottom:none}
|
|
128
|
+
table.fields td:first-child{color:var(--faint);white-space:nowrap;width:1%}
|
|
129
|
+
.blank{color:var(--faint);font-style:italic}
|
|
130
|
+
|
|
131
|
+
.limits{border-top:1px solid var(--rule);padding-top:26px;margin-top:44px}
|
|
132
|
+
.limits h2{font-size:13px;font-family:"JetBrains Mono",monospace;letter-spacing:.12em;
|
|
133
|
+
text-transform:uppercase;margin:0 0 14px;font-weight:600;color:var(--muted)}
|
|
134
|
+
.limits ol{margin:0;padding-left:20px;max-width:64ch}
|
|
135
|
+
.limits li{margin-bottom:11px;font-size:14.5px;color:var(--muted);line-height:1.55}
|
|
136
|
+
.limits li strong{color:var(--ink);font-weight:600}
|
|
137
|
+
|
|
138
|
+
.cta{margin-top:40px;border:1px solid var(--rule-firm);background:var(--sunk);padding:24px}
|
|
139
|
+
.cta p{margin:0 0 12px;font-size:15px;max-width:58ch}
|
|
140
|
+
.cta code{background:var(--panel);border:1px solid var(--rule);padding:3px 8px;font-size:13px;display:inline-block}
|
|
141
|
+
.cta a{color:var(--caught);font-weight:600}
|
|
142
|
+
footer.end{margin-top:34px;font-family:"JetBrains Mono",monospace;font-size:11px;
|
|
143
|
+
color:var(--faint);line-height:1.7}
|
|
144
|
+
a:focus-visible{outline:2px solid var(--steady);outline-offset:2px}
|
|
145
|
+
@media (max-width:600px){
|
|
146
|
+
.row{grid-template-columns:1fr}
|
|
147
|
+
.row .why{grid-column:1}
|
|
148
|
+
}
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _esc(value: object) -> str:
|
|
153
|
+
return _html.escape(str(value), quote=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _stamp(moment: datetime) -> str:
|
|
157
|
+
return moment.strftime("%d %b %Y, %H:%M UTC")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _rows(findings: list[Finding], css_class: str, limit: int | None = None) -> str:
|
|
161
|
+
shown = findings[:limit] if limit else findings
|
|
162
|
+
parts = []
|
|
163
|
+
for finding in shown:
|
|
164
|
+
parts.append(
|
|
165
|
+
f'<div class="row {css_class}">'
|
|
166
|
+
f'<span class="cve">{_esc(finding.primary_cve)}</span>'
|
|
167
|
+
f'<span class="comp">{_esc(finding.component.label)}</span>'
|
|
168
|
+
f'<span class="why">{_esc(finding.reason)}</span>'
|
|
169
|
+
"</div>"
|
|
170
|
+
)
|
|
171
|
+
if limit and len(findings) > limit:
|
|
172
|
+
parts.append(
|
|
173
|
+
f'<div class="more">and {len(findings) - limit} more at this level</div>'
|
|
174
|
+
)
|
|
175
|
+
return "".join(parts)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cascade(assessment: Assessment) -> str:
|
|
179
|
+
deadlines = assessment.deadlines
|
|
180
|
+
steps = [
|
|
181
|
+
("within 24 hours", "Early warning", deadlines["early_warning"]),
|
|
182
|
+
("within 72 hours", "Vulnerability notification", deadlines["notification"]),
|
|
183
|
+
("within 14 days", "Final report", deadlines["final_report"]),
|
|
184
|
+
]
|
|
185
|
+
cells = "".join(
|
|
186
|
+
f'<div class="step"><span class="w">{_esc(window)}</span>'
|
|
187
|
+
f'<span class="t">{_esc(name)}</span>'
|
|
188
|
+
f'<span class="w" style="margin-top:5px">{_esc(_stamp(when))}</span></div>'
|
|
189
|
+
for window, name, when in steps
|
|
190
|
+
)
|
|
191
|
+
return (
|
|
192
|
+
'<div class="cascade"><h2>Article 14 cascade</h2>'
|
|
193
|
+
"<p class=\"note\">Counted from the moment you became aware, which may well "
|
|
194
|
+
"predate this scan. Submitted to the ENISA Single Reporting Platform and the "
|
|
195
|
+
"CSIRT of your member state of main establishment.</p>"
|
|
196
|
+
f'<div class="steps">{cells}</div></div>'
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _early_warning(assessment: Assessment) -> str:
|
|
201
|
+
if not assessment.clock_running:
|
|
202
|
+
return ""
|
|
203
|
+
fields = early_warning_fields(assessment.clock_running[0], assessment)
|
|
204
|
+
rows = []
|
|
205
|
+
for key, value in fields.items():
|
|
206
|
+
label = _esc(key.replace("_", " "))
|
|
207
|
+
blank = value.startswith("<")
|
|
208
|
+
cell = (
|
|
209
|
+
f'<span class="blank">{_esc(value)}</span>' if blank else _esc(value)
|
|
210
|
+
)
|
|
211
|
+
rows.append(f"<tr><td>{label}</td><td>{cell}</td></tr>")
|
|
212
|
+
return (
|
|
213
|
+
'<div class="cascade"><h2>What the early warning must carry</h2>'
|
|
214
|
+
'<p class="note">The italic fields are the ones only you can fill. They are '
|
|
215
|
+
"left blank rather than guessed.</p>"
|
|
216
|
+
f'<table class="fields"><tbody>{"".join(rows)}</tbody></table></div>'
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def render_html(assessment: Assessment, scan_url: str | None = None) -> str:
|
|
221
|
+
"""Produce a complete, standalone HTML document for one scan."""
|
|
222
|
+
running = assessment.clock_running
|
|
223
|
+
urgent = assessment.urgent
|
|
224
|
+
monitor = assessment.monitor
|
|
225
|
+
unresolvable = assessment.unresolvable
|
|
226
|
+
|
|
227
|
+
if running:
|
|
228
|
+
tone, lab = "caught", "Caught in the act"
|
|
229
|
+
elif urgent:
|
|
230
|
+
tone, lab = "elevated", "Not yet confirmed"
|
|
231
|
+
else:
|
|
232
|
+
tone, lab = "", "Point-in-time observation"
|
|
233
|
+
|
|
234
|
+
subject = assessment.document.subject or "Unnamed SBOM"
|
|
235
|
+
doc = assessment.document
|
|
236
|
+
|
|
237
|
+
blocks: list[str] = []
|
|
238
|
+
|
|
239
|
+
blocks.append(
|
|
240
|
+
f'<div class="verdict {tone}"><div class="lab">{_esc(lab)}</div>'
|
|
241
|
+
f"<p>{_esc(assessment.verdict)}</p></div>"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
blocks.append(
|
|
245
|
+
'<div class="counts">'
|
|
246
|
+
f'<div class="count caught"><span class="n">{len(running)}</span>'
|
|
247
|
+
'<span class="k">Confirmed exploited</span></div>'
|
|
248
|
+
f'<div class="count elevated"><span class="n">{len(urgent)}</span>'
|
|
249
|
+
'<span class="k">Elevated probability</span></div>'
|
|
250
|
+
f'<div class="count"><span class="n">{len(monitor)}</span>'
|
|
251
|
+
'<span class="k">No exploit signal</span></div>'
|
|
252
|
+
f'<div class="count"><span class="n">{len(unresolvable)}</span>'
|
|
253
|
+
'<span class="k">Could not be checked</span></div>'
|
|
254
|
+
"</div>"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if running:
|
|
258
|
+
blocks.append(
|
|
259
|
+
'<section class="group caught"><h2>24-hour clock likely running</h2>'
|
|
260
|
+
'<p class="note">Listed in the CISA Known Exploited Vulnerabilities '
|
|
261
|
+
"catalogue — observed being exploited in the wild.</p>"
|
|
262
|
+
f'<div class="rows">{_rows(running, "caught")}</div></section>'
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if urgent:
|
|
266
|
+
blocks.append(
|
|
267
|
+
'<section class="group elevated"><h2>Assess today</h2>'
|
|
268
|
+
'<p class="note">EPSS puts these above a 10% chance of exploitation '
|
|
269
|
+
"within 30 days. Not confirmed, but this is the group obligations "
|
|
270
|
+
"tend to come from next.</p>"
|
|
271
|
+
f'<div class="rows">{_rows(urgent, "elevated", limit=12)}</div></section>'
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if running or urgent:
|
|
275
|
+
blocks.append(_cascade(assessment))
|
|
276
|
+
blocks.append(_early_warning(assessment))
|
|
277
|
+
|
|
278
|
+
if unresolvable:
|
|
279
|
+
names = "".join(
|
|
280
|
+
f'<div class="row solo"><span class="comp">{_esc(c.label)}</span></div>'
|
|
281
|
+
for c in unresolvable[:12]
|
|
282
|
+
)
|
|
283
|
+
extra = (
|
|
284
|
+
f'<div class="more">and {len(unresolvable) - 12} more</div>'
|
|
285
|
+
if len(unresolvable) > 12
|
|
286
|
+
else ""
|
|
287
|
+
)
|
|
288
|
+
blocks.append(
|
|
289
|
+
'<section class="group"><h2>Could not be checked</h2>'
|
|
290
|
+
'<p class="note">These components carry no package URL, so no feed can '
|
|
291
|
+
"be queried for them. That is an unknown, not a clear.</p>"
|
|
292
|
+
f'<div class="rows">{names}{extra}</div></section>'
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
blocks.append(
|
|
296
|
+
'<div class="limits"><h2>What this is not</h2><ol>'
|
|
297
|
+
"<li><strong>Not a determination that your product is affected.</strong> "
|
|
298
|
+
"A KEV listing means the vulnerability is exploited somewhere in the world, "
|
|
299
|
+
"not that your build is reachable or under attack. Article 14 turns on the "
|
|
300
|
+
"vulnerability being in the product and actively exploited. That judgement "
|
|
301
|
+
"is yours.</li>"
|
|
302
|
+
"<li><strong>Not complete.</strong> KEV lags real exploitation and EPSS is a "
|
|
303
|
+
"model, not an observation. Absence of a signal here is not evidence of "
|
|
304
|
+
"absence in the world.</li>"
|
|
305
|
+
"<li><strong>Not a conformity assessment, and not legal advice.</strong> "
|
|
306
|
+
"There is deliberately no outcome on this page that means you are in the "
|
|
307
|
+
"clear.</li>"
|
|
308
|
+
"</ol></div>"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
cta_link = (
|
|
312
|
+
f'<a href="https://{SITE}">{SITE}</a>' if not scan_url else f'<a href="{_esc(scan_url)}">{_esc(scan_url)}</a>'
|
|
313
|
+
)
|
|
314
|
+
blocks.append(
|
|
315
|
+
'<div class="cta">'
|
|
316
|
+
"<p>This is a snapshot. Exploitation status changes daily, and the 24-hour "
|
|
317
|
+
"window starts from awareness — not from your next scan.</p>"
|
|
318
|
+
f"<p><code>pip install flagrante</code> then "
|
|
319
|
+
f"<code>syft dir:. -o cyclonedx-json | flagrante</code></p>"
|
|
320
|
+
f"<p>Continuous watching and a dated evidence trail: {cta_link}</p>"
|
|
321
|
+
"</div>"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
meta = (
|
|
325
|
+
f"{_esc(doc.format)} {_esc(doc.spec_version or '')} · "
|
|
326
|
+
f"{assessment.components_checked} components checked · "
|
|
327
|
+
f"{_esc(_stamp(assessment.scanned_at))}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return f"""<!doctype html>
|
|
331
|
+
<html lang="en">
|
|
332
|
+
<head>
|
|
333
|
+
<meta charset="utf-8">
|
|
334
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
335
|
+
<title>{_esc(subject)} — {BRAND}</title>
|
|
336
|
+
<meta name="description" content="{_esc(assessment.verdict[:180])}">
|
|
337
|
+
<meta name="robots" content="noindex">
|
|
338
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
339
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
340
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Chivo:wght@400;600;800&family=JetBrains+Mono:wght@400;600&display=swap">
|
|
341
|
+
<style>{_STYLE}</style>
|
|
342
|
+
</head>
|
|
343
|
+
<body>
|
|
344
|
+
<div class="wrap">
|
|
345
|
+
<header class="top">
|
|
346
|
+
<a class="brand" href="https://{SITE}">Red<span>hand</span></a>
|
|
347
|
+
<span class="meta">{TAGLINE}</span>
|
|
348
|
+
</header>
|
|
349
|
+
<div class="subject">
|
|
350
|
+
<h1>{_esc(subject)}</h1>
|
|
351
|
+
<div class="sub">{meta}</div>
|
|
352
|
+
</div>
|
|
353
|
+
{"".join(blocks)}
|
|
354
|
+
<footer class="end">
|
|
355
|
+
Generated by {BRAND} from CISA KEV, FIRST EPSS and OSV.<br>
|
|
356
|
+
Exposure indicator only. Not a conformity assessment, not legal advice, and not
|
|
357
|
+
a determination that no reporting obligation exists.
|
|
358
|
+
</footer>
|
|
359
|
+
</div>
|
|
360
|
+
</body>
|
|
361
|
+
</html>
|
|
362
|
+
"""
|
flagrante/report.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Terminal rendering.
|
|
2
|
+
|
|
3
|
+
The output has one job: make the difference between "confirmed exploited" and
|
|
4
|
+
"merely vulnerable" impossible to miss, because that distinction is the whole
|
|
5
|
+
of Article 14 and every other scanner blurs it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from .classify import Assessment, Finding, Tier, early_warning_fields
|
|
16
|
+
|
|
17
|
+
_ANSI = {
|
|
18
|
+
"red": "\033[31m",
|
|
19
|
+
"yellow": "\033[33m",
|
|
20
|
+
"dim": "\033[2m",
|
|
21
|
+
"bold": "\033[1m",
|
|
22
|
+
"reset": "\033[0m",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _supports_colour(stream) -> bool:
|
|
27
|
+
if os.environ.get("NO_COLOR"):
|
|
28
|
+
return False
|
|
29
|
+
return bool(getattr(stream, "isatty", lambda: False)())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Style:
|
|
33
|
+
def __init__(self, enabled: bool) -> None:
|
|
34
|
+
self.enabled = enabled
|
|
35
|
+
|
|
36
|
+
def __call__(self, text: str, *names: str) -> str:
|
|
37
|
+
if not self.enabled or not names:
|
|
38
|
+
return text
|
|
39
|
+
prefix = "".join(_ANSI[n] for n in names if n in _ANSI)
|
|
40
|
+
return f"{prefix}{text}{_ANSI['reset']}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _stamp(moment: datetime) -> str:
|
|
44
|
+
return moment.strftime("%Y-%m-%d %H:%M UTC")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _finding_line(finding: Finding, style: _Style) -> str:
|
|
48
|
+
colour = {
|
|
49
|
+
Tier.CLOCK_RUNNING: ("red", "bold"),
|
|
50
|
+
Tier.URGENT_REVIEW: ("yellow",),
|
|
51
|
+
Tier.MONITOR: ("dim",),
|
|
52
|
+
}.get(finding.tier, ())
|
|
53
|
+
marker = style(finding.primary_cve, *colour)
|
|
54
|
+
return f" {marker} {finding.component.label}\n {finding.reason}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render(assessment: Assessment, stream=sys.stdout, show_monitor: bool = False) -> None:
|
|
58
|
+
style = _Style(_supports_colour(stream))
|
|
59
|
+
write = lambda line="": print(line, file=stream)
|
|
60
|
+
|
|
61
|
+
doc = assessment.document
|
|
62
|
+
subject = doc.subject or "(unnamed)"
|
|
63
|
+
write()
|
|
64
|
+
write(style(f"flagrante ~ {subject}", "bold"))
|
|
65
|
+
write(
|
|
66
|
+
style(
|
|
67
|
+
f"{doc.format} {doc.spec_version or ''}".strip()
|
|
68
|
+
+ f" ~ {assessment.components_checked} components checked"
|
|
69
|
+
+ f" ~ {_stamp(assessment.scanned_at)}",
|
|
70
|
+
"dim",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
write()
|
|
74
|
+
|
|
75
|
+
running = assessment.clock_running
|
|
76
|
+
urgent = assessment.urgent
|
|
77
|
+
monitor = assessment.monitor
|
|
78
|
+
|
|
79
|
+
if running:
|
|
80
|
+
write(style(f"{Tier.CLOCK_RUNNING.headline.upper()} ({len(running)})", "red", "bold"))
|
|
81
|
+
write(style(" Confirmed exploited in the wild -- CISA KEV", "dim"))
|
|
82
|
+
write()
|
|
83
|
+
for finding in running:
|
|
84
|
+
write(_finding_line(finding, style))
|
|
85
|
+
write()
|
|
86
|
+
|
|
87
|
+
if urgent:
|
|
88
|
+
write(style(f"{Tier.URGENT_REVIEW.headline.upper()} ({len(urgent)})", "yellow", "bold"))
|
|
89
|
+
write(style(" Elevated exploitation probability, not yet confirmed", "dim"))
|
|
90
|
+
write()
|
|
91
|
+
# A single stale dependency can carry twenty of these. Showing them all
|
|
92
|
+
# buries the section above it, which is the one that matters.
|
|
93
|
+
shown = urgent if show_monitor else urgent[:8]
|
|
94
|
+
for finding in shown:
|
|
95
|
+
write(_finding_line(finding, style))
|
|
96
|
+
if len(urgent) > len(shown):
|
|
97
|
+
write(style(f" ... and {len(urgent) - len(shown)} more (--all)", "dim"))
|
|
98
|
+
write()
|
|
99
|
+
|
|
100
|
+
if monitor:
|
|
101
|
+
if show_monitor:
|
|
102
|
+
write(style(f"{Tier.MONITOR.headline.upper()} ({len(monitor)})", "dim"))
|
|
103
|
+
write()
|
|
104
|
+
for finding in monitor:
|
|
105
|
+
write(_finding_line(finding, style))
|
|
106
|
+
write()
|
|
107
|
+
else:
|
|
108
|
+
write(style(f" {len(monitor)} further known vulnerabilities with no exploitation signal (--all to list)", "dim"))
|
|
109
|
+
write()
|
|
110
|
+
|
|
111
|
+
if assessment.unresolvable:
|
|
112
|
+
write(style(f"CANNOT BE CHECKED ({len(assessment.unresolvable)})", "bold"))
|
|
113
|
+
write(style(" No package URL in the SBOM. An unknown, not a clear.", "dim"))
|
|
114
|
+
write()
|
|
115
|
+
for component in assessment.unresolvable[:10]:
|
|
116
|
+
write(f" {component.label}")
|
|
117
|
+
if len(assessment.unresolvable) > 10:
|
|
118
|
+
write(style(f" ... and {len(assessment.unresolvable) - 10} more", "dim"))
|
|
119
|
+
write()
|
|
120
|
+
|
|
121
|
+
write(style("VERDICT", "bold"))
|
|
122
|
+
write(f" {assessment.verdict}")
|
|
123
|
+
write()
|
|
124
|
+
|
|
125
|
+
if running or urgent:
|
|
126
|
+
deadlines = assessment.deadlines
|
|
127
|
+
write(style("ARTICLE 14 CASCADE", "bold"))
|
|
128
|
+
write(style(" Counted from the moment you became aware, which may predate this scan.", "dim"))
|
|
129
|
+
write(f" Early warning {_stamp(deadlines['early_warning'])} (24h)")
|
|
130
|
+
write(f" Notification {_stamp(deadlines['notification'])} (72h)")
|
|
131
|
+
write(f" Final report {_stamp(deadlines['final_report'])} (14d)")
|
|
132
|
+
write()
|
|
133
|
+
|
|
134
|
+
if running:
|
|
135
|
+
write(style("WHAT THE EARLY WARNING MUST CARRY", "bold"))
|
|
136
|
+
fields = early_warning_fields(running[0], assessment)
|
|
137
|
+
width = max(len(k) for k in fields)
|
|
138
|
+
for key, value in fields.items():
|
|
139
|
+
write(f" {key.replace('_', ' ').ljust(width)} {value}")
|
|
140
|
+
write()
|
|
141
|
+
|
|
142
|
+
write(
|
|
143
|
+
style(
|
|
144
|
+
"This is an exposure indicator, not a conformity assessment and not "
|
|
145
|
+
"legal advice.\nIt cannot tell whether vulnerable code is reachable in "
|
|
146
|
+
"your product. That judgement\nis yours.",
|
|
147
|
+
"dim",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
write()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_json(assessment: Assessment, stream=sys.stdout) -> None:
|
|
154
|
+
payload = {
|
|
155
|
+
"scanned_at": assessment.scanned_at.isoformat(),
|
|
156
|
+
"subject": assessment.document.subject,
|
|
157
|
+
"sbom_format": assessment.document.format,
|
|
158
|
+
"components_checked": assessment.components_checked,
|
|
159
|
+
"verdict": assessment.verdict,
|
|
160
|
+
"counts": {
|
|
161
|
+
"clock_running": len(assessment.clock_running),
|
|
162
|
+
"urgent_review": len(assessment.urgent),
|
|
163
|
+
"monitor": len(assessment.monitor),
|
|
164
|
+
"unresolvable": len(assessment.unresolvable),
|
|
165
|
+
},
|
|
166
|
+
"findings": [
|
|
167
|
+
{
|
|
168
|
+
"tier": f.tier.value,
|
|
169
|
+
"cve": f.primary_cve,
|
|
170
|
+
"all_cves": list(f.cves),
|
|
171
|
+
"advisory": f.vulnerability.id,
|
|
172
|
+
"component": f.component.label,
|
|
173
|
+
"purl": f.component.purl,
|
|
174
|
+
"reason": f.reason,
|
|
175
|
+
"epss": f.epss,
|
|
176
|
+
"kev_date_added": f.kev.date_added if f.kev else None,
|
|
177
|
+
"ransomware_linked": f.kev.ransomware if f.kev else None,
|
|
178
|
+
}
|
|
179
|
+
for f in assessment.findings
|
|
180
|
+
if f.tier is not Tier.MONITOR
|
|
181
|
+
],
|
|
182
|
+
"unresolvable": [c.label for c in assessment.unresolvable],
|
|
183
|
+
"disclaimer": (
|
|
184
|
+
"Exposure indicator only. Not a conformity assessment, not legal "
|
|
185
|
+
"advice, and not a determination that no reporting obligation exists."
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
json.dump(payload, stream, indent=2)
|
|
189
|
+
print(file=stream)
|