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/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 &mdash; 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 &mdash; not from your next scan.</p>"
318
+ f"<p><code>pip install flagrante</code> &nbsp; then &nbsp; "
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 '')} &middot; "
326
+ f"{assessment.components_checked} components checked &middot; "
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)} &mdash; {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)