quantbom 0.6.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.
- quantbom/__init__.py +497 -0
- quantbom/_bitorder.py +121 -0
- quantbom/_circuit.py +77 -0
- quantbom/_collect.py +661 -0
- quantbom/_drift.py +333 -0
- quantbom/_fidelity.py +280 -0
- quantbom/_merge.py +187 -0
- quantbom/_qir.py +224 -0
- quantbom/_scan.py +118 -0
- quantbom/_sign.py +284 -0
- quantbom/_stats.py +125 -0
- quantbom/_verify.py +530 -0
- quantbom/actions/__init__.py +27 -0
- quantbom/actions/base.py +68 -0
- quantbom/actions/benchmark.py +53 -0
- quantbom/actions/bind_backend.py +71 -0
- quantbom/actions/capture.py +106 -0
- quantbom/actions/check_qir.py +44 -0
- quantbom/actions/check_results.py +75 -0
- quantbom/actions/compare_native.py +88 -0
- quantbom/actions/disclose_error_handling.py +66 -0
- quantbom/actions/execute.py +85 -0
- quantbom/actions/inventory.py +24 -0
- quantbom/actions/layout.py +118 -0
- quantbom/actions/prove_equivalence.py +225 -0
- quantbom/actions/record_provenance.py +49 -0
- quantbom/actions/verify_conversion.py +53 -0
- quantbom/attest.py +363 -0
- quantbom/cli.py +378 -0
- quantbom/frameworks/__init__.py +67 -0
- quantbom/frameworks/base.py +155 -0
- quantbom/frameworks/braket.py +132 -0
- quantbom/frameworks/cirq.py +106 -0
- quantbom/frameworks/cudaq.py +223 -0
- quantbom/frameworks/pennylane.py +116 -0
- quantbom/frameworks/qiskit.py +67 -0
- quantbom/frameworks/qrisp.py +267 -0
- quantbom/harness.json +1730 -0
- quantbom/harness.py +88 -0
- quantbom-0.6.0.dist-info/METADATA +207 -0
- quantbom-0.6.0.dist-info/RECORD +45 -0
- quantbom-0.6.0.dist-info/WHEEL +5 -0
- quantbom-0.6.0.dist-info/entry_points.txt +2 -0
- quantbom-0.6.0.dist-info/licenses/LICENSE +202 -0
- quantbom-0.6.0.dist-info/top_level.txt +1 -0
quantbom/attest.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Partner and tool evidence from a pip install — MVP_PLAN.md B3.
|
|
2
|
+
|
|
3
|
+
The four `provider: tool` actions of the harness (`quantbom actions`) used to
|
|
4
|
+
exist only as repository scripts, so a pip user saw them listed and could not
|
|
5
|
+
run them. This module is those scripts, in the wheel, stdlib-only:
|
|
6
|
+
|
|
7
|
+
quantbom attest tests --bundle b/ --suite S --outcome passed --passed N (MOD-05 test report)
|
|
8
|
+
quantbom attest vulns --bundle b/ --policy-url U --contact C (VEX/VDR + CVD policy, MOD-07)
|
|
9
|
+
quantbom attest constant-time --bundle b/ --tool dudect --target T --measurements CSV (CT proof, MOD-09)
|
|
10
|
+
quantbom attest crypto --bundle b/ [--cbom FILE] (CBOM agility, MOD-03/09)
|
|
11
|
+
|
|
12
|
+
Each writes its artifact(s) at the canonical path inside the bundle, hash-bound
|
|
13
|
+
and referenced from the QBOM exactly as the repository script does: the bytes
|
|
14
|
+
and the properties are identical, and tests/harness/test_sdk_attest.py keeps
|
|
15
|
+
them so. Run them after `quantbom collect` and before `quantbom sign`, so the
|
|
16
|
+
producer signature covers what they add.
|
|
17
|
+
|
|
18
|
+
The classification tables and thresholds below mirror the repository scripts
|
|
19
|
+
(emit_test_evidence.py, emit_vuln_evidence.py, emit_ct_evidence.py,
|
|
20
|
+
cbom_agility.py); the test pins the ones the gate also holds.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import csv
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import math
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
# ---- shared ----------------------------------------------------------------
|
|
34
|
+
def _canonical(data: Any) -> str:
|
|
35
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sha256(raw: bytes) -> str:
|
|
39
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bundle(bundle: str | os.PathLike) -> tuple[Path, Path]:
|
|
43
|
+
bundle_dir = Path(bundle)
|
|
44
|
+
sbom_path = bundle_dir / "sbom.cdx.json"
|
|
45
|
+
if not sbom_path.is_file():
|
|
46
|
+
raise FileNotFoundError(f"{bundle_dir} is not an evidence bundle (no sbom.cdx.json); make one with `quantbom collect`")
|
|
47
|
+
return bundle_dir, sbom_path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load(path: Path) -> dict[str, Any]:
|
|
51
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_report(path: Path, report: dict[str, Any]) -> tuple[bytes, str]:
|
|
55
|
+
"""Canonical JSON plus a newline — the bytes every repository emitter writes."""
|
|
56
|
+
raw = (_canonical(report) + "\n").encode("utf-8")
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path.write_bytes(raw)
|
|
59
|
+
return raw, _sha256(raw)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _inject(sbom_path: Path, own: set[str], props: list[dict[str, str]]) -> None:
|
|
63
|
+
"""Replace this action's own properties on the QBOM; every other property stays."""
|
|
64
|
+
sbom = _load(sbom_path)
|
|
65
|
+
metadata = sbom.setdefault("metadata", {})
|
|
66
|
+
kept = [p for p in metadata.setdefault("properties", []) if p.get("name") not in own]
|
|
67
|
+
metadata["properties"] = kept + props
|
|
68
|
+
sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _rel(bundle_dir: Path, path: Path) -> str:
|
|
72
|
+
try:
|
|
73
|
+
return str(path.resolve().relative_to(bundle_dir.resolve()))
|
|
74
|
+
except ValueError:
|
|
75
|
+
return str(path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- attest tests (MOD-05) -------------------------------------------------
|
|
79
|
+
TEST_PROPERTY_NAMES = {"secqdevops:test-report-ref", "secqdevops:test-report-sha256", "secqdevops:test-outcome"}
|
|
80
|
+
TEST_REPORT_REL = "evidence/mod05/test-report.json"
|
|
81
|
+
TEST_OUTCOMES = ("passed", "failed")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tests(bundle: str | os.PathLike, *, suite: str, outcome: str, passed: int = 0, failed: int = 0, skipped: int = 0,
|
|
85
|
+
tool: str = "mod05", source_module: str = "MOD-05", out: str = TEST_REPORT_REL) -> dict[str, Any]:
|
|
86
|
+
"""Attach a security test report (CRA Annex I Part II(3); control MOD05-TEST-01)."""
|
|
87
|
+
if outcome not in TEST_OUTCOMES:
|
|
88
|
+
raise ValueError(f"outcome must be one of {TEST_OUTCOMES}")
|
|
89
|
+
bundle_dir, sbom_path = _bundle(bundle)
|
|
90
|
+
report = {
|
|
91
|
+
"schema": "secqdevops.test-report/v1",
|
|
92
|
+
"source_module": source_module,
|
|
93
|
+
"suite": suite,
|
|
94
|
+
"tool": tool,
|
|
95
|
+
"outcome": outcome,
|
|
96
|
+
"totals": {"passed": passed, "failed": failed, "skipped": skipped},
|
|
97
|
+
}
|
|
98
|
+
path = bundle_dir / out
|
|
99
|
+
_, sha = _write_report(path, report)
|
|
100
|
+
ref = _rel(bundle_dir, path)
|
|
101
|
+
_inject(sbom_path, TEST_PROPERTY_NAMES, [
|
|
102
|
+
{"name": "secqdevops:test-report-ref", "value": ref},
|
|
103
|
+
{"name": "secqdevops:test-report-sha256", "value": sha},
|
|
104
|
+
{"name": "secqdevops:test-outcome", "value": outcome},
|
|
105
|
+
])
|
|
106
|
+
return {"out": str(path), "sha256": sha, "outcome": outcome, "injected_into": str(sbom_path)}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---- attest vulnerability handling (MOD-07) --------------------------------
|
|
110
|
+
VULN_PROPERTY_NAMES = {"secqdevops:vex-ref", "secqdevops:vex-sha256", "secqdevops:cvd-policy-ref",
|
|
111
|
+
"secqdevops:cvd-policy-sha256", "secqdevops:cvd-contact"}
|
|
112
|
+
VEX_REL, CVD_REL = "evidence/vex.cdx.json", "evidence/cvd-policy.json"
|
|
113
|
+
ALIGNED_STANDARDS = ["EN 40000-1-3 (prEN — no OJ citation; draft)", "ISO/IEC 29147:2018", "EN ISO/IEC 30111:2019"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_vex(sbom: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
metadata = sbom.get("metadata", {})
|
|
118
|
+
component = metadata.get("component", {})
|
|
119
|
+
return {
|
|
120
|
+
"bomFormat": "CycloneDX",
|
|
121
|
+
"specVersion": sbom.get("specVersion", "1.7"),
|
|
122
|
+
"version": 1,
|
|
123
|
+
"metadata": {
|
|
124
|
+
"timestamp": metadata.get("timestamp", "1970-01-01T00:00:00Z"),
|
|
125
|
+
"component": {"type": component.get("type", "application"), "name": component.get("name", "unknown"),
|
|
126
|
+
# the QBOM's version when it has one; never an invented "0"
|
|
127
|
+
**({"version": component["version"]} if component.get("version") else {}),
|
|
128
|
+
"purl": component.get("purl")},
|
|
129
|
+
"properties": [
|
|
130
|
+
{"name": "secqdevops:document-kind", "value": "vex-vdr"},
|
|
131
|
+
{"name": "secqdevops:covers-sbom-serial", "value": sbom.get("serialNumber", "")},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
"vulnerabilities": sbom.get("vulnerabilities", []),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def vulnerability_handling(bundle: str | os.PathLike, *, policy_url: str, contact: str, sla_days: int = 90,
|
|
139
|
+
advisories: str | os.PathLike | None = None, vex_out: str = VEX_REL, cvd_out: str = CVD_REL) -> dict[str, Any]:
|
|
140
|
+
"""VEX/VDR over the release plus a CVD-policy attestation (CRA Annex I Part II(2), II(5); CRA-VULN-01, CRA-VDP-01)."""
|
|
141
|
+
bundle_dir, sbom_path = _bundle(bundle)
|
|
142
|
+
sbom = _load(sbom_path)
|
|
143
|
+
if advisories is not None: # sibling-module advisories feed the VEX (and CRA-ESR-03)
|
|
144
|
+
known = {v.get("id") for v in sbom.get("vulnerabilities", [])}
|
|
145
|
+
merged = [dict(v, **{"properties": (v.get("properties") or []) + [{"name": "secqdevops:advisory-source", "value": "MOD-07"}]})
|
|
146
|
+
for v in _load(Path(advisories)).get("vulnerabilities", []) if v.get("id") and v["id"] not in known]
|
|
147
|
+
if merged:
|
|
148
|
+
sbom.setdefault("vulnerabilities", []).extend(merged)
|
|
149
|
+
sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
150
|
+
timestamp = sbom.get("metadata", {}).get("timestamp", "1970-01-01T00:00:00Z")
|
|
151
|
+
vex = _build_vex(sbom)
|
|
152
|
+
cvd = {"schema": "secqdevops.cvd-policy/v1", "policy_url": policy_url, "contact": contact,
|
|
153
|
+
"disclosure_sla_days": sla_days, "attested_at": timestamp, "aligned_standards": ALIGNED_STANDARDS}
|
|
154
|
+
vex_path, cvd_path = bundle_dir / vex_out, bundle_dir / cvd_out
|
|
155
|
+
_, vex_sha = _write_report(vex_path, vex)
|
|
156
|
+
_, cvd_sha = _write_report(cvd_path, cvd)
|
|
157
|
+
_inject(sbom_path, VULN_PROPERTY_NAMES, [
|
|
158
|
+
{"name": "secqdevops:vex-ref", "value": _rel(bundle_dir, vex_path)},
|
|
159
|
+
{"name": "secqdevops:vex-sha256", "value": vex_sha},
|
|
160
|
+
{"name": "secqdevops:cvd-policy-ref", "value": _rel(bundle_dir, cvd_path)},
|
|
161
|
+
{"name": "secqdevops:cvd-policy-sha256", "value": cvd_sha},
|
|
162
|
+
{"name": "secqdevops:cvd-contact", "value": contact},
|
|
163
|
+
])
|
|
164
|
+
return {"vex_out": str(vex_path), "vex_sha256": vex_sha, "vex_vulnerability_count": len(vex["vulnerabilities"]),
|
|
165
|
+
"cvd_out": str(cvd_path), "cvd_policy_sha256": cvd_sha, "injected_into": str(sbom_path)}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---- prove constant time (MOD-09) -------------------------------------------
|
|
169
|
+
CT_PROPERTY_NAMES = {"secqdevops:ct-tool", "secqdevops:ct-target", "secqdevops:ct-verdict",
|
|
170
|
+
"secqdevops:ct-proof-ref", "secqdevops:ct-proof-sha256"}
|
|
171
|
+
CT_PROOF_REL = "crypto/ct-proof.json"
|
|
172
|
+
SUPPORTED_CT_TOOLS = ("binsec-rel", "ctgrind", "dudect") # the gate's SUPPORTED_CT_TOOLS
|
|
173
|
+
DEFAULT_T_THRESHOLD = 10.0 # dudect's rule of thumb: |t| > 10 is strong evidence of timing leakage
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def welch_t(class_a: list[float], class_b: list[float]) -> float:
|
|
177
|
+
"""Two-sample Welch's t-statistic — the core of dudect's leakage test."""
|
|
178
|
+
if len(class_a) < 2 or len(class_b) < 2:
|
|
179
|
+
raise ValueError("each timing class needs at least two measurements")
|
|
180
|
+
mean_a, mean_b = sum(class_a) / len(class_a), sum(class_b) / len(class_b)
|
|
181
|
+
var_a = sum((v - mean_a) ** 2 for v in class_a) / (len(class_a) - 1)
|
|
182
|
+
var_b = sum((v - mean_b) ** 2 for v in class_b) / (len(class_b) - 1)
|
|
183
|
+
denom = math.sqrt(var_a / len(class_a) + var_b / len(class_b))
|
|
184
|
+
return 0.0 if denom == 0.0 else (mean_a - mean_b) / denom
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_measurements(path: str | os.PathLike) -> tuple[list[float], list[float]]:
|
|
188
|
+
"""A two-column CSV: class label (0/1, fix/fixed = class 0) and a timing in nanoseconds."""
|
|
189
|
+
class_a: list[float] = []
|
|
190
|
+
class_b: list[float] = []
|
|
191
|
+
with Path(path).open(newline="", encoding="utf-8") as handle:
|
|
192
|
+
for row in csv.reader(handle):
|
|
193
|
+
if not row or row[0].strip().lower() in {"class", "#"}:
|
|
194
|
+
continue
|
|
195
|
+
label, timing = row[0].strip(), float(row[1])
|
|
196
|
+
(class_a if label in {"0", "fix", "fixed"} else class_b).append(timing)
|
|
197
|
+
return class_a, class_b
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def constant_time(bundle: str | os.PathLike, *, tool: str, target: str, t_value: float | None = None,
|
|
201
|
+
measurements: str | os.PathLike | None = None, threshold: float = DEFAULT_T_THRESHOLD,
|
|
202
|
+
out: str = CT_PROOF_REL) -> dict[str, Any]:
|
|
203
|
+
"""A constant-time proof from a dudect / ctgrind / Binsec-Rel result (CRA Annex I 3(c); CRA-ESR-06)."""
|
|
204
|
+
if tool not in SUPPORTED_CT_TOOLS:
|
|
205
|
+
raise ValueError(f"tool must be one of {SUPPORTED_CT_TOOLS}")
|
|
206
|
+
if (t_value is None) == (measurements is None):
|
|
207
|
+
raise ValueError("give exactly one of t_value= or measurements=")
|
|
208
|
+
bundle_dir, sbom_path = _bundle(bundle)
|
|
209
|
+
if measurements is not None:
|
|
210
|
+
class_a, class_b = load_measurements(measurements)
|
|
211
|
+
t_value = welch_t(class_a, class_b)
|
|
212
|
+
n = len(class_a) + len(class_b)
|
|
213
|
+
else:
|
|
214
|
+
n = None
|
|
215
|
+
leaks = abs(t_value) > threshold
|
|
216
|
+
report = {
|
|
217
|
+
"schema": "secqdevops.ct-proof/v1",
|
|
218
|
+
"tool": tool,
|
|
219
|
+
"target": target,
|
|
220
|
+
"metric": "welch_t" if tool == "dudect" else "tool_verdict",
|
|
221
|
+
"t_value": round(t_value, 6),
|
|
222
|
+
"t_threshold": threshold,
|
|
223
|
+
"n_measurements": n,
|
|
224
|
+
"verdict": "fail" if leaks else "pass",
|
|
225
|
+
"interpretation": (f"|t|={abs(t_value):.3f} exceeds {threshold} — timing leakage detected" if leaks
|
|
226
|
+
else f"|t|={abs(t_value):.3f} within {threshold} — no timing leakage observed"),
|
|
227
|
+
}
|
|
228
|
+
path = bundle_dir / out
|
|
229
|
+
_, sha = _write_report(path, report)
|
|
230
|
+
ref = _rel(bundle_dir, path)
|
|
231
|
+
_inject(sbom_path, CT_PROPERTY_NAMES, [
|
|
232
|
+
{"name": "secqdevops:ct-tool", "value": tool},
|
|
233
|
+
{"name": "secqdevops:ct-target", "value": target},
|
|
234
|
+
{"name": "secqdevops:ct-verdict", "value": report["verdict"]},
|
|
235
|
+
{"name": "secqdevops:ct-proof-ref", "value": ref},
|
|
236
|
+
{"name": "secqdevops:ct-proof-sha256", "value": sha},
|
|
237
|
+
])
|
|
238
|
+
return {"verdict": report["verdict"], "t_value": report["t_value"], "report_ref": ref, "report_sha256": sha, "injected_into": str(sbom_path)}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---- assess crypto (MOD-03 / MOD-09) ----------------------------------------
|
|
242
|
+
CBOM_PROPERTY_NAMES = {"secqdevops:cbom-ref", "secqdevops:cbom-sha256", "secqdevops:crypto-agility-ref",
|
|
243
|
+
"secqdevops:crypto-agility-sha256", "secqdevops:crypto-agility-score", "secqdevops:crypto-migration-readiness"}
|
|
244
|
+
CBOM_REL, AGILITY_REL = "crypto/cbom.cdx.json", "crypto/cbom-agility.json"
|
|
245
|
+
# Shor-broken public-key families
|
|
246
|
+
QUANTUM_VULNERABLE = ["RSA", "DSA", "DIFFIE-HELLMAN", "DH", "ECDH", "ECDSA", "ECC", "EDDSA",
|
|
247
|
+
"ED25519", "ED448", "X25519", "X448", "ELGAMAL", "SECP", "BRAINPOOL", "CURVE25519"]
|
|
248
|
+
# NIST PQC + stateful hash-based signatures
|
|
249
|
+
PQC = ["ML-KEM", "KYBER", "ML-DSA", "DILITHIUM", "SLH-DSA", "SPHINCS", "FALCON", "FN-DSA", "HQC", "MCELIECE",
|
|
250
|
+
"CLASSIC MCELIECE", "BIKE", "FRODO", "FRODOKEM", "XMSS", "LMS", "HSS"]
|
|
251
|
+
# symmetric / hash that remain acceptable (Grover only halves the security margin)
|
|
252
|
+
SAFE_SYMMETRIC = ["AES-256", "AES256", "AES-192", "CHACHA20", "SHA-256", "SHA-384", "SHA-512", "SHA256", "SHA384", "SHA512", "SHA3", "SHAKE", "HMAC"]
|
|
253
|
+
# broken or quantum-weakened classical primitives
|
|
254
|
+
WEAKENED = ["AES-128", "AES128", "3DES", "TRIPLE-DES", "DES", "RC4", "SHA-1", "SHA1", "MD5"]
|
|
255
|
+
CLASS_RISK = {"QUANTUM_VULNERABLE": 1.0, "WEAKENED": 0.5, "UNKNOWN": 0.5, "QUANTUM_SAFE": 0.0}
|
|
256
|
+
PQC_STANDARD_IDS = [
|
|
257
|
+
(("ML-KEM", "KYBER"), "NIST FIPS 203 (ML-KEM)"),
|
|
258
|
+
(("ML-DSA", "DILITHIUM"), "NIST FIPS 204 (ML-DSA)"),
|
|
259
|
+
(("SLH-DSA", "SPHINCS"), "NIST FIPS 205 (SLH-DSA)"),
|
|
260
|
+
(("XMSS", "LMS", "HSS"), "NIST SP 800-208 (stateful hash-based signatures)"),
|
|
261
|
+
]
|
|
262
|
+
REPORT_STANDARDS = ["NIST FIPS 203/204/205", "NIST SP 800-208", "ETSI TR 103 619"]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _match(name: str, table: list[str]) -> bool:
|
|
266
|
+
n = name.upper().replace("_", "-")
|
|
267
|
+
return any(token in n for token in table)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _standard_for(name: str) -> str | None:
|
|
271
|
+
n = name.upper().replace("_", "-")
|
|
272
|
+
for tokens, standard in PQC_STANDARD_IDS:
|
|
273
|
+
if any(token in n for token in tokens):
|
|
274
|
+
return standard
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def classify_asset(name: str, crypto_props: dict[str, Any]) -> tuple[str, str]:
|
|
279
|
+
"""(classification, reason); uses nistQuantumSecurityLevel when present."""
|
|
280
|
+
algo = crypto_props.get("algorithmProperties", {}) if isinstance(crypto_props, dict) else {}
|
|
281
|
+
primitive = str(algo.get("primitive", "")).lower()
|
|
282
|
+
nq = algo.get("nistQuantumSecurityLevel")
|
|
283
|
+
is_asymmetric = primitive in {"pke", "kem", "signature", "key-agree", "drbg-asym"} or _match(name, QUANTUM_VULNERABLE) or _match(name, PQC)
|
|
284
|
+
if _match(name, PQC):
|
|
285
|
+
return "QUANTUM_SAFE", "NIST PQC / hash-based primitive"
|
|
286
|
+
if isinstance(nq, int):
|
|
287
|
+
if nq >= 1:
|
|
288
|
+
return "QUANTUM_SAFE", f"declared nistQuantumSecurityLevel={nq}"
|
|
289
|
+
if nq == 0 and is_asymmetric:
|
|
290
|
+
return "QUANTUM_VULNERABLE", "declared nistQuantumSecurityLevel=0 (asymmetric)"
|
|
291
|
+
if _match(name, QUANTUM_VULNERABLE):
|
|
292
|
+
return "QUANTUM_VULNERABLE", "Shor-broken public-key family"
|
|
293
|
+
if _match(name, WEAKENED):
|
|
294
|
+
return "WEAKENED", "broken or quantum-weakened classical primitive"
|
|
295
|
+
if _match(name, SAFE_SYMMETRIC):
|
|
296
|
+
return "QUANTUM_SAFE", "strong symmetric/hash (Grover only halves margin)"
|
|
297
|
+
return "UNKNOWN", "unrecognized algorithm; manual review required"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def assess_cbom(cbom: dict[str, Any]) -> dict[str, Any]:
|
|
301
|
+
assets = []
|
|
302
|
+
counts = {"QUANTUM_VULNERABLE": 0, "WEAKENED": 0, "QUANTUM_SAFE": 0, "UNKNOWN": 0}
|
|
303
|
+
pqc_present = 0
|
|
304
|
+
for comp in [c for c in cbom.get("components", []) if c.get("type") == "cryptographic-asset" or "cryptoProperties" in c]:
|
|
305
|
+
name = comp.get("name", "<unnamed>")
|
|
306
|
+
crypto_props = comp.get("cryptoProperties", {})
|
|
307
|
+
classification, reason = classify_asset(name, crypto_props)
|
|
308
|
+
counts[classification] += 1
|
|
309
|
+
if _match(name, PQC):
|
|
310
|
+
pqc_present += 1
|
|
311
|
+
asset: dict[str, Any] = {"name": name, "primitive": (crypto_props.get("algorithmProperties", {}) or {}).get("primitive"),
|
|
312
|
+
"classification": classification, "reason": reason}
|
|
313
|
+
standard = _standard_for(name)
|
|
314
|
+
if standard:
|
|
315
|
+
asset["standard"] = standard
|
|
316
|
+
assets.append(asset)
|
|
317
|
+
total = len(assets)
|
|
318
|
+
risk = sum(CLASS_RISK[a["classification"]] for a in assets)
|
|
319
|
+
agility_score = round(100 * (1 - risk / total)) if total else 0
|
|
320
|
+
if counts["QUANTUM_VULNERABLE"] == 0 and counts["WEAKENED"] == 0:
|
|
321
|
+
readiness = "READY"
|
|
322
|
+
elif counts["QUANTUM_VULNERABLE"] > 0 and pqc_present == 0:
|
|
323
|
+
readiness = "NOT_READY"
|
|
324
|
+
else:
|
|
325
|
+
readiness = "IN_PROGRESS"
|
|
326
|
+
return {
|
|
327
|
+
"schema": "secqdevops.cbom-agility/v1",
|
|
328
|
+
"standards": REPORT_STANDARDS,
|
|
329
|
+
"summary": {"total_assets": total, "quantum_vulnerable": counts["QUANTUM_VULNERABLE"], "weakened": counts["WEAKENED"],
|
|
330
|
+
"quantum_safe": counts["QUANTUM_SAFE"], "pqc_present": pqc_present, "unknown": counts["UNKNOWN"]},
|
|
331
|
+
"agility_score": agility_score,
|
|
332
|
+
"migration_readiness": readiness,
|
|
333
|
+
"assets": assets,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def crypto(bundle: str | os.PathLike, *, cbom: str | os.PathLike | None = None, out: str = AGILITY_REL) -> dict[str, Any]:
|
|
338
|
+
"""CBOM ingestion and PQC migration-readiness (CRA Annex I 3(c); CRA-CRYPTO-01).
|
|
339
|
+
|
|
340
|
+
The CBOM is read from crypto/cbom.cdx.json inside the bundle; a path given
|
|
341
|
+
with cbom= is copied there first, so the bundle carries what was assessed.
|
|
342
|
+
"""
|
|
343
|
+
bundle_dir, sbom_path = _bundle(bundle)
|
|
344
|
+
cbom_path = bundle_dir / CBOM_REL
|
|
345
|
+
if cbom is not None and Path(cbom).resolve() != cbom_path.resolve():
|
|
346
|
+
cbom_path.parent.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
shutil.copy(Path(cbom), cbom_path)
|
|
348
|
+
if not cbom_path.is_file():
|
|
349
|
+
raise FileNotFoundError(f"no CBOM at {cbom_path}: copy your CycloneDX CBOM there, or pass cbom=FILE (--cbom)")
|
|
350
|
+
report = assess_cbom(_load(cbom_path))
|
|
351
|
+
path = bundle_dir / out
|
|
352
|
+
_, report_sha = _write_report(path, report)
|
|
353
|
+
cbom_sha = _sha256(cbom_path.read_bytes())
|
|
354
|
+
_inject(sbom_path, CBOM_PROPERTY_NAMES, [
|
|
355
|
+
{"name": "secqdevops:cbom-ref", "value": _rel(bundle_dir, cbom_path)},
|
|
356
|
+
{"name": "secqdevops:cbom-sha256", "value": cbom_sha},
|
|
357
|
+
{"name": "secqdevops:crypto-agility-ref", "value": _rel(bundle_dir, path)},
|
|
358
|
+
{"name": "secqdevops:crypto-agility-sha256", "value": report_sha},
|
|
359
|
+
{"name": "secqdevops:crypto-agility-score", "value": str(report["agility_score"])},
|
|
360
|
+
{"name": "secqdevops:crypto-migration-readiness", "value": report["migration_readiness"]},
|
|
361
|
+
])
|
|
362
|
+
return {"agility_score": report["agility_score"], "migration_readiness": report["migration_readiness"],
|
|
363
|
+
"summary": report["summary"], "report_out": str(path), "injected_into": str(sbom_path)}
|