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_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]
|