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