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.
Files changed (51) hide show
  1. ecdat/__init__.py +10 -0
  2. ecdat/__main__.py +5 -0
  3. ecdat/cli/__init__.py +203 -0
  4. ecdat/cli/commands/__init__.py +0 -0
  5. ecdat/cli/commands/about.py +130 -0
  6. ecdat/cli/commands/demo.py +116 -0
  7. ecdat/cli/commands/doctor.py +296 -0
  8. ecdat/cli/commands/help_cmd.py +205 -0
  9. ecdat/cli/commands/scan.py +228 -0
  10. ecdat/cli/commands/version_cmd.py +48 -0
  11. ecdat/cli/parser.py +87 -0
  12. ecdat/demo_project/auth/login.py +75 -0
  13. ecdat/demo_project/certs/cert_verify.go +81 -0
  14. ecdat/demo_project/keyexchange/channel.go +48 -0
  15. ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
  16. ecdat/demo_project/payments/payment.py +64 -0
  17. ecdat/demo_project/quantum/pqc_utils.py +67 -0
  18. ecdat/demo_project/quantum/slh_signer.py +40 -0
  19. ecdat/demo_project/tokens/signing.js +54 -0
  20. ecdat/py.typed +0 -0
  21. ecdat/services/__init__.py +1 -0
  22. ecdat/services/crashlog.py +109 -0
  23. ecdat/services/demo.py +85 -0
  24. ecdat/services/paths.py +52 -0
  25. ecdat/services/scanner.py +248 -0
  26. ecdat/services/viewmodel.py +326 -0
  27. ecdat/ui/__init__.py +1 -0
  28. ecdat/ui/art3d.py +136 -0
  29. ecdat/ui/art_static.py +65 -0
  30. ecdat/ui/art_text.py +81 -0
  31. ecdat/ui/banner.py +148 -0
  32. ecdat/ui/console.py +119 -0
  33. ecdat/ui/motion.py +64 -0
  34. ecdat/ui/render.py +486 -0
  35. ecdat/ui/theme.py +173 -0
  36. ecdat-0.2.0.dist-info/METADATA +142 -0
  37. ecdat-0.2.0.dist-info/RECORD +51 -0
  38. ecdat-0.2.0.dist-info/WHEEL +5 -0
  39. ecdat-0.2.0.dist-info/entry_points.txt +2 -0
  40. ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
  41. ecdat-0.2.0.dist-info/top_level.txt +2 -0
  42. ecdat_core/__init__.py +6 -0
  43. ecdat_core/cbom_export.py +287 -0
  44. ecdat_core/cli.py +202 -0
  45. ecdat_core/detector.py +273 -0
  46. ecdat_core/ingestion.py +581 -0
  47. ecdat_core/models.py +145 -0
  48. ecdat_core/recommender.py +74 -0
  49. ecdat_core/risk_engine.py +264 -0
  50. ecdat_core/signature_loader.py +204 -0
  51. 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."""