ecdat 0.2.0__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.
- ecdat/__init__.py +10 -0
- ecdat/__main__.py +5 -0
- ecdat/cli/__init__.py +203 -0
- ecdat/cli/commands/__init__.py +0 -0
- ecdat/cli/commands/about.py +130 -0
- ecdat/cli/commands/demo.py +116 -0
- ecdat/cli/commands/doctor.py +296 -0
- ecdat/cli/commands/help_cmd.py +205 -0
- ecdat/cli/commands/scan.py +228 -0
- ecdat/cli/commands/version_cmd.py +48 -0
- ecdat/cli/parser.py +87 -0
- ecdat/demo_project/auth/login.py +75 -0
- ecdat/demo_project/certs/cert_verify.go +81 -0
- ecdat/demo_project/keyexchange/channel.go +48 -0
- ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
- ecdat/demo_project/payments/payment.py +64 -0
- ecdat/demo_project/quantum/pqc_utils.py +67 -0
- ecdat/demo_project/quantum/slh_signer.py +40 -0
- ecdat/demo_project/tokens/signing.js +54 -0
- ecdat/py.typed +0 -0
- ecdat/services/__init__.py +1 -0
- ecdat/services/crashlog.py +109 -0
- ecdat/services/demo.py +85 -0
- ecdat/services/paths.py +52 -0
- ecdat/services/scanner.py +248 -0
- ecdat/services/viewmodel.py +326 -0
- ecdat/ui/__init__.py +1 -0
- ecdat/ui/art3d.py +136 -0
- ecdat/ui/art_static.py +65 -0
- ecdat/ui/art_text.py +81 -0
- ecdat/ui/banner.py +148 -0
- ecdat/ui/console.py +119 -0
- ecdat/ui/motion.py +64 -0
- ecdat/ui/render.py +486 -0
- ecdat/ui/theme.py +173 -0
- ecdat-0.2.0.dist-info/METADATA +142 -0
- ecdat-0.2.0.dist-info/RECORD +51 -0
- ecdat-0.2.0.dist-info/WHEEL +5 -0
- ecdat-0.2.0.dist-info/entry_points.txt +2 -0
- ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
- ecdat-0.2.0.dist-info/top_level.txt +2 -0
- ecdat_core/__init__.py +6 -0
- ecdat_core/cbom_export.py +287 -0
- ecdat_core/cli.py +202 -0
- ecdat_core/detector.py +273 -0
- ecdat_core/ingestion.py +581 -0
- ecdat_core/models.py +145 -0
- ecdat_core/recommender.py +74 -0
- ecdat_core/risk_engine.py +264 -0
- ecdat_core/signature_loader.py +204 -0
- ecdat_core/signatures.json +692 -0
ecdat/ui/render.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Rich report renderers for ECDAT scan results.
|
|
2
|
+
|
|
3
|
+
Every renderer consumes :class:`~ecdat.services.viewmodel.ScanVM` and
|
|
4
|
+
:class:`~ecdat.services.viewmodel.FindingVM` — never raw engine models.
|
|
5
|
+
ALL dynamic values (file paths, snippets, algorithm names, rationales) are
|
|
6
|
+
wrapped in :class:`rich.text.Text` — they are NEVER interpolated into Rich
|
|
7
|
+
markup strings because they come from attacker-controlled scanned repos.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from rich.console import Group, RenderableType
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.style import Style
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from ecdat.services.viewmodel import FindingVM, ScanVM
|
|
21
|
+
from ecdat.ui.art_static import emblem_for, headline_for, verdict
|
|
22
|
+
from ecdat.ui.theme import (
|
|
23
|
+
MINT_GRADIENT,
|
|
24
|
+
PALETTE,
|
|
25
|
+
RISK_COLORS,
|
|
26
|
+
RISK_LABELS,
|
|
27
|
+
RISK_ORDER,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Character sets
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
_UNICODE_EIGHTHS = "\u258f\u258e\u258d\u258c\u258b\u258a\u2589\u2588" # ▏▎▍▌▋▊▉█
|
|
35
|
+
"""Eighth-block characters, index 0 = 1/8, index 7 = 8/8 (full block)."""
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Helpers
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe(value: str) -> Text:
|
|
43
|
+
"""Wrap a string in a plain :class:`~rich.text.Text` — no markup parsing."""
|
|
44
|
+
return Text(value, style=Style())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ellipsis_path(path: str, line: int | None, width: int = 50) -> str:
|
|
48
|
+
"""Ellipsise a file path in the middle, preserving the filename.
|
|
49
|
+
|
|
50
|
+
``"very/long/path/to/some/file.py:123"`` →
|
|
51
|
+
``"very/lo…/some/file.py:123"`` (or similar).
|
|
52
|
+
"""
|
|
53
|
+
suffix = f":{line}" if line is not None else ""
|
|
54
|
+
full = f"{path}{suffix}"
|
|
55
|
+
if len(full) <= width:
|
|
56
|
+
return full
|
|
57
|
+
|
|
58
|
+
# Keep filename + preceding segment on the right, prefix on the left.
|
|
59
|
+
parts = path.split("/")
|
|
60
|
+
if len(parts) <= 2:
|
|
61
|
+
# Can't really ellipsise — truncate right.
|
|
62
|
+
return full[: width - 1] + "\u2026"
|
|
63
|
+
|
|
64
|
+
# Keep last 2 segments on the right.
|
|
65
|
+
right = "/".join(parts[-2:]) + suffix
|
|
66
|
+
left_budget = width - len(right) - 1 # -1 for the ellipsis sigil
|
|
67
|
+
if left_budget < 3:
|
|
68
|
+
return f"\u2026{right}"[:width]
|
|
69
|
+
|
|
70
|
+
left = parts[0]
|
|
71
|
+
i = 1
|
|
72
|
+
while i < len(parts) - 2 and len(left) + 1 + len(parts[i]) <= left_budget:
|
|
73
|
+
left = f"{left}/{parts[i]}"
|
|
74
|
+
i += 1
|
|
75
|
+
|
|
76
|
+
return f"{left}\u2026/{right}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _flag_text(finding: FindingVM) -> str:
|
|
80
|
+
"""Return flag letters for a finding: ``Q`` = quantum-vulnerable, ``!`` = classically broken."""
|
|
81
|
+
flags = []
|
|
82
|
+
if finding.quantum_vulnerable:
|
|
83
|
+
flags.append("Q")
|
|
84
|
+
if finding.classically_broken:
|
|
85
|
+
flags.append("!")
|
|
86
|
+
return " ".join(flags) if flags else "\u2014"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Public renderers
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def smooth_bar(
|
|
95
|
+
fraction: float,
|
|
96
|
+
width: int,
|
|
97
|
+
*,
|
|
98
|
+
unicode: bool = True,
|
|
99
|
+
) -> Text:
|
|
100
|
+
"""Render a horizontal progress bar with eighth-block precision.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
fraction: Fill ratio, clamped to ``[0, 1]``.
|
|
104
|
+
width: Total bar width in cells.
|
|
105
|
+
unicode: Use Unicode eighth-blocks (``True``) or ASCII ``#``/``-``.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A :class:`~rich.text.Text` of exactly *width* cells.
|
|
109
|
+
"""
|
|
110
|
+
fraction = max(0.0, min(1.0, fraction))
|
|
111
|
+
width = max(1, width)
|
|
112
|
+
|
|
113
|
+
filled = fraction * width
|
|
114
|
+
full_cells = int(filled)
|
|
115
|
+
frac_part = filled - full_cells
|
|
116
|
+
|
|
117
|
+
result = Text()
|
|
118
|
+
accent_style = Style(color=PALETTE.accent)
|
|
119
|
+
muted_style = Style(color=PALETTE.muted)
|
|
120
|
+
|
|
121
|
+
for i in range(width):
|
|
122
|
+
if i < full_cells:
|
|
123
|
+
if unicode:
|
|
124
|
+
result.append("\u2588", style=accent_style) # full block
|
|
125
|
+
else:
|
|
126
|
+
result.append("#", style=accent_style)
|
|
127
|
+
elif i == full_cells and frac_part > 0:
|
|
128
|
+
eig_idx = min(7, int(frac_part * 8))
|
|
129
|
+
if unicode and eig_idx > 0:
|
|
130
|
+
result.append(_UNICODE_EIGHTHS[eig_idx - 1], style=accent_style)
|
|
131
|
+
elif unicode:
|
|
132
|
+
result.append(" ", style=muted_style)
|
|
133
|
+
else:
|
|
134
|
+
result.append("-", style=muted_style)
|
|
135
|
+
else:
|
|
136
|
+
if unicode:
|
|
137
|
+
result.append(" ", style=muted_style)
|
|
138
|
+
else:
|
|
139
|
+
result.append("-", style=muted_style)
|
|
140
|
+
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def risk_chip(level: str) -> Text:
|
|
145
|
+
"""Return a short coloured label for *level* (e.g. ``"CRITICAL"``).
|
|
146
|
+
|
|
147
|
+
Uses :data:`~ecdat.ui.theme.RISK_COLORS` and
|
|
148
|
+
:data:`~ecdat.ui.theme.RISK_LABELS`.
|
|
149
|
+
"""
|
|
150
|
+
color = RISK_COLORS.get(level, PALETTE.text)
|
|
151
|
+
label = RISK_LABELS.get(level, level.upper())
|
|
152
|
+
return Text(label, style=Style(color=color, bold=True))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def summary_panel(vm: ScanVM) -> Panel:
|
|
156
|
+
"""Render the scan summary panel with padlock emblem and headline."""
|
|
157
|
+
v = verdict(vm.counts)
|
|
158
|
+
emblem = emblem_for(v)
|
|
159
|
+
headline = headline_for(vm.counts, vm.files_scanned)
|
|
160
|
+
|
|
161
|
+
# Compute safe percentage.
|
|
162
|
+
safe_pct = round(vm.safe_ratio * 100)
|
|
163
|
+
|
|
164
|
+
# Build the right-side content.
|
|
165
|
+
right = Text()
|
|
166
|
+
hl = _safe(headline)
|
|
167
|
+
hl.stylize(Style(bold=True))
|
|
168
|
+
right.append(hl)
|
|
169
|
+
right.append("\n\n")
|
|
170
|
+
|
|
171
|
+
# Target.
|
|
172
|
+
right.append("Target ", style=Style(color=PALETTE.muted))
|
|
173
|
+
right.append(_safe(vm.target))
|
|
174
|
+
right.append("\n")
|
|
175
|
+
|
|
176
|
+
# Duration.
|
|
177
|
+
dur = f"{vm.duration_s:.1f}s" if vm.duration_s is not None else "N/A"
|
|
178
|
+
right.append("Duration ", style=Style(color=PALETTE.muted))
|
|
179
|
+
right.append(_safe(dur))
|
|
180
|
+
right.append("\n")
|
|
181
|
+
|
|
182
|
+
# Files scanned.
|
|
183
|
+
right.append("Files ", style=Style(color=PALETTE.muted))
|
|
184
|
+
right.append(_safe(str(vm.files_scanned)))
|
|
185
|
+
right.append("\n")
|
|
186
|
+
|
|
187
|
+
# Quantum-safe percentage.
|
|
188
|
+
right.append("Safe ", style=Style(color=PALETTE.muted))
|
|
189
|
+
right.append(f"{safe_pct}%", style=Style(color=PALETTE.safe, bold=True))
|
|
190
|
+
|
|
191
|
+
# Build the emblem as left-side content.
|
|
192
|
+
left_text = Text()
|
|
193
|
+
for i, row in enumerate(emblem):
|
|
194
|
+
if i > 0:
|
|
195
|
+
left_text.append("\n")
|
|
196
|
+
# Colour the emblem.
|
|
197
|
+
color = {"crit": PALETTE.critical, "warn": PALETTE.high}.get(
|
|
198
|
+
v, PALETTE.safe
|
|
199
|
+
)
|
|
200
|
+
left_text.append(row, style=Style(color=color, bold=True))
|
|
201
|
+
|
|
202
|
+
# Build layout with columns.
|
|
203
|
+
content = Table.grid(padding=(0, 2))
|
|
204
|
+
content.add_column(justify="left", vertical="top")
|
|
205
|
+
content.add_column(justify="left", vertical="top")
|
|
206
|
+
content.add_row(left_text, right)
|
|
207
|
+
|
|
208
|
+
return Panel(
|
|
209
|
+
content,
|
|
210
|
+
title="Scan Summary",
|
|
211
|
+
title_align="left",
|
|
212
|
+
border_style=PALETTE.border,
|
|
213
|
+
padding=(1, 2),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def risk_bars(vm: ScanVM) -> RenderableType:
|
|
218
|
+
"""Render a horizontal stacked risk-distribution bar.
|
|
219
|
+
|
|
220
|
+
Each risk level gets a segment proportional to its count. Returns a
|
|
221
|
+
:class:`~rich.table.Table` showing a stacked bar and a legend row.
|
|
222
|
+
"""
|
|
223
|
+
total = vm.total
|
|
224
|
+
grid = Table.grid(padding=(0, 1))
|
|
225
|
+
grid.add_column(justify="left")
|
|
226
|
+
|
|
227
|
+
# Stacked bar row.
|
|
228
|
+
if total == 0:
|
|
229
|
+
bar_text = Text(" " * 40, style=Style(color=PALETTE.muted, bgcolor=PALETTE.surface))
|
|
230
|
+
grid.add_row(bar_text)
|
|
231
|
+
else:
|
|
232
|
+
bar = Text()
|
|
233
|
+
for level in RISK_ORDER:
|
|
234
|
+
count = vm.counts.get(level, 0)
|
|
235
|
+
if count == 0:
|
|
236
|
+
continue
|
|
237
|
+
seg_width = max(1, round(count / total * 40))
|
|
238
|
+
color = RISK_COLORS.get(level, PALETTE.muted)
|
|
239
|
+
bar.append("\u2588" * seg_width, style=Style(color=color, bgcolor=color))
|
|
240
|
+
# Pad to exactly 40 cells.
|
|
241
|
+
current = len(bar.plain)
|
|
242
|
+
if current < 40:
|
|
243
|
+
bar.append(" " * (40 - current))
|
|
244
|
+
grid.add_row(bar)
|
|
245
|
+
|
|
246
|
+
# Legend row.
|
|
247
|
+
legend_parts: list[Text] = []
|
|
248
|
+
for level in RISK_ORDER:
|
|
249
|
+
count = vm.counts.get(level, 0)
|
|
250
|
+
color = RISK_COLORS.get(level, PALETTE.muted)
|
|
251
|
+
label = RISK_LABELS.get(level, level)
|
|
252
|
+
part = Text(f" {label}:{count} ", style=Style(color=color))
|
|
253
|
+
legend_parts.append(part)
|
|
254
|
+
|
|
255
|
+
legend_row = Text()
|
|
256
|
+
for i, part in enumerate(legend_parts):
|
|
257
|
+
if i > 0:
|
|
258
|
+
legend_row.append(" ")
|
|
259
|
+
legend_row.append(part)
|
|
260
|
+
grid.add_row(legend_row)
|
|
261
|
+
|
|
262
|
+
return grid
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def findings_table(vm: ScanVM, *, limit: int = 15) -> Table:
|
|
266
|
+
"""Render the findings table.
|
|
267
|
+
|
|
268
|
+
Columns: Risk, Algorithm, Location (file:line ellipsised), Conf %, Flags.
|
|
269
|
+
Shows at most *limit* rows; a ``"+N more"`` row is appended when truncated.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
vm: The scan view-model.
|
|
273
|
+
limit: Maximum findings to display.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
A :class:`~rich.table.Table`.
|
|
277
|
+
"""
|
|
278
|
+
table = Table(
|
|
279
|
+
title="Findings",
|
|
280
|
+
title_style=f"bold {PALETTE.accent}",
|
|
281
|
+
border_style=PALETTE.border,
|
|
282
|
+
header_style=Style(color=PALETTE.muted, bold=True),
|
|
283
|
+
expand=False,
|
|
284
|
+
pad_edge=True,
|
|
285
|
+
)
|
|
286
|
+
table.add_column("Risk", style="bold", no_wrap=True)
|
|
287
|
+
table.add_column("Algorithm", no_wrap=True)
|
|
288
|
+
table.add_column("Location", no_wrap=True)
|
|
289
|
+
table.add_column("Conf %", justify="right", no_wrap=True)
|
|
290
|
+
table.add_column("Flags", no_wrap=True)
|
|
291
|
+
|
|
292
|
+
displayed = vm.findings[:limit]
|
|
293
|
+
for f in displayed:
|
|
294
|
+
loc = _ellipsis_path(f.file_path, f.line)
|
|
295
|
+
conf = f"{f.confidence * 100:.0f}%"
|
|
296
|
+
flags = _flag_text(f)
|
|
297
|
+
|
|
298
|
+
# Risk chip.
|
|
299
|
+
risk_cell = risk_chip(f.risk_level)
|
|
300
|
+
|
|
301
|
+
table.add_row(
|
|
302
|
+
risk_cell,
|
|
303
|
+
_safe(f.algorithm),
|
|
304
|
+
_safe(loc),
|
|
305
|
+
_safe(conf),
|
|
306
|
+
_safe(flags),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# "+N more" row if truncated.
|
|
310
|
+
remaining = len(vm.findings) - limit
|
|
311
|
+
if remaining > 0:
|
|
312
|
+
more = Text(f"+{remaining} more", style=Style(color=PALETTE.muted, italic=True))
|
|
313
|
+
table.add_row(more, Text(), Text(), Text(), Text())
|
|
314
|
+
|
|
315
|
+
return table
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def priority_actions_panel(vm: ScanVM, *, limit: int = 8) -> Panel:
|
|
319
|
+
"""Render the priority-actions panel.
|
|
320
|
+
|
|
321
|
+
Groups findings by ``(family, file_path)``, ranked worst-first. Shows at
|
|
322
|
+
most *limit* rows.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
vm: The scan view-model.
|
|
326
|
+
limit: Maximum priority actions to display.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
A :class:`~rich.panel.Panel`.
|
|
330
|
+
"""
|
|
331
|
+
actions = vm.priority_actions[:limit]
|
|
332
|
+
|
|
333
|
+
table = Table(
|
|
334
|
+
border_style=PALETTE.border,
|
|
335
|
+
header_style=Style(color=PALETTE.muted, bold=True),
|
|
336
|
+
expand=False,
|
|
337
|
+
pad_edge=False,
|
|
338
|
+
)
|
|
339
|
+
table.add_column("Risk", no_wrap=True)
|
|
340
|
+
table.add_column("Family", no_wrap=True)
|
|
341
|
+
table.add_column("File", no_wrap=False)
|
|
342
|
+
table.add_column("#", justify="right", no_wrap=True)
|
|
343
|
+
table.add_column("Recommend", no_wrap=True)
|
|
344
|
+
|
|
345
|
+
for pa in actions:
|
|
346
|
+
loc = _ellipsis_path(pa.file_path, None, width=40)
|
|
347
|
+
table.add_row(
|
|
348
|
+
risk_chip(pa.worst_risk),
|
|
349
|
+
_safe(pa.family),
|
|
350
|
+
_safe(loc),
|
|
351
|
+
_safe(str(pa.count)),
|
|
352
|
+
_safe(pa.recommended),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if not actions:
|
|
356
|
+
table.add_row(
|
|
357
|
+
Text("No priority actions", style=Style(color=PALETTE.safe, italic=True)),
|
|
358
|
+
Text(), Text(), Text(), Text(),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return Panel(
|
|
362
|
+
table,
|
|
363
|
+
title="Priority Actions",
|
|
364
|
+
title_align="left",
|
|
365
|
+
border_style=PALETTE.border,
|
|
366
|
+
padding=(0, 1),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def recommendations_table(vm: ScanVM) -> Table:
|
|
371
|
+
"""Render the recommendations table (algorithm → PQC replacement).
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
A :class:`~rich.table.Table`.
|
|
375
|
+
"""
|
|
376
|
+
table = Table(
|
|
377
|
+
title="Recommendations",
|
|
378
|
+
title_style=f"bold {PALETTE.accent}",
|
|
379
|
+
border_style=PALETTE.border,
|
|
380
|
+
header_style=Style(color=PALETTE.muted, bold=True),
|
|
381
|
+
expand=False,
|
|
382
|
+
pad_edge=True,
|
|
383
|
+
)
|
|
384
|
+
table.add_column("Algorithm")
|
|
385
|
+
table.add_column("Count", justify="right")
|
|
386
|
+
table.add_column("Recommendation")
|
|
387
|
+
|
|
388
|
+
for rec_name, findings in sorted(vm.by_recommendation.items()):
|
|
389
|
+
if rec_name == "(none)":
|
|
390
|
+
continue
|
|
391
|
+
# Gather unique families that have this recommendation.
|
|
392
|
+
families = sorted({f.family for f in findings})
|
|
393
|
+
table.add_row(
|
|
394
|
+
_safe(", ".join(families)),
|
|
395
|
+
_safe(str(len(findings))),
|
|
396
|
+
_safe(rec_name),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if not table.rows:
|
|
400
|
+
table.add_row(
|
|
401
|
+
Text("All algorithms are quantum-safe", style=Style(color=PALETTE.safe, italic=True)),
|
|
402
|
+
Text(),
|
|
403
|
+
Text(),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return table
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def legend() -> Panel:
|
|
410
|
+
"""Render a legend panel explaining flags, risk levels, and colours.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
A :class:`~rich.panel.Panel`.
|
|
414
|
+
"""
|
|
415
|
+
content = Text()
|
|
416
|
+
content.append("Flags ", style=Style(bold=True))
|
|
417
|
+
content.append("Q = quantum-vulnerable ")
|
|
418
|
+
content.append("! = classically broken\n", style=Style(color=PALETTE.critical))
|
|
419
|
+
content.append("Risk levels: ", style=Style(bold=True))
|
|
420
|
+
for level in RISK_ORDER:
|
|
421
|
+
chip = risk_chip(level)
|
|
422
|
+
content.append(chip)
|
|
423
|
+
content.append(" ")
|
|
424
|
+
|
|
425
|
+
return Panel(
|
|
426
|
+
content,
|
|
427
|
+
title="Legend",
|
|
428
|
+
title_align="left",
|
|
429
|
+
border_style=PALETTE.border,
|
|
430
|
+
padding=(1, 2),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def scan_report(
|
|
435
|
+
vm: ScanVM,
|
|
436
|
+
*,
|
|
437
|
+
limit: int = 15,
|
|
438
|
+
unicode: bool = True,
|
|
439
|
+
) -> Group:
|
|
440
|
+
"""Render the complete scan report as a :class:`~rich.console.Group`.
|
|
441
|
+
|
|
442
|
+
When the scan found no cryptographic artefacts (:attr:`ScanVM.total` == 0),
|
|
443
|
+
a single friendly panel is returned instead of the full layout.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
vm: The scan view-model.
|
|
447
|
+
limit: Maximum findings to display in the table.
|
|
448
|
+
unicode: Use Unicode characters (``True``) or ASCII fallback.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
A :class:`~rich.console.Group` ready for ``console.print()``.
|
|
452
|
+
"""
|
|
453
|
+
# Empty scan — special case.
|
|
454
|
+
if vm.total == 0:
|
|
455
|
+
no_results = Text(
|
|
456
|
+
f"No cryptographic artefacts detected in {vm.files_scanned} files",
|
|
457
|
+
style=Style(color=PALETTE.safe),
|
|
458
|
+
)
|
|
459
|
+
no_results.justify = "center"
|
|
460
|
+
return Group(
|
|
461
|
+
Panel(
|
|
462
|
+
no_results,
|
|
463
|
+
border_style=PALETTE.border,
|
|
464
|
+
padding=(2, 4),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
elements: List[RenderableType] = [
|
|
469
|
+
summary_panel(vm),
|
|
470
|
+
Text(), # spacer
|
|
471
|
+
risk_bars(vm),
|
|
472
|
+
Text(), # spacer
|
|
473
|
+
findings_table(vm, limit=limit),
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
# Priority actions — only if there are non-quantum-safe findings.
|
|
477
|
+
if vm.priority_actions:
|
|
478
|
+
elements.append(Text()) # spacer
|
|
479
|
+
elements.append(priority_actions_panel(vm))
|
|
480
|
+
|
|
481
|
+
elements.append(Text()) # spacer
|
|
482
|
+
elements.append(recommendations_table(vm))
|
|
483
|
+
elements.append(Text()) # spacer
|
|
484
|
+
elements.append(legend())
|
|
485
|
+
|
|
486
|
+
return Group(*elements)
|
ecdat/ui/theme.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Frozen palette, colour utilities, and Rich theme for the ECDAT app layer.
|
|
2
|
+
|
|
3
|
+
Colours are derived from the dashboard design tokens (``frontend/src/index.css``)
|
|
4
|
+
and the shared risk palette (``frontend/src/lib/colors.ts``). All values are
|
|
5
|
+
immutable — palette is a frozen dataclass, and the module-level mappings are tuples
|
|
6
|
+
or frozensets so nothing accidentally mutates the shared state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Tuple
|
|
13
|
+
|
|
14
|
+
from rich.theme import Theme
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Palette
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Palette:
|
|
23
|
+
"""Immutable colour palette derived from the dashboard design tokens.
|
|
24
|
+
|
|
25
|
+
The default values are hard-coded hex colours that match the dashboard's
|
|
26
|
+
dark-only theme. Where the dashboard uses ``oklch(…)`` expressions the
|
|
27
|
+
approximate sRGB hex is given.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
bg: str = "#0b0f0e" # ~dashboard --ecdat-bg #04100b — brightened for terminal
|
|
31
|
+
surface: str = "#111816" # ~dashboard --ecdat-surface #0b1a12 — brightened
|
|
32
|
+
panel: str = "#17211e" # panel background (card on surface)
|
|
33
|
+
border: str = "#26332f" # oklch(1 0 0 / 12%) ≈ #26332f
|
|
34
|
+
text: str = "#dfeae6" # oklch(0.97 0.005 160)
|
|
35
|
+
muted: str = "#8aa39a" # oklch(0.65 0.015 160)
|
|
36
|
+
accent: str = "#3ddc97" # dashboard --ecdat-accent #44e0a4 (softened for terminal)
|
|
37
|
+
|
|
38
|
+
# Risk-level colours — bright terminal-safe hex values distinct from the
|
|
39
|
+
# dashboard's Tailwind-700 shades (which are tuned for WCAG AA on white).
|
|
40
|
+
critical: str = "#ff5d5d"
|
|
41
|
+
high: str = "#ff9a3c"
|
|
42
|
+
medium: str = "#ffd23f"
|
|
43
|
+
low: str = "#4cc3ff"
|
|
44
|
+
safe: str = "#4ade80" # quantum-safe green
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Singleton instance — import this everywhere.
|
|
48
|
+
PALETTE = Palette()
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Risk metadata
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
RISK_ORDER: Tuple[str, ...] = ("critical", "high", "medium", "low", "quantum-safe")
|
|
55
|
+
|
|
56
|
+
RISK_COLORS: dict[str, str] = {
|
|
57
|
+
"critical": PALETTE.critical,
|
|
58
|
+
"high": PALETTE.high,
|
|
59
|
+
"medium": PALETTE.medium,
|
|
60
|
+
"low": PALETTE.low,
|
|
61
|
+
"quantum-safe": PALETTE.safe,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
RISK_LABELS: dict[str, str] = {
|
|
65
|
+
"critical": "CRITICAL",
|
|
66
|
+
"high": "HIGH",
|
|
67
|
+
"medium": "MEDIUM",
|
|
68
|
+
"low": "LOW",
|
|
69
|
+
"quantum-safe": "SAFE",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
RISK_INDEX: dict[str, int] = {level: i for i, level in enumerate(RISK_ORDER)}
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Colour helpers
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
|
|
80
|
+
"""Convert a ``#RRGGBB`` string to an ``(r, g, b)`` tuple (0–255)."""
|
|
81
|
+
hex_color = hex_color.lstrip("#")
|
|
82
|
+
return (
|
|
83
|
+
int(hex_color[0:2], 16),
|
|
84
|
+
int(hex_color[2:4], 16),
|
|
85
|
+
int(hex_color[4:6], 16),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def rgb_to_hex(r: int, g: int, b: int) -> str:
|
|
90
|
+
"""Convert ``(r, g, b)`` (0–255) to a ``#RRGGBB`` string."""
|
|
91
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def lerp_hex(a: str, b: str, t: float) -> str:
|
|
95
|
+
"""Linearly interpolate between two hex colours.
|
|
96
|
+
|
|
97
|
+
``t`` is clamped to ``[0, 1]``. Per-channel linear interpolation in sRGB.
|
|
98
|
+
"""
|
|
99
|
+
t = max(0.0, min(1.0, t))
|
|
100
|
+
ar, ag, ab = hex_to_rgb(a)
|
|
101
|
+
br, bg, bb = hex_to_rgb(b)
|
|
102
|
+
return rgb_to_hex(
|
|
103
|
+
round(ar + (br - ar) * t),
|
|
104
|
+
round(ag + (bg - ag) * t),
|
|
105
|
+
round(ab + (bb - ab) * t),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def gradient(stops: list[str], steps: int) -> list[str]:
|
|
110
|
+
"""Return ``steps`` evenly-spaced colours interpolating through *stops*.
|
|
111
|
+
|
|
112
|
+
The first colour is *stops[0]* and the last is *stops[-1]* exactly.
|
|
113
|
+
Interior stops are placed at evenly-spaced indices; linear interpolation
|
|
114
|
+
fills the gaps between them.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
stops: Two or more hex-colour anchor points.
|
|
118
|
+
steps: Total number of colours to return (must be ≥ 2).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A list of ``steps`` ``#RRGGBB`` strings.
|
|
122
|
+
"""
|
|
123
|
+
if steps < 2:
|
|
124
|
+
return [stops[0]] * steps if steps == 1 else []
|
|
125
|
+
if len(stops) == 1:
|
|
126
|
+
return [stops[0]] * steps
|
|
127
|
+
|
|
128
|
+
n_segments = len(stops) - 1
|
|
129
|
+
result: list[str] = []
|
|
130
|
+
for i in range(steps):
|
|
131
|
+
# Map step index i to a float position in [0, n_segments].
|
|
132
|
+
pos = i * n_segments / (steps - 1)
|
|
133
|
+
seg = min(int(pos), n_segments - 1)
|
|
134
|
+
frac = pos - seg
|
|
135
|
+
result.append(lerp_hex(stops[seg], stops[seg + 1], frac))
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Gradient presets
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
MINT_GRADIENT: list[str] = gradient(
|
|
144
|
+
["#0b3d2e", "#1f9d6b", PALETTE.accent, "#eafff6"],
|
|
145
|
+
12,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Rich theme
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def rich_theme() -> Theme:
|
|
154
|
+
"""Build a :class:`rich.theme.Theme` from the :class:`Palette`.
|
|
155
|
+
|
|
156
|
+
Style names use the ``ecdat.`` prefix so they never clash with Rich's
|
|
157
|
+
built-in styles. Usable as:
|
|
158
|
+
|
|
159
|
+
console = Console(theme=rich_theme())
|
|
160
|
+
console.print("[ecdat.accent]hi[/]")
|
|
161
|
+
"""
|
|
162
|
+
return Theme(
|
|
163
|
+
{
|
|
164
|
+
"ecdat.accent": f"bold {PALETTE.accent}",
|
|
165
|
+
"ecdat.muted": PALETTE.muted,
|
|
166
|
+
"ecdat.title": f"bold {PALETTE.accent}",
|
|
167
|
+
"ecdat.critical": f"bold {PALETTE.critical}",
|
|
168
|
+
"ecdat.high": f"bold {PALETTE.high}",
|
|
169
|
+
"ecdat.medium": PALETTE.medium,
|
|
170
|
+
"ecdat.low": PALETTE.low,
|
|
171
|
+
"ecdat.safe": PALETTE.safe,
|
|
172
|
+
}
|
|
173
|
+
)
|