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/_merge.py ADDED
@@ -0,0 +1,187 @@
1
+ """Bundled environment-SBOM merge for the QuantBOM SDK (self-contained — no server deps).
2
+
3
+ Merges a syft-generated (or any CycloneDX) environment SBOM into the collected
4
+ QBOM so one document carries the full dependency inventory plus the quantum
5
+ evidence (QBOM_SPEC §5). Runs client-side, where the environment lives — the
6
+ merge can therefore enrich missing licenses from the *installed* package
7
+ metadata, which a static scanner cannot.
8
+
9
+ Internal module; reached via `quantbom certify --environment / --syft-sbom` or
10
+ `quantbom.certify(..., environment=True / syft_sbom=...)`.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import shutil
16
+ import subprocess
17
+ from typing import Any
18
+
19
+ ENV_TOOL_PROPERTY = "secqdevops:environment-sbom-tool"
20
+
21
+
22
+ def syft_available() -> str | None:
23
+ return shutil.which("syft")
24
+
25
+
26
+ def run_syft(target: str) -> dict[str, Any]:
27
+ """Run a local syft binary and return its CycloneDX JSON output."""
28
+ binary = syft_available()
29
+ if not binary:
30
+ raise RuntimeError("syft not found on PATH (https://github.com/anchore/syft)")
31
+ proc = subprocess.run(
32
+ [binary, "scan", target, "-o", "cyclonedx-json", "--quiet"],
33
+ capture_output=True, text=True, timeout=600,
34
+ )
35
+ if proc.returncode != 0:
36
+ raise RuntimeError(f"syft scan failed: {proc.stderr.strip()[:400]}")
37
+ return json.loads(proc.stdout)
38
+
39
+
40
+ def _dedupe_key(component: dict[str, Any]) -> str:
41
+ purl = component.get("purl") or ""
42
+ if purl:
43
+ return purl.split("?", 1)[0].split("#", 1)[0].lower()
44
+ return f"{component.get('name', '')}@{component.get('version', '')}".lower()
45
+
46
+
47
+ def _is_python_component(component: dict[str, Any]) -> bool:
48
+ if (component.get("purl") or "").startswith("pkg:pypi/"):
49
+ return True
50
+ for prop in component.get("properties", []) or []:
51
+ if prop.get("name", "").endswith("package:type") and prop.get("value") == "python":
52
+ return True
53
+ if prop.get("name", "").endswith(":language") and prop.get("value") == "python":
54
+ return True
55
+ return False
56
+
57
+
58
+ def _license_from_environment(name: str) -> str | None:
59
+ try:
60
+ from importlib.metadata import metadata
61
+ meta = metadata(name)
62
+ except Exception:
63
+ return None
64
+ lic = (meta.get("License") or "").strip()
65
+ if lic and lic.upper() not in ("UNKNOWN", "NONE") and len(lic) <= 120:
66
+ return lic
67
+ for classifier in meta.get_all("Classifier") or []:
68
+ if classifier.startswith("License ::"):
69
+ return classifier.split("::")[-1].strip()
70
+ return None
71
+
72
+
73
+ def _strip_local_references(component: dict[str, Any]) -> int:
74
+ """Drop externalReferences that point into the producer's filesystem.
75
+
76
+ syft copies pip's direct_url for packages installed from a local directory
77
+ or in editable mode (`file:///home/<user>/...`). That identifies the
78
+ producer's machine, not the component, and would travel to the service and
79
+ to every module the QBOM is handed to. Returns how many were removed.
80
+ """
81
+ refs = component.get("externalReferences")
82
+ if not refs:
83
+ return 0
84
+ kept = [r for r in refs if not str(r.get("url", "")).lower().startswith("file:")]
85
+ if kept:
86
+ component["externalReferences"] = kept
87
+ else:
88
+ del component["externalReferences"]
89
+ return len(refs) - len(kept)
90
+
91
+
92
+ def merge_environment(qbom: dict[str, Any], env_sbom: dict[str, Any], tool_label: str | None = None,
93
+ include_all: bool = False) -> dict[str, Any]:
94
+ """Merge env components/dependencies into the QBOM (in place). Returns stats.
95
+
96
+ The QBOM keeps its identity (metadata, serialNumber, specVersion). Colliding
97
+ components keep the env entry's bom-ref — the dependency graph stays intact —
98
+ and inherit the QBOM entry's properties/licenses. By default only
99
+ identifiable package components are merged (type:file inventory and
100
+ purl-less binary hits are skipped and counted in the stats).
101
+ """
102
+ stats = {"env_components": 0, "added": 0, "merged": 0,
103
+ "skipped_files": 0, "skipped_unidentified": 0,
104
+ "licenses_enriched": 0, "purls_synthesized": 0,
105
+ "missing_license": 0, "missing_purl": 0, "local_refs_removed": 0}
106
+
107
+ merged: dict[str, dict[str, Any]] = {}
108
+ for comp in qbom.get("components", []):
109
+ merged[_dedupe_key(comp)] = comp
110
+
111
+ for comp in env_sbom.get("components", []) or []:
112
+ stats["env_components"] += 1
113
+ stats["local_refs_removed"] += _strip_local_references(comp)
114
+ if not comp.get("purl") and _is_python_component(comp) and comp.get("name") and comp.get("version"):
115
+ comp["purl"] = f"pkg:pypi/{comp['name']}@{comp['version']}"
116
+ stats["purls_synthesized"] += 1
117
+ if not include_all:
118
+ if comp.get("type") == "file":
119
+ stats["skipped_files"] += 1
120
+ continue
121
+ if not comp.get("purl"):
122
+ stats["skipped_unidentified"] += 1
123
+ continue
124
+ key = _dedupe_key(comp)
125
+ ours = merged.get(key)
126
+ if ours is None:
127
+ merged[key] = comp
128
+ stats["added"] += 1
129
+ continue
130
+ if not comp.get("licenses") and ours.get("licenses"):
131
+ comp["licenses"] = ours["licenses"]
132
+ have = {(p.get("name"), p.get("value")) for p in comp.setdefault("properties", [])}
133
+ for prop in ours.get("properties", []) or []:
134
+ if (prop.get("name"), prop.get("value")) not in have:
135
+ comp["properties"].append(prop)
136
+ if not comp["properties"]:
137
+ del comp["properties"]
138
+ merged[key] = comp
139
+ stats["merged"] += 1
140
+
141
+ for comp in merged.values():
142
+ if not comp.get("licenses") and _is_python_component(comp):
143
+ lic = _license_from_environment(comp.get("name", ""))
144
+ if lic:
145
+ comp["licenses"] = [{"license": {"name": lic}}]
146
+ stats["licenses_enriched"] += 1
147
+ if not comp.get("licenses"):
148
+ stats["missing_license"] += 1
149
+ if not comp.get("purl"):
150
+ stats["missing_purl"] += 1
151
+
152
+ qbom["components"] = sorted(merged.values(), key=_dedupe_key)
153
+
154
+ if env_sbom.get("dependencies"):
155
+ # keep only edges whose refs resolve to merged components (skipped
156
+ # file/unidentified entries would otherwise leave dangling refs)
157
+ kept_refs = {c.get("bom-ref") for c in qbom["components"] if c.get("bom-ref")}
158
+ kept_refs.add((env_sbom.get("metadata", {}).get("component", {}) or {}).get("bom-ref"))
159
+ deps = []
160
+ for dep in env_sbom["dependencies"]:
161
+ if dep.get("ref") not in kept_refs:
162
+ continue
163
+ entry = {"ref": dep["ref"]}
164
+ if dep.get("dependsOn"):
165
+ entry["dependsOn"] = [r for r in dep["dependsOn"] if r in kept_refs]
166
+ deps.append(entry)
167
+ if deps:
168
+ qbom["dependencies"] = deps
169
+
170
+ env_tools = (env_sbom.get("metadata", {}).get("tools", {}) or {})
171
+ env_tool_components = env_tools.get("components", []) if isinstance(env_tools, dict) else []
172
+ label = tool_label
173
+ qbom_tools = qbom.setdefault("metadata", {}).setdefault("tools", {})
174
+ if isinstance(qbom_tools, dict):
175
+ tool_list = qbom_tools.setdefault("components", [])
176
+ for tool in env_tool_components:
177
+ entry = {"type": "application", "name": tool.get("name", "env-sbom-tool"), "version": tool.get("version", "")}
178
+ if entry not in tool_list:
179
+ tool_list.append(entry)
180
+ if not label:
181
+ label = f"{entry['name']}@{entry['version']}".rstrip("@")
182
+ props = qbom["metadata"].setdefault("properties", [])
183
+ props[:] = [p for p in props if p.get("name") != ENV_TOOL_PROPERTY]
184
+ props.append({"name": ENV_TOOL_PROPERTY, "value": label or "unknown"})
185
+
186
+ stats["total_components"] = len(qbom["components"])
187
+ return stats
quantbom/_qir.py ADDED
@@ -0,0 +1,224 @@
1
+ """QIR — the second standards-named IR (HARNESS_KITS_PLAN.md K5), by the QDK route.
2
+
3
+ Qiskit 2 ──qasm3.dumps──▶ OpenQASM 3 ──qdk.openqasm.compile──▶ QIR (LLVM IR)
4
+ │ │
5
+ program.qasm program.ll ── pyqir (inspect)
6
+ (hash-bound) (hash-bound) ── qirrunner (execute)
7
+
8
+ QIR is *derived*: a second compiler (Microsoft's QDK) sits in the chain, so a
9
+ release never ships QIR on trust. `assess()` checks the QIR against the
10
+ circuit it came from and the counts the release actually measured —
11
+ qubit and result counts, and the output distribution of qirrunner within a
12
+ tolerance — and the gate's QNT-07 fails the release on a mismatch.
13
+
14
+ Conventions pinned, not assumed (the rule since the PennyLane bit-order bug):
15
+
16
+ * Output order. The QDK's `OutputSemantics.Qiskit` records results in the
17
+ order Qiskit's measured string reads (classical bit 0 rightmost, registers
18
+ reversed). `probe_order()` compiles and runs X-on-qubit-0-of-2 and requires
19
+ "01" before any comparison; anything else is undecidable, never a guess.
20
+ * Profiles. Static circuits compile to the Base profile, dynamic ones to
21
+ Adaptive_RI; the profile is read back from the module's own attribute.
22
+ * Gate names. The QDK rewrites gates (`p` -> `rz`, `u` -> `rz`/`ry`, `cp` ->
23
+ `rz`/`cx`), so gate-name equality is not a consistency criterion; both
24
+ inventories are recorded for the auditor, counts and behaviour are judged.
25
+
26
+ Everything here is optional: install `quantbom[qir]` (qdk, pyqir, qirrunner).
27
+ Without it, capture records why QIR is absent and QNT-07 is N-A.
28
+
29
+ qirrunner executes in a child interpreter: its JIT and CUDA-Q's bundled
30
+ LLVM/MLIR cannot share one process (K1 found a segmentation fault inside
31
+ `qirrunner.run` whenever `cudaq` had been imported first). The QDK's Python
32
+ telemetry is switched off before the QDK is imported: an evidence tool does
33
+ not phone home.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import math
39
+ import os
40
+ import re
41
+ import subprocess
42
+ import sys
43
+ import tempfile
44
+ from collections import Counter
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ from .frameworks import Undecidable
49
+
50
+ os.environ.setdefault("QDK_PYTHON_TELEMETRY", "none") # read by qdk.telemetry at import
51
+
52
+ SCHEMA = "secqdevops.qir-consistency/v1"
53
+ _RUNNER = (
54
+ "import json, sys, qirrunner\n"
55
+ "h = qirrunner.OutputHandler()\n"
56
+ "qirrunner.run(sys.argv[1], shots=int(sys.argv[2]), rng_seed=int(sys.argv[3]), output_fn=h.handle)\n"
57
+ "sys.stdout.write(json.dumps(str(h.get_output())))\n"
58
+ )
59
+ PROBE_QASM = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nbit[2] c;\nqubit[2] q;\nx q[0];\nc[0] = measure q[0];\nc[1] = measure q[1];\n'
60
+ PROBE_EXPECT = "01" # Qiskit's reading: qubit 0 is the rightmost character
61
+ RUN_SEED = 7
62
+ _PROFILE = re.compile(r'"qir_profiles"="(\w+)"')
63
+
64
+
65
+ def toolchain() -> tuple[bool, str | None]:
66
+ """(available, reason-if-not): the three packages the QIR route needs."""
67
+ missing = []
68
+ for mod, dist in (("qdk.openqasm", "qdk"), ("pyqir", "pyqir"), ("qirrunner", "qirrunner")):
69
+ try:
70
+ __import__(mod)
71
+ except Exception:
72
+ missing.append(dist)
73
+ if missing:
74
+ return False, f"QIR not emitted: {', '.join(missing)} not installed (pip install 'quantbom[qir]')"
75
+ return True, None
76
+
77
+
78
+ def compile_qir(qasm3_text: str, dynamic: bool) -> tuple[str, str]:
79
+ """(QIR text, profile) for an OpenQASM 3 program — Base for static circuits,
80
+ Adaptive_RI for dynamic ones; Qiskit output semantics so the recorded
81
+ order is the measured order."""
82
+ import qdk
83
+ import qdk.openqasm as oq
84
+
85
+ profile = qdk.TargetProfile.Adaptive_RI if dynamic else qdk.TargetProfile.Base
86
+ text = str(oq.compile(qasm3_text, target_profile=profile, output_semantics=oq.OutputSemantics.Qiskit))
87
+ return text, profile_of(text)
88
+
89
+
90
+ def profile_of(qir_text: str) -> str:
91
+ m = _PROFILE.search(qir_text)
92
+ return m.group(1) if m else "unknown"
93
+
94
+
95
+ def inspect_qir(qir_text: str) -> dict[str, Any]:
96
+ """What the QIR module declares: entry point, qubits, results, profile, and
97
+ the quantum-instruction calls it makes (names and counts)."""
98
+ import pyqir
99
+
100
+ module = pyqir.Module.from_ir(pyqir.Context(), qir_text)
101
+ for f in module.functions:
102
+ if pyqir.is_entry_point(f):
103
+ calls = [inst.callee.name for bb in f.basic_blocks for inst in bb.instructions if isinstance(inst, pyqir.Call)]
104
+ qis = Counter(re.sub(r"^__quantum__qis__", "", c) for c in calls if c.startswith("__quantum__qis__"))
105
+ return {
106
+ "entry_point": f.name,
107
+ "qubits": int(pyqir.required_num_qubits(f) or 0),
108
+ "results": int(pyqir.required_num_results(f) or 0),
109
+ "profile": profile_of(qir_text),
110
+ "adaptive": any(c == "__quantum__rt__read_result" for c in calls),
111
+ "qis_calls": dict(sorted(qis.items())),
112
+ }
113
+ raise Undecidable("QIR module has no entry point")
114
+
115
+
116
+ def run_qir(qir_text: str, shots: int, seed: int = RUN_SEED) -> dict[str, int]:
117
+ """Execute with qirrunner in a child interpreter; keys are the recorded bits in output order."""
118
+ with tempfile.TemporaryDirectory() as d:
119
+ path = Path(d) / "program.ll"
120
+ path.write_text(qir_text, encoding="utf-8")
121
+ proc = subprocess.run([sys.executable, "-c", _RUNNER, str(path), str(int(shots)), str(int(seed))],
122
+ capture_output=True, text=True, timeout=600)
123
+ if proc.returncode != 0:
124
+ raise Undecidable(f"qirrunner failed (exit {proc.returncode}): {proc.stderr.strip()[-300:]}")
125
+ raw = json.loads(proc.stdout or '""')
126
+ counts: Counter = Counter()
127
+ cur: list[str] | None = None
128
+ for line in raw.splitlines():
129
+ parts = line.split("\t")
130
+ if parts[0] == "START":
131
+ cur = []
132
+ elif parts[:2] == ["OUTPUT", "RESULT"] and cur is not None:
133
+ cur.append(parts[2])
134
+ elif parts[0] == "END" and cur is not None:
135
+ counts["".join(cur)] += 1
136
+ cur = None
137
+ return dict(counts)
138
+
139
+
140
+ def probe_order() -> str:
141
+ """Pin the output order with a circuit independent of the one under test."""
142
+ text, _ = compile_qir(PROBE_QASM, dynamic=False)
143
+ got = run_qir(text, shots=1)
144
+ if list(got) != [PROBE_EXPECT]:
145
+ raise Undecidable(f"QIR output-order probe read {list(got)!r}, expected {PROBE_EXPECT!r} (Qiskit order); "
146
+ "the recorded order is unknown, so no comparison is made")
147
+ return "qiskit: classical bit 0 is the rightmost character (pinned by probe)"
148
+
149
+
150
+ def tvd(a: dict[str, float], b: dict[str, float]) -> float:
151
+ return 0.5 * sum(abs(a.get(k, 0.0) - b.get(k, 0.0)) for k in set(a) | set(b))
152
+
153
+
154
+ def default_tolerance(outcomes: int, shots: int) -> float:
155
+ """Two independent samples of `shots` over `outcomes` distinct results differ
156
+ by a TVD that grows with sqrt(outcomes / shots); the bound used is
157
+ max(0.1, 1.5 * sqrt(outcomes / shots)) and is recorded in the report."""
158
+ return max(0.1, 1.5 * math.sqrt(max(outcomes, 1) / max(shots, 1)))
159
+
160
+
161
+ def assess(qir_text: str, circuit: Any, counts: dict[str, int], shots: int, tolerance: float | None = None) -> dict[str, Any]:
162
+ """The QIR consistency report. Never raises for an undecidable case: the
163
+ verdict is `consistent`, `inconsistent` (a mismatch the release must not
164
+ ship with) or `inconclusive` (nothing to compare, or the order unknown)."""
165
+ measured = {str(k).replace(" ", ""): int(v) for k, v in (counts or {}).items()}
166
+ report: dict[str, Any] = {
167
+ "schema": SCHEMA,
168
+ "method": ("QIR compiled by the QDK from the hash-bound OpenQASM 3 export (Qiskit output semantics); "
169
+ "qubit and result counts compared with the source circuit; qirrunner's output distribution "
170
+ "compared with the measured counts (total-variation distance); gate inventories recorded, "
171
+ "not compared (the QDK rewrites gates)"),
172
+ "output_order": None,
173
+ "qir": None,
174
+ "circuit": {"qubits": int(getattr(circuit, "num_qubits", 0) or 0),
175
+ "measured_clbits": _measured_clbits(circuit),
176
+ "ops": {k: int(v) for k, v in dict(circuit.count_ops()).items()}},
177
+ "shots": int(shots or 0),
178
+ "distributions": None,
179
+ "tvd": None,
180
+ "tolerance": None,
181
+ "verdict": "inconclusive",
182
+ "reason": None,
183
+ }
184
+ try:
185
+ report["qir"] = inspect_qir(qir_text)
186
+ if report["circuit"]["measured_clbits"] == 0:
187
+ raise Undecidable("the source circuit declares no measurements, so its QIR records no results to compare "
188
+ "(the run measured every qubit); declare measurements to enable the comparison")
189
+ if not shots or not measured:
190
+ raise Undecidable("no measured counts to compare with")
191
+ report["output_order"] = probe_order()
192
+ problems = []
193
+ if report["qir"]["qubits"] != report["circuit"]["qubits"]:
194
+ problems.append(f"QIR declares {report['qir']['qubits']} qubits, the circuit has {report['circuit']['qubits']}")
195
+ if report["qir"]["results"] != report["circuit"]["measured_clbits"]:
196
+ problems.append(f"QIR records {report['qir']['results']} results, the circuit measures {report['circuit']['measured_clbits']} bits")
197
+ qir_counts = run_qir(qir_text, shots)
198
+ n = sum(qir_counts.values()) or 1
199
+ p_qir = {k: v / n for k, v in qir_counts.items()}
200
+ p_meas = {k: v / shots for k, v in measured.items()}
201
+ report["distributions"] = {"measured": measured, "qir": qir_counts}
202
+ distance = tvd(p_qir, p_meas)
203
+ report["tvd"] = round(distance, 6)
204
+ report["tolerance"] = tolerance if tolerance is not None else round(default_tolerance(len(set(p_qir) | set(p_meas)), shots), 6)
205
+ if distance > report["tolerance"]:
206
+ problems.append(f"qirrunner's distribution differs from the measured counts: TVD {distance:.4f} > tolerance {report['tolerance']}")
207
+ if problems:
208
+ report["verdict"] = "inconsistent"
209
+ report["reason"] = "; ".join(problems)
210
+ else:
211
+ report["verdict"] = "consistent"
212
+ except Undecidable as why:
213
+ report["reason"] = str(why)
214
+ except Exception as err: # never block collection, never affirm
215
+ report["reason"] = f"comparison failed: {type(err).__name__}: {err}"
216
+ return report
217
+
218
+
219
+ def _measured_clbits(circuit: Any) -> int:
220
+ bits = set()
221
+ for inst in circuit.data:
222
+ if inst.operation.name == "measure":
223
+ bits.update(inst.clbits)
224
+ return len(bits)
quantbom/_scan.py ADDED
@@ -0,0 +1,118 @@
1
+ """Bundled vulnerability scan for the QuantBOM SDK (self-contained — no server deps).
2
+
3
+ Runs grype over the collected (optionally syft-merged) QBOM and writes the
4
+ normalized findings into its `vulnerabilities[]`, so the gate's CRA-ESR-03
5
+ control and the VEX/VDR evidence carry real data. Affected-component refs are
6
+ remapped onto this QBOM's components by purl. Internal module; reached via
7
+ `quantbom certify --vuln-scan` or `quantbom.certify(..., vuln_scan=True)`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ SCANNER_PROPERTY = "secqdevops:vuln-scanner"
18
+ SEVERITY_ORDER = ["critical", "high", "medium", "low", "info", "none", "unknown"]
19
+
20
+
21
+ def grype_available() -> str | None:
22
+ return shutil.which("grype")
23
+
24
+
25
+ def run_grype(sbom_path: Path) -> dict[str, Any]:
26
+ import tempfile
27
+
28
+ binary = grype_available()
29
+ if not binary:
30
+ raise RuntimeError("grype not found on PATH (https://github.com/anchore/grype)")
31
+ # grype's CycloneDX decoder lags the spec and rejects specVersion 1.7
32
+ # documents. Feed it a temp copy floored to 1.6 — purls/versions (all the
33
+ # scanner reads) are identical; the real QBOM keeps its specVersion.
34
+ doc = json.loads(sbom_path.read_text(encoding="utf-8"))
35
+ scan_target, tmp_path = sbom_path, None
36
+ try:
37
+ if str(doc.get("specVersion", "")) not in ("1.2", "1.3", "1.4", "1.5", "1.6"):
38
+ doc["specVersion"] = "1.6"
39
+ with tempfile.NamedTemporaryFile("w", suffix=".cdx.json", delete=False) as tmp:
40
+ json.dump(doc, tmp)
41
+ tmp_path = Path(tmp.name)
42
+ scan_target = tmp_path
43
+ proc = subprocess.run(
44
+ [binary, f"sbom:{scan_target}", "-o", "cyclonedx-json"],
45
+ capture_output=True, text=True, timeout=900,
46
+ )
47
+ finally:
48
+ if tmp_path:
49
+ tmp_path.unlink(missing_ok=True)
50
+ if proc.returncode != 0:
51
+ raise RuntimeError(f"grype scan failed: {proc.stderr.strip()[-400:]}")
52
+ return json.loads(proc.stdout)
53
+
54
+
55
+ def _purl_key(purl: str | None) -> str | None:
56
+ if not purl:
57
+ return None
58
+ return purl.split("?", 1)[0].split("#", 1)[0].lower()
59
+
60
+
61
+ def _severity(vuln: dict[str, Any]) -> str:
62
+ for rating in vuln.get("ratings", []) or []:
63
+ sev = str(rating.get("severity", "")).lower()
64
+ if sev:
65
+ return sev
66
+ return "unknown"
67
+
68
+
69
+ def apply_findings(qbom: dict[str, Any], scan_doc: dict[str, Any], tool_label: str | None = None) -> dict[str, Any]:
70
+ """Write the scanner's vulnerabilities into the QBOM (in place). Returns stats."""
71
+ scanner_ref_to_purl: dict[str, str] = {}
72
+ for comp in scan_doc.get("components", []) or []:
73
+ key = _purl_key(comp.get("purl"))
74
+ if key and comp.get("bom-ref"):
75
+ scanner_ref_to_purl[comp["bom-ref"]] = key
76
+ ours_by_purl: dict[str, str] = {}
77
+ for comp in qbom.get("components", []) or []:
78
+ key = _purl_key(comp.get("purl"))
79
+ if key:
80
+ ours_by_purl[key] = comp.get("bom-ref") or comp["purl"]
81
+
82
+ stats = {"findings": 0, "matched_components": 0, "unmatched_refs": 0}
83
+ by_severity: dict[str, int] = {}
84
+ vulnerabilities: list[dict[str, Any]] = []
85
+ for vuln in scan_doc.get("vulnerabilities", []) or []:
86
+ stats["findings"] += 1
87
+ by_severity[_severity(vuln)] = by_severity.get(_severity(vuln), 0) + 1
88
+ entry = {k: v for k, v in vuln.items() if k in ("id", "source", "references", "ratings", "description", "advisories")}
89
+ affects = []
90
+ for affected in vuln.get("affects", []) or []:
91
+ purl_key = scanner_ref_to_purl.get(affected.get("ref", ""))
92
+ our_ref = ours_by_purl.get(purl_key) if purl_key else None
93
+ if our_ref:
94
+ affects.append({"ref": our_ref})
95
+ stats["matched_components"] += 1
96
+ else:
97
+ affects.append(affected)
98
+ stats["unmatched_refs"] += 1
99
+ if affects:
100
+ entry["affects"] = affects
101
+ vulnerabilities.append(entry)
102
+
103
+ qbom["vulnerabilities"] = vulnerabilities
104
+
105
+ label = tool_label
106
+ scan_tools = (scan_doc.get("metadata", {}).get("tools", {}) or {})
107
+ tool_components = scan_tools.get("components", []) if isinstance(scan_tools, dict) else []
108
+ if not label:
109
+ for tool in tool_components:
110
+ if tool.get("name"):
111
+ label = f"{tool.get('name')}@{tool.get('version', '')}".rstrip("@")
112
+ break
113
+ props = qbom.setdefault("metadata", {}).setdefault("properties", [])
114
+ props[:] = [p for p in props if p.get("name") != SCANNER_PROPERTY]
115
+ props.append({"name": SCANNER_PROPERTY, "value": label or "unknown"})
116
+
117
+ stats["by_severity"] = {s: by_severity[s] for s in SEVERITY_ORDER if s in by_severity}
118
+ return stats