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
ecdat_core/models.py ADDED
@@ -0,0 +1,145 @@
1
+ """Pydantic v2 data models for ECDAT CBOM scanner.
2
+
3
+ Defines the core domain models: Detection, RiskAssessment, Recommendation,
4
+ and ScanResult. These models form the contract for the rest of the system.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+ from uuid import uuid4
11
+
12
+ from pydantic import BaseModel, field_validator
13
+
14
+
15
+ class Detection(BaseModel):
16
+ """A single cryptographic artefact discovered during scanning.
17
+
18
+ Attributes:
19
+ id: Unique identifier, auto-generated via uuid4 if not provided.
20
+ file_path: Path to the file where the artefact was found.
21
+ line_number: 1-based line number of the match.
22
+ matched_text: The exact text that triggered detection.
23
+ asset_type: CycloneDX-aligned asset classification.
24
+ algorithm_family: Broad algorithm family (e.g. RSA, AES, SHA).
25
+ key_size_bits: Key size in bits, if determinable.
26
+ quantum_vulnerable: Whether the artefact is vulnerable to quantum attacks.
27
+ classically_broken: Whether the artefact is already broken or deprecated
28
+ today, independent of quantum computing entirely (e.g. MD5, SHA-1,
29
+ DES, 3DES, RC4).
30
+ confidence: Detection confidence score in [0.0, 1.0].
31
+ language: Programming language of the source file.
32
+ detection_method: How the artefact was detected.
33
+ """
34
+
35
+ id: str = ""
36
+ file_path: str
37
+ line_number: int
38
+ matched_text: str
39
+ asset_type: Literal["algorithm", "certificate", "protocol", "related-crypto-material"]
40
+ algorithm_family: str
41
+ key_size_bits: int | None = None
42
+ quantum_vulnerable: bool
43
+ classically_broken: bool = False
44
+ confidence: float
45
+ language: str
46
+ detection_method: Literal["regex", "manifest", "certificate-parse"]
47
+
48
+ def model_post_init(self, __context: object) -> None:
49
+ """Generate a UUID4 id if one was not provided."""
50
+ if not self.id:
51
+ object.__setattr__(self, "id", str(uuid4()))
52
+
53
+ @field_validator("confidence")
54
+ @classmethod
55
+ def validate_confidence(cls, v: float) -> float:
56
+ """Ensure confidence is in the range [0.0, 1.0]."""
57
+ if not 0.0 <= v <= 1.0:
58
+ raise ValueError(f"confidence must be between 0.0 and 1.0, got {v}")
59
+ return v
60
+
61
+
62
+ class RiskAssessment(BaseModel):
63
+ """Risk assessment derived from Mosca's inequality.
64
+
65
+ Attributes:
66
+ detection_id: Reference to the Detection being assessed.
67
+ migration_time_years: Estimated migration time in years (X).
68
+ shelf_life_years: How long the data must remain secret (Y).
69
+ threat_horizon_years: Quantum threat horizon in years (Z).
70
+ urgency_ratio: (X + Y) / Z — values >= 1.0 indicate immediate risk.
71
+ risk_level: Classified risk severity.
72
+ mosca_violation: Whether Mosca's inequality is violated (X+Y > Z).
73
+ """
74
+
75
+ detection_id: str
76
+ migration_time_years: float
77
+ shelf_life_years: float
78
+ threat_horizon_years: float
79
+ urgency_ratio: float
80
+ risk_level: Literal["critical", "high", "medium", "low", "quantum-safe"]
81
+ mosca_violation: bool
82
+
83
+ @field_validator("urgency_ratio", mode="after")
84
+ def validate_urgency_ratio(cls, v: float) -> float:
85
+ """Ensure urgency_ratio is non-negative."""
86
+ if v < 0:
87
+ raise ValueError(f"urgency_ratio must be non-negative, got {v}")
88
+ return v
89
+
90
+ def model_post_init(self, __context: object) -> None:
91
+ """Check consistency between urgency_ratio and mosca_violation.
92
+
93
+ Mosca's inequality states that if X + Y > Z (i.e. urgency_ratio >= 1.0),
94
+ then the artefact is at risk. The mosca_violation flag must be consistent
95
+ with the urgency_ratio.
96
+ """
97
+ expected_violation = self.urgency_ratio >= 1.0
98
+ if self.mosca_violation != expected_violation:
99
+ raise ValueError(
100
+ f"mosca_violation ({self.mosca_violation}) contradicts "
101
+ f"urgency_ratio ({self.urgency_ratio}). "
102
+ f"Expected mosca_violation={expected_violation} "
103
+ f"(urgency_ratio {'>=' if expected_violation else '<'} 1.0)"
104
+ )
105
+
106
+
107
+ class Recommendation(BaseModel):
108
+ """Post-quantum migration recommendation for a detected artefact.
109
+
110
+ Attributes:
111
+ detection_id: Reference to the Detection being recommended for.
112
+ recommended_algorithm: NIST-approved post-quantum algorithm.
113
+ fips_reference: FIPS standard reference for the algorithm.
114
+ rationale: Why this algorithm is recommended.
115
+ latency_note: Performance impact advisory.
116
+ migration_note: Migration complexity advisory.
117
+ """
118
+
119
+ detection_id: str
120
+ recommended_algorithm: str
121
+ fips_reference: str
122
+ rationale: str
123
+ latency_note: str
124
+ migration_note: str
125
+
126
+
127
+ class ScanResult(BaseModel):
128
+ """Complete result of a CBOM scan operation.
129
+
130
+ Attributes:
131
+ scan_id: Unique identifier for this scan.
132
+ target: Path or URL that was scanned.
133
+ detections: All cryptographic artefacts discovered.
134
+ risk_assessments: Risk assessments for each detection.
135
+ recommendations: Migration recommendations for each detection.
136
+ scanned_at: ISO 8601 timestamp of when the scan was performed.
137
+ """
138
+
139
+ scan_id: str
140
+ target: str
141
+ detections: list[Detection]
142
+ risk_assessments: list[RiskAssessment]
143
+ recommendations: list[Recommendation]
144
+ scanned_at: str # ISO timestamp
145
+ files_scanned: int = 0
@@ -0,0 +1,74 @@
1
+ """Post-quantum recommendation engine for ECDAT.
2
+
3
+ Produces a :class:`Recommendation` for each detected cryptographic artefact.
4
+ For ordinary algorithms the recommendation is a direct pass-through of the
5
+ signature's ``pqc_recommendation``. The one special case is an artefact that
6
+ *already* uses a NIST-standardized post-quantum algorithm — those get a
7
+ "no migration needed" recommendation instead of a migration directive.
8
+
9
+ Public API:
10
+ - :func:`recommend` -> build a :class:`Recommendation` for a single
11
+ detection against a signature entry.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ecdat_core.models import Detection, Recommendation
17
+ from ecdat_core.signature_loader import SignatureEntry
18
+
19
+ # Signature families that are *already* NIST-standardized post-quantum
20
+ # schemes. These are quantum-safe, so instead of recommending a migration we
21
+ # only note that no migration is required.
22
+ _PQC_FAMILIES = frozenset({"pqc-kem", "pqc-signature"})
23
+
24
+ # Fixed text for the already-quantum-safe recommendation.
25
+ _NO_MIGRATION_RATIONALE = (
26
+ "Already using a NIST-standardized post-quantum algorithm; no migration needed."
27
+ )
28
+ _NO_MIGRATION_MIGRATION_NOTE = "Monitor for future FIPS updates."
29
+
30
+
31
+ def recommend(
32
+ detection: Detection, signature_entry: SignatureEntry
33
+ ) -> Recommendation:
34
+ """Build a post-quantum migration recommendation for a detection.
35
+
36
+ The normal path copies the signature's ``pqc_recommendation`` verbatim
37
+ into a new :class:`Recommendation` keyed to ``detection.id``. The
38
+ exception is an artefact that already uses a NIST-standardized post-quantum
39
+ algorithm (a PQC KEM or signature family that is not quantum-vulnerable) —
40
+ for those, a lightweight "no migration needed" recommendation is returned
41
+ that keeps the signature's FIPS reference but replaces the migration
42
+ guidance with monitoring advice.
43
+
44
+ Args:
45
+ detection: The detected cryptographic artefact to recommend for.
46
+ signature_entry: The knowledge-base entry describing this artefact and
47
+ its post-quantum replacement.
48
+
49
+ Returns:
50
+ A fully populated :class:`Recommendation`.
51
+ """
52
+ already_pqc = (
53
+ not detection.quantum_vulnerable
54
+ and signature_entry.family in _PQC_FAMILIES
55
+ )
56
+ if already_pqc:
57
+ return Recommendation(
58
+ detection_id=detection.id,
59
+ recommended_algorithm=detection.algorithm_family,
60
+ fips_reference=signature_entry.pqc_recommendation.fips_reference,
61
+ rationale=_NO_MIGRATION_RATIONALE,
62
+ latency_note="",
63
+ migration_note=_NO_MIGRATION_MIGRATION_NOTE,
64
+ )
65
+
66
+ rec = signature_entry.pqc_recommendation
67
+ return Recommendation(
68
+ detection_id=detection.id,
69
+ recommended_algorithm=rec.algorithm,
70
+ fips_reference=rec.fips_reference,
71
+ rationale=rec.rationale,
72
+ latency_note=rec.latency_note,
73
+ migration_note=rec.migration_note,
74
+ )
@@ -0,0 +1,264 @@
1
+ """Mosca's inequality risk engine for ECDAT.
2
+
3
+ Evaluates post-quantum risk for detected cryptographic artefacts by computing
4
+ Mosca's inequality: ``X + Y > Z`` where X is migration time, Y is data
5
+ shelf life, and Z is the quantum threat horizon. When the inequality holds
6
+ (urgency_ratio >= 1.0), the artefact is at immediate risk.
7
+
8
+ Public API:
9
+ - :func:`assess_risk` -> compute a :class:`RiskAssessment` for a single
10
+ detection against a signature entry.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ecdat_core.models import Detection, RiskAssessment
16
+ from ecdat_core.signature_loader import SignatureEntry
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Sensitive-path keywords for shelf-life heuristic (Y).
20
+ # If any of these appear case-insensitively in the file path, the artefact
21
+ # is assumed to protect high-sensitivity data with a longer shelf life.
22
+ # ---------------------------------------------------------------------------
23
+ _SENSITIVE_PATH_KEYWORDS = ("payment", "auth", "pii", "secret", "key")
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Asset-type → default migration time (X) mapping.
27
+ # Different asset types have very different migration complexity: swapping an
28
+ # algorithm in a library is fast; rotating certificates or crypto material
29
+ # across an enterprise is much slower.
30
+ # ---------------------------------------------------------------------------
31
+ _MIGRATION_TIME_BY_ASSET: dict[str, float] = {
32
+ "algorithm": 0.5,
33
+ "protocol": 2.0,
34
+ "certificate": 1.0,
35
+ "related-crypto-material": 5.0,
36
+ }
37
+
38
+
39
+ def assess_risk(
40
+ detection: Detection,
41
+ signature_entry: SignatureEntry,
42
+ shelf_life_override: float | None = None,
43
+ migration_time_override: float | None = None,
44
+ ) -> RiskAssessment:
45
+ """Evaluate post-quantum risk for a single detection via Mosca's inequality.
46
+
47
+ Mosca's inequality states that a cryptographic artefact is at risk when::
48
+
49
+ X + Y > Z
50
+
51
+ where:
52
+ - **X** (`migration_time_years`): estimated time to migrate away from
53
+ the artefact to a post-quantum alternative.
54
+ - **Y** (`shelf_life_years`): how long the data protected by the
55
+ artefact must remain confidential.
56
+ - **Z** (`threat_horizon_years`): estimated time before a cryptographically
57
+ relevant quantum computer exists.
58
+
59
+ The urgency ratio ``(X + Y) / Z`` quantifies the severity: values >= 1.0
60
+ indicate that migration must already be underway.
61
+
62
+ Classically-broken artefacts (MD5, SHA-1, DES, 3DES, RC4) are broken today
63
+ by classical attacks, independent of quantum computing. They always resolve
64
+ to ``risk_level="critical"`` with ``mosca_violation=True`` and an urgency
65
+ ratio floored at 1.0 — this represents a classical break, **not** a
66
+ quantum-specific one, and such artefacts are never reported as
67
+ "quantum-safe". X/Y/Z are still computed via the normal heuristics so the
68
+ numbers remain visible for transparency.
69
+
70
+ Args:
71
+ detection: The detected cryptographic artefact to assess.
72
+ signature_entry: The knowledge-base entry for this artefact's family,
73
+ providing the default threat horizon.
74
+ shelf_life_override: Explicit shelf life in years (Y). When ``None``,
75
+ a default is chosen based on file-path heuristics.
76
+ migration_time_override: Explicit migration time in years (X). When
77
+ ``None``, a default is chosen based on asset type.
78
+
79
+ Returns:
80
+ A fully populated :class:`RiskAssessment` with consistent risk_level,
81
+ urgency_ratio, and mosca_violation fields.
82
+
83
+ Raises:
84
+ pydantic.ValidationError: If the computed fields are internally
85
+ inconsistent (e.g. mosca_violation disagrees with urgency_ratio).
86
+ """
87
+ # ------------------------------------------------------------------
88
+ # 1. Classically-broken — critical, unconditionally.
89
+ # MD5, SHA-1, DES, 3DES, RC4 are broken *today* by classical
90
+ # attacks, independent of any quantum computer, so they must never
91
+ # fall through to the quantum-safe short-circuit below. X/Y/Z are
92
+ # still computed via the normal heuristics/overrides so the numbers
93
+ # are visible for transparency, and the naive urgency ratio is
94
+ # floored at 1.0 so the Pydantic consistency validator
95
+ # (mosca_violation == urgency_ratio >= 1.0) holds with the forced
96
+ # critical / mosca_violation=True classification.
97
+ # ------------------------------------------------------------------
98
+ if detection.classically_broken:
99
+ z = _adjusted_threat_horizon(detection, signature_entry)
100
+ if shelf_life_override is not None:
101
+ y = shelf_life_override
102
+ else:
103
+ y = _default_shelf_life(detection.file_path)
104
+ if migration_time_override is not None:
105
+ x = migration_time_override
106
+ else:
107
+ x = _default_migration_time(detection.asset_type)
108
+ naive_urgency_ratio = (x + y) / z
109
+ urgency_ratio = max(naive_urgency_ratio, 1.0)
110
+ return RiskAssessment(
111
+ detection_id=detection.id,
112
+ migration_time_years=x,
113
+ shelf_life_years=y,
114
+ threat_horizon_years=z,
115
+ urgency_ratio=urgency_ratio,
116
+ risk_level="critical",
117
+ mosca_violation=True,
118
+ )
119
+
120
+ # ------------------------------------------------------------------
121
+ # 2. Quantum-safe short-circuit — no formula needed. Only reachable
122
+ # when classically_broken is False (branch 1 above already
123
+ # returned), so a classically-broken-but-not-quantum-vulnerable
124
+ # artefact can never be mislabelled "quantum-safe".
125
+ # ------------------------------------------------------------------
126
+ if not detection.quantum_vulnerable:
127
+ return RiskAssessment(
128
+ detection_id=detection.id,
129
+ migration_time_years=0.0,
130
+ shelf_life_years=0.0,
131
+ threat_horizon_years=0.0,
132
+ urgency_ratio=0.0,
133
+ risk_level="quantum-safe",
134
+ mosca_violation=False,
135
+ )
136
+
137
+ # ------------------------------------------------------------------
138
+ # Z — Threat horizon (years) with key-size adjustment.
139
+ # ------------------------------------------------------------------
140
+ z = _adjusted_threat_horizon(detection, signature_entry)
141
+
142
+ # ------------------------------------------------------------------
143
+ # Y — Shelf life (years).
144
+ # ------------------------------------------------------------------
145
+ if shelf_life_override is not None:
146
+ y = shelf_life_override
147
+ else:
148
+ y = _default_shelf_life(detection.file_path)
149
+
150
+ # ------------------------------------------------------------------
151
+ # X — Migration time (years).
152
+ # ------------------------------------------------------------------
153
+ if migration_time_override is not None:
154
+ x = migration_time_override
155
+ else:
156
+ x = _default_migration_time(detection.asset_type)
157
+
158
+ # ------------------------------------------------------------------
159
+ # 4. Urgency ratio, risk level, Mosca violation.
160
+ # ------------------------------------------------------------------
161
+ urgency_ratio = (x + y) / z
162
+ risk_level = _classify_risk(urgency_ratio)
163
+ mosca_violation = urgency_ratio >= 1.0
164
+
165
+ return RiskAssessment(
166
+ detection_id=detection.id,
167
+ migration_time_years=x,
168
+ shelf_life_years=y,
169
+ threat_horizon_years=z,
170
+ urgency_ratio=urgency_ratio,
171
+ risk_level=risk_level,
172
+ mosca_violation=mosca_violation,
173
+ )
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Private helpers
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def _adjusted_threat_horizon(
182
+ detection: Detection, signature_entry: SignatureEntry
183
+ ) -> float:
184
+ """Compute the threat horizon (Z) with key-size-based adjustments.
185
+
186
+ Smaller keys are vulnerable to brute-force sooner, even before a full
187
+ cryptographically relevant quantum computer materialises — an attacker
188
+ with limited quantum resources could still break them. Conversely,
189
+ very large classical keys buy extra time.
190
+
191
+ Adjustment thresholds (documented here and in the caller's docstring):
192
+
193
+ **RSA** (algorithm_family contains "RSA"):
194
+ - key_size <= 1024: scale Z by 0.3 (breakable with near-term QC)
195
+ - key_size < 2048: scale Z by 0.5 (weakened but not trivially breakable)
196
+ - key_size >= 4096: scale Z by 1.2 (extra breathing room)
197
+ - otherwise: use default unchanged
198
+
199
+ **EC / ECC** (algorithm_family contains "EC"):
200
+ - key_size < 256: scale Z by 0.5 (below NIST P-256 minimum)
201
+
202
+ **DSA** (algorithm_family contains "DSA"):
203
+ - key_size < 2048: scale Z by 0.5 (below NIST minimum)
204
+
205
+ All other families or missing key sizes use the default unchanged.
206
+ """
207
+ z = signature_entry.threat_horizon_years_default
208
+ key_size = detection.key_size_bits
209
+ family_upper = detection.algorithm_family.upper()
210
+
211
+ if key_size is not None:
212
+ if "RSA" in family_upper:
213
+ if key_size <= 1024:
214
+ z *= 0.3
215
+ elif key_size < 2048:
216
+ z *= 0.5
217
+ elif key_size >= 4096:
218
+ z *= 1.2
219
+ # 2048 <= key < 4096: no adjustment — use default
220
+
221
+ elif "EC" in family_upper:
222
+ if key_size < 256:
223
+ z *= 0.5
224
+
225
+ elif "DSA" in family_upper:
226
+ if key_size < 2048:
227
+ z *= 0.5
228
+
229
+ return z
230
+
231
+
232
+ def _default_shelf_life(file_path: str) -> float:
233
+ """Choose a default shelf life (Y) based on file-path heuristics.
234
+
235
+ Paths containing sensitive keywords (payment, auth, pii, secret, key)
236
+ are assumed to handle data that must remain secret longer.
237
+ """
238
+ path_lower = file_path.lower()
239
+ if any(kw in path_lower for kw in _SENSITIVE_PATH_KEYWORDS):
240
+ return 10.0
241
+ return 5.0
242
+
243
+
244
+ def _default_migration_time(asset_type: str) -> float:
245
+ """Choose a default migration time (X) based on the CycloneDX asset type."""
246
+ return _MIGRATION_TIME_BY_ASSET.get(asset_type, 0.5)
247
+
248
+
249
+ def _classify_risk(urgency_ratio: float) -> str:
250
+ """Classify risk level from the urgency ratio.
251
+
252
+ Thresholds:
253
+ - >= 1.0 → critical (Mosca violated — migration needed now)
254
+ - >= 0.8 → high
255
+ - >= 0.5 → medium
256
+ - < 0.5 → low
257
+ """
258
+ if urgency_ratio >= 1.0:
259
+ return "critical"
260
+ if urgency_ratio >= 0.8:
261
+ return "high"
262
+ if urgency_ratio >= 0.5:
263
+ return "medium"
264
+ return "low"
@@ -0,0 +1,204 @@
1
+ """Loader and validation for the ECDAT cryptographic signature knowledge base.
2
+
3
+ Loads ``signatures.json`` and validates it against the :class:`SignatureEntry`
4
+ Pydantic model at import time. A malformed JSON document raises a clear error
5
+ immediately, so downstream code can rely on the in-memory signature list being
6
+ valid.
7
+
8
+ Public API:
9
+ - :func:`get_all_signatures` -> list of all signature entries.
10
+ - :func:`get_signatures_for_language` -> entries that define patterns for a
11
+ given language.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ from pydantic import BaseModel, Field, field_validator
21
+
22
+ # Languages supported by the signature knowledge base. The JSON payload uses
23
+ # these exact keys for each entry's ``patterns`` mapping.
24
+ SUPPORTED_LANGUAGES = frozenset({"python", "javascript", "java", "go", "c_cpp"})
25
+
26
+ _SIGNATURES_PATH = Path(__file__).with_name("signatures.json")
27
+
28
+
29
+ class PqcRecommendation(BaseModel):
30
+ """Post-quantum migration recommendation attached to a signature entry.
31
+
32
+ Attributes:
33
+ algorithm: Recommended NIST post-quantum algorithm name.
34
+ fips_reference: FIPS standard reference (e.g. "FIPS 203"), or "N/A" for
35
+ non-quantum-specific fixes (e.g. classical hash weaknesses).
36
+ rationale: Why this algorithm is the recommended replacement.
37
+ latency_note: Performance impact advisory.
38
+ migration_note: Migration complexity and steps advisory.
39
+ """
40
+
41
+ algorithm: str
42
+ fips_reference: str
43
+ rationale: str
44
+ latency_note: str
45
+ migration_note: str
46
+
47
+ @field_validator("fips_reference")
48
+ @classmethod
49
+ def fips_reference_non_empty(cls, v: str) -> str:
50
+ """Require fips_reference to be a non-empty string."""
51
+ v = v.strip()
52
+ if not v:
53
+ raise ValueError("fips_reference must be a non-empty string")
54
+ return v
55
+
56
+
57
+ class SignatureEntry(BaseModel):
58
+ """A single detection signature for one algorithm/family.
59
+
60
+ Attributes:
61
+ name: Human-readable algorithm name (e.g. "RSA").
62
+ family: Broad algorithm family classification.
63
+ quantum_vulnerable: Whether the algorithm is vulnerable to quantum
64
+ attacks. For symmetric ciphers with a
65
+ ``min_quantum_safe_key_bits`` threshold this is the conservative
66
+ static default used when the extracted key size is unavailable;
67
+ per-detection it should be recomputed from the extracted key size
68
+ (see :data:`min_quantum_safe_key_bits`).
69
+ classically_broken: Whether the algorithm is already broken/deprecated
70
+ today, independent of quantum computing entirely (e.g. MD5, SHA-1,
71
+ DES, 3DES, RC4). Must be set explicitly on every entry — there is
72
+ no default.
73
+ min_quantum_safe_key_bits: Minimum key size (in bits) at which the
74
+ algorithm's post-quantum security is acceptable; only meaningful
75
+ for symmetric ciphers whose ``quantum_vulnerable`` should depend on
76
+ the extracted key size rather than being static. ``None`` elsewhere.
77
+ threat_horizon_years_default: Default Mosca threat horizon (Z) in years.
78
+ patterns: Map of language -> list of regex patterns used to detect this
79
+ algorithm in source code of that language.
80
+ key_size_pattern: Optional regex (with a capture group) used to extract
81
+ key size from matched source; ``None`` when key size is not relevant.
82
+ pqc_recommendation: Post-quantum migration recommendation.
83
+ """
84
+
85
+ name: str
86
+ family: Literal[
87
+ "asymmetric-encryption",
88
+ "signature",
89
+ "hash",
90
+ "symmetric-encryption",
91
+ "pqc-kem",
92
+ "pqc-signature",
93
+ ]
94
+ quantum_vulnerable: bool
95
+ classically_broken: bool
96
+ min_quantum_safe_key_bits: int | None = None
97
+ threat_horizon_years_default: float = Field(ge=0)
98
+ patterns: dict[str, list[str]]
99
+ key_size_pattern: str | None = None
100
+ pqc_recommendation: PqcRecommendation
101
+
102
+ @field_validator("name")
103
+ @classmethod
104
+ def name_non_empty(cls, v: str) -> str:
105
+ """Require name to be a non-empty string."""
106
+ v = v.strip()
107
+ if not v:
108
+ raise ValueError("name must be a non-empty string")
109
+ return v
110
+
111
+ @field_validator("patterns")
112
+ @classmethod
113
+ def patterns_have_at_least_one_language(
114
+ cls, v: dict[str, list[str]]
115
+ ) -> dict[str, list[str]]:
116
+ """Require patterns for at least one supported language.
117
+
118
+ Each supported language key must map to a non-empty list of non-empty
119
+ regex strings. Unknown language keys are rejected.
120
+ """
121
+ if not v:
122
+ raise ValueError("patterns must contain at least one language")
123
+
124
+ unknown = set(v.keys()) - SUPPORTED_LANGUAGES
125
+ if unknown:
126
+ raise ValueError(
127
+ f"patterns contains unsupported language(s): {sorted(unknown)}"
128
+ )
129
+
130
+ for lang, regexes in v.items():
131
+ if not regexes:
132
+ raise ValueError(f"patterns[{lang!r}] must be a non-empty list")
133
+ if any(not isinstance(r, str) or not r.strip() for r in regexes):
134
+ raise ValueError(f"patterns[{lang!r}] contains an empty pattern")
135
+
136
+ return v
137
+
138
+
139
+ def _load_signatures() -> list[SignatureEntry]:
140
+ """Load and validate ``signatures.json`` into SignatureEntry instances."""
141
+ try:
142
+ raw = json.loads(_SIGNATURES_PATH.read_text(encoding="utf-8"))
143
+ except FileNotFoundError as exc:
144
+ raise FileNotFoundError(
145
+ f"Cannot find cryptographic signature knowledge base at "
146
+ f"{_SIGNATURES_PATH}. Ensure ecdat_core/signatures.json is present."
147
+ ) from exc
148
+ except json.JSONDecodeError as exc:
149
+ raise ValueError(
150
+ f"signatures.json is not valid JSON: {exc.msg} "
151
+ f"(line {exc.lineno}, column {exc.colno})"
152
+ ) from exc
153
+
154
+ if not isinstance(raw, list):
155
+ raise ValueError(
156
+ f"signatures.json must be a JSON array of entries, "
157
+ f"got {type(raw).__name__}"
158
+ )
159
+
160
+ entries: list[SignatureEntry] = []
161
+ for idx, item in enumerate(raw):
162
+ try:
163
+ entries.append(SignatureEntry.model_validate(item))
164
+ except Exception as exc: # noqa: BLE001 - surface any validation failure
165
+ raise ValueError(
166
+ f"signatures.json entry #{idx} failed validation: {exc}"
167
+ ) from exc
168
+
169
+ return entries
170
+
171
+
172
+ # Load and validate once at import time. Any malformed entry aborts the import
173
+ # with a descriptive error rather than surfacing later during a scan.
174
+ _SIGNATURES: list[SignatureEntry] = _load_signatures()
175
+
176
+
177
+ def get_all_signatures() -> list[SignatureEntry]:
178
+ """Return all validated signature entries from the knowledge base.
179
+
180
+ Returns:
181
+ list[SignatureEntry]: Every signature entry defined in signatures.json.
182
+ """
183
+ return list(_SIGNATURES)
184
+
185
+
186
+ def get_signatures_for_language(lang: str) -> list[SignatureEntry]:
187
+ """Return signature entries that define patterns for the given language.
188
+
189
+ Args:
190
+ lang: A supported language name (e.g. "python", "go").
191
+
192
+ Returns:
193
+ list[SignatureEntry]: Entries with at least one detection pattern for
194
+ the requested language.
195
+
196
+ Raises:
197
+ ValueError: If ``lang`` is not a supported language.
198
+ """
199
+ if lang not in SUPPORTED_LANGUAGES:
200
+ raise ValueError(
201
+ f"Unsupported language {lang!r}; expected one of "
202
+ f"{sorted(SUPPORTED_LANGUAGES)}"
203
+ )
204
+ return [entry for entry in _SIGNATURES if lang in entry.patterns]