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.
Files changed (45) hide show
  1. quantbom/__init__.py +497 -0
  2. quantbom/_bitorder.py +121 -0
  3. quantbom/_circuit.py +77 -0
  4. quantbom/_collect.py +661 -0
  5. quantbom/_drift.py +333 -0
  6. quantbom/_fidelity.py +280 -0
  7. quantbom/_merge.py +187 -0
  8. quantbom/_qir.py +224 -0
  9. quantbom/_scan.py +118 -0
  10. quantbom/_sign.py +284 -0
  11. quantbom/_stats.py +125 -0
  12. quantbom/_verify.py +530 -0
  13. quantbom/actions/__init__.py +27 -0
  14. quantbom/actions/base.py +68 -0
  15. quantbom/actions/benchmark.py +53 -0
  16. quantbom/actions/bind_backend.py +71 -0
  17. quantbom/actions/capture.py +106 -0
  18. quantbom/actions/check_qir.py +44 -0
  19. quantbom/actions/check_results.py +75 -0
  20. quantbom/actions/compare_native.py +88 -0
  21. quantbom/actions/disclose_error_handling.py +66 -0
  22. quantbom/actions/execute.py +85 -0
  23. quantbom/actions/inventory.py +24 -0
  24. quantbom/actions/layout.py +118 -0
  25. quantbom/actions/prove_equivalence.py +225 -0
  26. quantbom/actions/record_provenance.py +49 -0
  27. quantbom/actions/verify_conversion.py +53 -0
  28. quantbom/attest.py +363 -0
  29. quantbom/cli.py +378 -0
  30. quantbom/frameworks/__init__.py +67 -0
  31. quantbom/frameworks/base.py +155 -0
  32. quantbom/frameworks/braket.py +132 -0
  33. quantbom/frameworks/cirq.py +106 -0
  34. quantbom/frameworks/cudaq.py +223 -0
  35. quantbom/frameworks/pennylane.py +116 -0
  36. quantbom/frameworks/qiskit.py +67 -0
  37. quantbom/frameworks/qrisp.py +267 -0
  38. quantbom/harness.json +1730 -0
  39. quantbom/harness.py +88 -0
  40. quantbom-0.6.0.dist-info/METADATA +207 -0
  41. quantbom-0.6.0.dist-info/RECORD +45 -0
  42. quantbom-0.6.0.dist-info/WHEEL +5 -0
  43. quantbom-0.6.0.dist-info/entry_points.txt +2 -0
  44. quantbom-0.6.0.dist-info/licenses/LICENSE +202 -0
  45. 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)}