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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Scanner service — the only app-layer code that talks to the engine.
|
|
2
|
+
|
|
3
|
+
This module is the single gateway through which CLI commands and the TUI
|
|
4
|
+
invoke the scanner engine (:func:`ecdat_core.cli.run_scan`). It classifies
|
|
5
|
+
target strings, performs scans, and wraps the raw engine result into a
|
|
6
|
+
:class:`ScanVM` view-model that renderers consume.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
- :data:`STAGE_LABELS` — human-readable stage names for progress reporting.
|
|
10
|
+
- :class:`ScanError` — user-facing error with exit code.
|
|
11
|
+
- :class:`Target` — classified scan target (local path or git URL).
|
|
12
|
+
- :func:`classify_target` — classify and validate a raw target string.
|
|
13
|
+
- :class:`ScanOutcome` — the result of a scan operation.
|
|
14
|
+
- :func:`perform_scan` — run a scan and return the outcome.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
from ecdat_core.cli import run_scan
|
|
26
|
+
from ecdat.services.viewmodel import ScanVM, build_scan_vm
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Stage labels — keys correspond to logical scan phases.
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
STAGE_LABELS: dict[str, str] = {
|
|
33
|
+
"clone": "Cloning repository",
|
|
34
|
+
"scan": "Scanning files",
|
|
35
|
+
"detect": "Detecting cryptographic artefacts",
|
|
36
|
+
"assess": "Assessing quantum risk",
|
|
37
|
+
"recommend": "Generating recommendations",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Errors
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
_GIT_UNSUPPORTED_MSG = (
|
|
45
|
+
"Only https:// Git URLs are supported for direct scanning. "
|
|
46
|
+
"For other URL schemes, clone the repository yourself and scan "
|
|
47
|
+
"the local folder instead."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ScanError(Exception):
|
|
52
|
+
"""A user-facing scan error with an exit code.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
user_message: Human-readable error description.
|
|
56
|
+
exit_code: Process exit code (2 = usage/validation, 3 = runtime).
|
|
57
|
+
hint: Optional remediation hint, e.g. "Try cloning the repo manually."
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
user_message: str,
|
|
63
|
+
*,
|
|
64
|
+
exit_code: int = 3,
|
|
65
|
+
hint: Optional[str] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
super().__init__(user_message)
|
|
68
|
+
self.user_message = user_message
|
|
69
|
+
self.exit_code = exit_code
|
|
70
|
+
self.hint = hint
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Target classification
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Target:
|
|
80
|
+
"""A classified and validated scan target."""
|
|
81
|
+
|
|
82
|
+
__slots__ = ("kind", "value", "display")
|
|
83
|
+
|
|
84
|
+
kind: str # "local" or "git"
|
|
85
|
+
value: str # Resolved path or URL
|
|
86
|
+
display: str # Original user-facing label
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def classify_target(
|
|
90
|
+
raw: str,
|
|
91
|
+
*,
|
|
92
|
+
force_git: bool = False,
|
|
93
|
+
) -> Target:
|
|
94
|
+
"""Classify a raw target string as a local path or git URL.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
raw: The raw target string from user input (may have leading/trailing
|
|
98
|
+
whitespace).
|
|
99
|
+
force_git: When ``True``, treat *raw* as a git URL regardless of its
|
|
100
|
+
format.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A :class:`Target` with kind, resolved value, and display label.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ScanError: exit_code=2 for invalid or unsupported targets (empty
|
|
107
|
+
string, unsupported URL scheme, non-existent path, file-not-dir).
|
|
108
|
+
"""
|
|
109
|
+
raw = raw.strip()
|
|
110
|
+
if not raw:
|
|
111
|
+
raise ScanError(
|
|
112
|
+
"No scan target provided. Specify a local directory path or a "
|
|
113
|
+
"https:// Git URL.",
|
|
114
|
+
exit_code=2,
|
|
115
|
+
hint="Usage: ecdat scan <path-or-url>",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if force_git:
|
|
119
|
+
return _classify_git(raw)
|
|
120
|
+
|
|
121
|
+
# Heuristic: if it looks like a URL (has "://") or an scp-style git
|
|
122
|
+
# address ("git@host:…"), classify as git. Windows drive paths (C:\\…)
|
|
123
|
+
# and plain relative/absolute paths fall through to local.
|
|
124
|
+
if "://" in raw or raw.startswith("git@"):
|
|
125
|
+
return _classify_git(raw)
|
|
126
|
+
|
|
127
|
+
return _classify_local(raw)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _classify_git(raw: str) -> Target:
|
|
131
|
+
"""Classify and validate a git URL target.
|
|
132
|
+
|
|
133
|
+
Only ``https://`` URLs are accepted. Everything else — ``http://``,
|
|
134
|
+
``ssh://``, ``git://``, ``file://``, ``git@…`` scp-style — is rejected
|
|
135
|
+
with exit code 2.
|
|
136
|
+
"""
|
|
137
|
+
# git@ scp-style
|
|
138
|
+
if raw.startswith("git@"):
|
|
139
|
+
raise ScanError(
|
|
140
|
+
_GIT_UNSUPPORTED_MSG,
|
|
141
|
+
exit_code=2,
|
|
142
|
+
hint=f"Try: git clone {raw} && ecdat scan <repo-dir>",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
parsed = urlparse(raw)
|
|
146
|
+
scheme = parsed.scheme.lower()
|
|
147
|
+
|
|
148
|
+
if scheme == "https":
|
|
149
|
+
return Target(kind="git", value=raw, display=raw)
|
|
150
|
+
|
|
151
|
+
# Anything else with a scheme or weird format — reject.
|
|
152
|
+
raise ScanError(
|
|
153
|
+
_GIT_UNSUPPORTED_MSG,
|
|
154
|
+
exit_code=2,
|
|
155
|
+
hint=f"Try: git clone {raw} && ecdat scan <repo-dir>",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _classify_local(raw: str) -> Target:
|
|
160
|
+
"""Classify and validate a local directory path target."""
|
|
161
|
+
path = Path(raw).expanduser().resolve()
|
|
162
|
+
|
|
163
|
+
if not path.exists():
|
|
164
|
+
raise ScanError(
|
|
165
|
+
f"Path does not exist: {raw}",
|
|
166
|
+
exit_code=2,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not path.is_dir():
|
|
170
|
+
raise ScanError(
|
|
171
|
+
f"Not a directory: {raw}",
|
|
172
|
+
exit_code=2,
|
|
173
|
+
hint="Specify a directory to scan, not a single file.",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return Target(kind="local", value=str(path), display=raw)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Scan execution
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class ScanOutcome:
|
|
186
|
+
"""The result of a scan operation.
|
|
187
|
+
|
|
188
|
+
Attributes:
|
|
189
|
+
result: The raw engine ``ScanResult`` returned by ``run_scan``.
|
|
190
|
+
vm: The renderer-ready :class:`ScanVM` view-model.
|
|
191
|
+
duration_s: Wall-clock scan duration in seconds.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
__slots__ = ("result", "vm", "duration_s")
|
|
195
|
+
|
|
196
|
+
result: "ScanResult"
|
|
197
|
+
vm: ScanVM
|
|
198
|
+
duration_s: float
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def perform_scan(
|
|
202
|
+
target: Target,
|
|
203
|
+
*,
|
|
204
|
+
force_git: bool = False,
|
|
205
|
+
label: Optional[str] = None,
|
|
206
|
+
) -> ScanOutcome:
|
|
207
|
+
"""Run a scan against an already-classified :class:`Target`.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
target: The classified scan target.
|
|
211
|
+
force_git: Unused — the target is already classified. Accepted for
|
|
212
|
+
signature compatibility with the CLI layer.
|
|
213
|
+
label: Optional display label for the scan VM. Falls back to
|
|
214
|
+
``target.display`` when ``None``.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
A :class:`ScanOutcome` bundling the engine result, view-model, and
|
|
218
|
+
elapsed time.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ScanError: exit_code=2 for git-URL validation errors (the engine's
|
|
222
|
+
``validate_git_url`` raises ``ValueError``, which is mapped to a
|
|
223
|
+
usage error). exit_code=3 for any other unexpected failure.
|
|
224
|
+
KeyboardInterrupt: Propagated directly — never caught.
|
|
225
|
+
"""
|
|
226
|
+
start = time.monotonic()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
if target.kind == "git":
|
|
230
|
+
result = run_scan(target.value, is_git_url=True)
|
|
231
|
+
else:
|
|
232
|
+
result = run_scan(target.value, sandboxed=False)
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
# Engine validation errors from git-URL checks (validate_git_url in
|
|
235
|
+
# ingestion.py raises ValueError) — these are usage errors.
|
|
236
|
+
raise ScanError(str(exc), exit_code=2) from exc
|
|
237
|
+
except KeyboardInterrupt:
|
|
238
|
+
raise
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
raise ScanError(
|
|
241
|
+
f"Scan failed: {type(exc).__name__}: {exc}",
|
|
242
|
+
exit_code=3,
|
|
243
|
+
) from exc
|
|
244
|
+
|
|
245
|
+
duration_s = time.monotonic() - start
|
|
246
|
+
vm = build_scan_vm(result, target=label or target.display, duration_s=duration_s)
|
|
247
|
+
|
|
248
|
+
return ScanOutcome(result=result, vm=vm, duration_s=duration_s)
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Frozen ViewModel dataclasses for the ECDAT app layer.
|
|
2
|
+
|
|
3
|
+
These are the ONLY types that renderers, exporters, and the TUI consume.
|
|
4
|
+
Engine models (:mod:`ecdat_core.models`) are touched in exactly one place:
|
|
5
|
+
:func:`build_scan_vm`. Raw strings are stored unchanged — escaping is the
|
|
6
|
+
renderers' job.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ecdat_core.models import Detection, Recommendation, RiskAssessment, ScanResult
|
|
16
|
+
|
|
17
|
+
# Risk-level sort order (lower index = worse / more severe).
|
|
18
|
+
_RISK_ORDER: dict[str, int] = {
|
|
19
|
+
"critical": 0,
|
|
20
|
+
"high": 1,
|
|
21
|
+
"medium": 2,
|
|
22
|
+
"low": 3,
|
|
23
|
+
"quantum-safe": 4,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class FindingVM:
|
|
29
|
+
"""A single finding view-model, joining Detection + RiskAssessment + Recommendation.
|
|
30
|
+
|
|
31
|
+
Every field is derived from the engine models — nothing is invented.
|
|
32
|
+
Where a risk assessment or recommendation is missing (shouldn't happen in
|
|
33
|
+
normal operation), sensible defaults are used.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"id",
|
|
38
|
+
"risk_level",
|
|
39
|
+
"algorithm",
|
|
40
|
+
"family",
|
|
41
|
+
"file_path",
|
|
42
|
+
"line",
|
|
43
|
+
"language",
|
|
44
|
+
"confidence",
|
|
45
|
+
"quantum_vulnerable",
|
|
46
|
+
"classically_broken",
|
|
47
|
+
"urgency_ratio",
|
|
48
|
+
"mosca_x",
|
|
49
|
+
"mosca_y",
|
|
50
|
+
"mosca_z",
|
|
51
|
+
"mosca_violation",
|
|
52
|
+
"rationale",
|
|
53
|
+
"recommended",
|
|
54
|
+
"fips_reference",
|
|
55
|
+
"snippet",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
risk_level: str # critical | high | medium | low | quantum-safe
|
|
60
|
+
algorithm: str # Most specific display name (e.g. "RSA-2048"), else family
|
|
61
|
+
family: str # algorithm_family from Detection
|
|
62
|
+
file_path: str # Forward slashes, no leading "./"
|
|
63
|
+
line: int # 1-based line number
|
|
64
|
+
language: str
|
|
65
|
+
confidence: float # 0..1
|
|
66
|
+
quantum_vulnerable: bool
|
|
67
|
+
classically_broken: bool
|
|
68
|
+
urgency_ratio: float
|
|
69
|
+
mosca_x: float # migration_time_years
|
|
70
|
+
mosca_y: float # shelf_life_years
|
|
71
|
+
mosca_z: float # threat_horizon_years
|
|
72
|
+
mosca_violation: bool
|
|
73
|
+
rationale: str
|
|
74
|
+
recommended: str # recommended_algorithm
|
|
75
|
+
fips_reference: str
|
|
76
|
+
snippet: Optional[str] # ≤6 lines / ≤400 chars, or None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class PriorityAction:
|
|
81
|
+
"""A grouped priority action — one per (family, file_path).
|
|
82
|
+
|
|
83
|
+
Excludes quantum-safe findings. Groups by (family, file_path), ranked by
|
|
84
|
+
worst risk → max urgency → count.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
__slots__ = ("family", "file_path", "worst_risk", "count", "recommended")
|
|
88
|
+
|
|
89
|
+
family: str
|
|
90
|
+
file_path: str
|
|
91
|
+
worst_risk: str # The most severe risk_level in the group
|
|
92
|
+
count: int # Number of findings in this group
|
|
93
|
+
recommended: str # Representative recommendation algorithm
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ScanVM:
|
|
98
|
+
"""Complete scan view-model: aggregates all findings into renderer-ready form.
|
|
99
|
+
|
|
100
|
+
``counts`` ALWAYS has all five keys ("critical", "high", "medium", "low",
|
|
101
|
+
"quantum-safe"), even when some counts are zero.
|
|
102
|
+
``safe_ratio`` is ``quantum-safe / total``; ``0.0`` when ``total == 0``.
|
|
103
|
+
``priority_actions`` holds at most 10 grouped actions, excluding
|
|
104
|
+
quantum-safe findings.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
__slots__ = (
|
|
108
|
+
"target",
|
|
109
|
+
"duration_s",
|
|
110
|
+
"files_scanned",
|
|
111
|
+
"findings",
|
|
112
|
+
"counts",
|
|
113
|
+
"total",
|
|
114
|
+
"safe_ratio",
|
|
115
|
+
"by_file",
|
|
116
|
+
"by_recommendation",
|
|
117
|
+
"priority_actions",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
target: str
|
|
121
|
+
duration_s: Optional[float]
|
|
122
|
+
files_scanned: int
|
|
123
|
+
findings: list[FindingVM] # Sorted: severity → urgency desc → file → line
|
|
124
|
+
counts: dict[str, int] # ALWAYS all five keys
|
|
125
|
+
total: int
|
|
126
|
+
safe_ratio: float
|
|
127
|
+
by_file: dict[str, list[FindingVM]]
|
|
128
|
+
by_recommendation: dict[str, list[FindingVM]]
|
|
129
|
+
priority_actions: list[PriorityAction] # ≤10
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Public API
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_scan_vm(
|
|
138
|
+
result: ScanResult,
|
|
139
|
+
*,
|
|
140
|
+
target: str,
|
|
141
|
+
duration_s: Optional[float] = None,
|
|
142
|
+
) -> ScanVM:
|
|
143
|
+
"""Build a :class:`ScanVM` from an engine :class:`~ecdat_core.models.ScanResult`.
|
|
144
|
+
|
|
145
|
+
Joins detections ↔ risk assessments ↔ recommendations exactly like
|
|
146
|
+
:func:`ecdat_core.cbom_export.export_cbom` does. Raw strings are stored
|
|
147
|
+
unchanged — escaping is the renderers' job.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
result: The complete scan result from the engine.
|
|
151
|
+
target: Display label for the scan (e.g. path or URL).
|
|
152
|
+
duration_s: Wall-clock duration of the scan, if available.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
A fully populated :class:`ScanVM`.
|
|
156
|
+
"""
|
|
157
|
+
risk_lookup: dict[str, RiskAssessment] = {
|
|
158
|
+
ra.detection_id: ra for ra in result.risk_assessments
|
|
159
|
+
}
|
|
160
|
+
rec_lookup: dict[str, Recommendation] = {
|
|
161
|
+
rec.detection_id: rec for rec in result.recommendations
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
findings: list[FindingVM] = []
|
|
165
|
+
for det in result.detections:
|
|
166
|
+
ra = risk_lookup.get(det.id)
|
|
167
|
+
rec = rec_lookup.get(det.id)
|
|
168
|
+
findings.append(_build_finding(det, ra, rec))
|
|
169
|
+
|
|
170
|
+
# Sort: severity (risk_level order), then urgency descending, then file,
|
|
171
|
+
# then line — deterministic tie-breaking.
|
|
172
|
+
findings.sort(
|
|
173
|
+
key=lambda f: (
|
|
174
|
+
_RISK_ORDER.get(f.risk_level, 99),
|
|
175
|
+
-f.urgency_ratio,
|
|
176
|
+
f.file_path,
|
|
177
|
+
f.line,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Counts — ALWAYS all five keys.
|
|
182
|
+
counts: dict[str, int] = {
|
|
183
|
+
"critical": 0,
|
|
184
|
+
"high": 0,
|
|
185
|
+
"medium": 0,
|
|
186
|
+
"low": 0,
|
|
187
|
+
"quantum-safe": 0,
|
|
188
|
+
}
|
|
189
|
+
for f in findings:
|
|
190
|
+
counts[f.risk_level] = counts.get(f.risk_level, 0) + 1
|
|
191
|
+
|
|
192
|
+
total = len(findings)
|
|
193
|
+
quantum_safe_count = counts.get("quantum-safe", 0)
|
|
194
|
+
safe_ratio = (quantum_safe_count / total) if total > 0 else 0.0
|
|
195
|
+
|
|
196
|
+
# Group by file.
|
|
197
|
+
by_file: dict[str, list[FindingVM]] = defaultdict(list)
|
|
198
|
+
for f in findings:
|
|
199
|
+
by_file[f.file_path].append(f)
|
|
200
|
+
|
|
201
|
+
# Group by recommendation.
|
|
202
|
+
by_recommendation: dict[str, list[FindingVM]] = defaultdict(list)
|
|
203
|
+
for f in findings:
|
|
204
|
+
key = f.recommended or "(none)"
|
|
205
|
+
by_recommendation[key].append(f)
|
|
206
|
+
|
|
207
|
+
priority_actions = _build_priority_actions(findings)
|
|
208
|
+
|
|
209
|
+
return ScanVM(
|
|
210
|
+
target=target,
|
|
211
|
+
duration_s=duration_s,
|
|
212
|
+
files_scanned=result.files_scanned,
|
|
213
|
+
findings=findings,
|
|
214
|
+
counts=counts,
|
|
215
|
+
total=total,
|
|
216
|
+
safe_ratio=safe_ratio,
|
|
217
|
+
by_file=dict(by_file),
|
|
218
|
+
by_recommendation=dict(by_recommendation),
|
|
219
|
+
priority_actions=priority_actions,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Private helpers
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _build_finding(
|
|
229
|
+
det: Detection,
|
|
230
|
+
ra: Optional[RiskAssessment],
|
|
231
|
+
rec: Optional[Recommendation],
|
|
232
|
+
) -> FindingVM:
|
|
233
|
+
"""Build a single :class:`FindingVM` from engine model instances."""
|
|
234
|
+
# Most specific display name: "RSA-2048" when key size is known, else the
|
|
235
|
+
# algorithm family name.
|
|
236
|
+
if det.key_size_bits is not None:
|
|
237
|
+
algorithm = f"{det.algorithm_family}-{det.key_size_bits}"
|
|
238
|
+
else:
|
|
239
|
+
algorithm = det.algorithm_family
|
|
240
|
+
|
|
241
|
+
# Normalise the file path: forward slashes, strip leading "./".
|
|
242
|
+
file_path = det.file_path.replace("\\", "/")
|
|
243
|
+
if file_path.startswith("./"):
|
|
244
|
+
file_path = file_path[2:]
|
|
245
|
+
|
|
246
|
+
# Snippet: matched text, capped at 6 lines / 400 chars.
|
|
247
|
+
matched = det.matched_text
|
|
248
|
+
if matched:
|
|
249
|
+
lines = matched.split("\n")
|
|
250
|
+
if len(lines) > 6:
|
|
251
|
+
snippet = "\n".join(lines[:6])
|
|
252
|
+
else:
|
|
253
|
+
snippet = matched
|
|
254
|
+
if len(snippet) > 400:
|
|
255
|
+
snippet = snippet[:400]
|
|
256
|
+
else:
|
|
257
|
+
snippet = None
|
|
258
|
+
|
|
259
|
+
return FindingVM(
|
|
260
|
+
id=det.id,
|
|
261
|
+
risk_level=ra.risk_level if ra else "quantum-safe",
|
|
262
|
+
algorithm=algorithm,
|
|
263
|
+
family=det.algorithm_family,
|
|
264
|
+
file_path=file_path,
|
|
265
|
+
line=det.line_number,
|
|
266
|
+
language=det.language,
|
|
267
|
+
confidence=det.confidence,
|
|
268
|
+
quantum_vulnerable=det.quantum_vulnerable,
|
|
269
|
+
classically_broken=det.classically_broken,
|
|
270
|
+
urgency_ratio=ra.urgency_ratio if ra else 0.0,
|
|
271
|
+
mosca_x=ra.migration_time_years if ra else 0.0,
|
|
272
|
+
mosca_y=ra.shelf_life_years if ra else 0.0,
|
|
273
|
+
mosca_z=ra.threat_horizon_years if ra else 0.0,
|
|
274
|
+
mosca_violation=ra.mosca_violation if ra else False,
|
|
275
|
+
rationale=rec.rationale if rec else "",
|
|
276
|
+
recommended=rec.recommended_algorithm if rec else "",
|
|
277
|
+
fips_reference=rec.fips_reference if rec else "",
|
|
278
|
+
snippet=snippet,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _build_priority_actions(
|
|
283
|
+
findings: list[FindingVM],
|
|
284
|
+
) -> list[PriorityAction]:
|
|
285
|
+
"""Group non-quantum-safe findings into priority actions.
|
|
286
|
+
|
|
287
|
+
Groups by ``(family, file_path)``, then ranks by worst risk → max urgency
|
|
288
|
+
→ count. Returns at most 10 actions.
|
|
289
|
+
"""
|
|
290
|
+
groups: dict[tuple[str, str], list[FindingVM]] = defaultdict(list)
|
|
291
|
+
for f in findings:
|
|
292
|
+
if f.risk_level == "quantum-safe":
|
|
293
|
+
continue
|
|
294
|
+
groups[(f.family, f.file_path)].append(f)
|
|
295
|
+
|
|
296
|
+
# Compute per-group data for sorting.
|
|
297
|
+
group_data: list[tuple[str, str, str, int, float, str]] = []
|
|
298
|
+
for (family, file_path), group in groups.items():
|
|
299
|
+
worst_risk = min(
|
|
300
|
+
group, key=lambda f: _RISK_ORDER.get(f.risk_level, 99)
|
|
301
|
+
).risk_level
|
|
302
|
+
max_urgency = max(f.urgency_ratio for f in group)
|
|
303
|
+
recommended = group[0].recommended
|
|
304
|
+
group_data.append(
|
|
305
|
+
(family, file_path, worst_risk, len(group), max_urgency, recommended)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Rank: worst risk first, then highest max_urgency, then largest count.
|
|
309
|
+
group_data.sort(
|
|
310
|
+
key=lambda x: (
|
|
311
|
+
_RISK_ORDER.get(x[2], 99),
|
|
312
|
+
-x[4],
|
|
313
|
+
-x[3],
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return [
|
|
318
|
+
PriorityAction(
|
|
319
|
+
family=family,
|
|
320
|
+
file_path=file_path,
|
|
321
|
+
worst_risk=worst_risk,
|
|
322
|
+
count=count,
|
|
323
|
+
recommended=recommended,
|
|
324
|
+
)
|
|
325
|
+
for family, file_path, worst_risk, count, _ur, recommended in group_data[:10]
|
|
326
|
+
]
|
ecdat/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""UI sub-package — console factory, theme, renderers, and ASCII art."""
|