"""
quantum_safe.audit.sbom
~~~~~~~~~~~~~~~~~~~~~~~~
CycloneDX Software Bill of Materials (SBOM) enrichment.
Takes an existing CycloneDX SBOM (JSON format, spec 1.4+) and adds a
pqc-readiness property to each component that tells you whether that
dependency uses quantum-safe cryptography.
What it checks
--------------
For each component in the SBOM, we check:
1. Is it a known cryptography library? (cryptography, pycryptodome, etc.)
2. If so, which version? PQC support was added at specific versions.
3. Does the component name match our knowledge base of PQC-ready libs?
This is necessarily best-effort — we can't run the code of each dependency.
The output is tagged READY / PARTIAL / NOT_READY / UNKNOWN.
CycloneDX 1.4+ component properties
-------------------------------------
We add a property named "quantum-safe:pqc-readiness" with value
READY | PARTIAL | NOT_READY | UNKNOWN.
We also add:
"quantum-safe:reason" — why we assigned that status
"quantum-safe:since" — version at which PQC support was added (if known)
"quantum-safe:action" — recommended action
CycloneDX spec: https://cyclonedx.org/docs/1.4/json/
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import Any
class PQCReadiness(str, Enum):
"""PQC readiness assessment for a software component."""
READY = "READY" # Component is PQC-ready
PARTIAL = "PARTIAL" # Component has some PQC support (hybrid, etc.)
NOT_READY = "NOT_READY" # Component uses only classical crypto
UNKNOWN = "UNKNOWN" # Cannot determine
[docs]
@dataclass
class ComponentAssessment:
"""PQC readiness assessment for a single SBOM component."""
name: str
version: str | None
readiness: PQCReadiness
reason: str
since_version: str | None = None # version at which PQC was added
action: str = ""
# Knowledge base of known library PQC support.
# Format: {package_name: {min_version: PQCReadiness, reason, since, action}}
# Versions use simple string comparison — this works for well-formatted semver.
_KNOWN_LIBRARIES: dict[str, dict[str, Any]] = {
# cryptography (PyCA) — PQC support added in 44.x
"cryptography": {
"pqc_since": "44.0.0",
"status_before": PQCReadiness.NOT_READY,
"status_at_or_after": PQCReadiness.PARTIAL,
"reason_before": "cryptography < 44.0.0 has no PQC support",
"reason_after": "cryptography >= 44.0.0 includes ML-KEM, ML-DSA via OpenSSL 3.x",
"since": "44.0.0",
"action_before": "Upgrade to cryptography >= 44.0.0 and add quantum-safe wrappers",
"action_after": "Use quantum_safe.HybridKEM/HybridSign for hybrid mode",
},
# quantum-safe (this library) — always READY
"quantum-safe": {
"pqc_since": "0.1.0",
"status_before": PQCReadiness.NOT_READY,
"status_at_or_after": PQCReadiness.READY,
"reason_before": "quantum-safe < 0.1.0 is pre-release",
"reason_after": "quantum-safe provides HybridKEM, HybridSign, and migration tooling",
"since": "0.1.0",
"action_before": "Upgrade to quantum-safe >= 0.1.0",
"action_after": "No action required",
},
# liboqs-python — full PQC, no hybrid
"liboqs-python": {
"pqc_since": "0.9.0",
"status_before": PQCReadiness.NOT_READY,
"status_at_or_after": PQCReadiness.PARTIAL,
"reason_before": "liboqs-python < 0.9.0 predates ML-KEM standardization",
"reason_after": "liboqs-python provides ML-KEM/ML-DSA but no hybrid combiner",
"since": "0.9.0",
"action_before": "Upgrade to liboqs-python >= 0.9.0",
"action_after": "Wrap with quantum_safe for hybrid mode and typed API",
},
# pycryptodome / pycrypto — classical only
"pycryptodome": {
"pqc_since": None, # no PQC support yet
"status_before": PQCReadiness.NOT_READY,
"status_at_or_after": PQCReadiness.NOT_READY,
"reason_before": "pycryptodome has no PQC support",
"reason_after": "pycryptodome has no PQC support",
"since": None,
"action_before": "Replace with quantum_safe",
"action_after": "Replace with quantum_safe",
},
"pycrypto": {
"pqc_since": None,
"status_before": PQCReadiness.NOT_READY,
"status_at_or_after": PQCReadiness.NOT_READY,
"reason_before": "pycrypto is unmaintained and has no PQC support",
"reason_after": "pycrypto is unmaintained and has no PQC support",
"since": None,
"action_before": "Replace with quantum_safe immediately — pycrypto is abandoned",
"action_after": "Replace with quantum_safe immediately — pycrypto is abandoned",
},
# PyJWT — classical JWT signing only
"PyJWT": {
"pqc_since": None,
"status_before": PQCReadiness.NOT_READY,
"status_at_or_after": PQCReadiness.NOT_READY,
"reason_before": "PyJWT supports only classical JWT algorithms (RS256, ES256, HS256)",
"reason_after": "PyJWT supports only classical JWT algorithms",
"since": None,
"action_before": "Replace with quantum_safe.protocols.jwt.JWTSigner/JWTVerifier",
"action_after": "Replace with quantum_safe.protocols.jwt.JWTSigner/JWTVerifier",
},
# paramiko (SSH) — classical only
"paramiko": {
"pqc_since": None,
"status_before": PQCReadiness.NOT_READY,
"status_at_or_after": PQCReadiness.NOT_READY,
"reason_before": "paramiko uses classical RSA/ECDSA for SSH key exchange",
"reason_after": "paramiko uses classical RSA/ECDSA for SSH key exchange",
"since": None,
"action_before": "Consider quantum-safe SSH alternatives for long-lived connections",
"action_after": "Consider quantum-safe SSH alternatives",
},
# requests / httpx — depends on underlying TLS stack
"requests": {
"pqc_since": None,
"status_before": PQCReadiness.UNKNOWN,
"status_at_or_after": PQCReadiness.UNKNOWN,
"reason_before": "requests delegates TLS to urllib3 and the system OpenSSL",
"reason_after": "requests delegates TLS to urllib3 and the system OpenSSL",
"since": None,
"action_before": "Enable hybrid TLS at the OpenSSL/OQS level; requests inherits it",
"action_after": "Enable hybrid TLS at the OpenSSL/OQS level",
},
"httpx": {
"pqc_since": None,
"status_before": PQCReadiness.UNKNOWN,
"status_at_or_after": PQCReadiness.UNKNOWN,
"reason_before": "httpx delegates TLS to the system SSL stack",
"reason_after": "httpx delegates TLS to the system SSL stack",
"since": None,
"action_before": "Enable hybrid TLS at the OpenSSL/OQS level",
"action_after": "Enable hybrid TLS at the OpenSSL/OQS level",
},
}
def _version_ge(v1: str, v2: str) -> bool:
"""Simple version comparison: return True if v1 >= v2.
Handles semver-ish strings (major.minor.patch). Not robust for
pre-release tags — good enough for the SBOM use case.
"""
def parts(v: str) -> tuple[int, ...]:
cleaned = v.strip().lstrip("v")
try:
return tuple(int(x) for x in cleaned.split(".")[:3])
except ValueError:
return (0, 0, 0)
return parts(v1) >= parts(v2)
def _assess_component(name: str, version: str | None) -> ComponentAssessment:
"""Assess PQC readiness for a single component."""
info = _KNOWN_LIBRARIES.get(name) or _KNOWN_LIBRARIES.get(name.lower())
if info is None:
return ComponentAssessment(
name=name,
version=version,
readiness=PQCReadiness.UNKNOWN,
reason=f"'{name}' is not in the PQC knowledge base",
action="Check manually whether this library uses cryptographic primitives",
)
if info["pqc_since"] is None:
# No PQC support expected ever
return ComponentAssessment(
name=name,
version=version,
readiness=info["status_at_or_after"], # still NOT_READY or UNKNOWN
reason=info["reason_after"],
since_version=None,
action=info["action_after"],
)
if version is None:
return ComponentAssessment(
name=name,
version=None,
readiness=PQCReadiness.UNKNOWN,
reason=f"Version unknown - cannot determine PQC readiness for '{name}'",
since_version=info["pqc_since"],
action=f"Pin to a specific version and check if >= {info['pqc_since']}",
)
if _version_ge(version, info["pqc_since"]):
return ComponentAssessment(
name=name,
version=version,
readiness=info["status_at_or_after"],
reason=info["reason_after"],
since_version=info["pqc_since"],
action=info["action_after"],
)
else:
return ComponentAssessment(
name=name,
version=version,
readiness=info["status_before"],
reason=info["reason_before"],
since_version=info["pqc_since"],
action=info["action_before"],
)
[docs]
class SBOMEnricher:
"""Enriches a CycloneDX SBOM with PQC-readiness annotations.
Usage::
with open("sbom.json") as f:
sbom = json.load(f)
enriched, assessments = SBOMEnricher.enrich(sbom)
with open("sbom-pqc.json", "w") as f:
json.dump(enriched, f, indent=2)
for a in assessments:
if a.readiness == PQCReadiness.NOT_READY:
print(f"NOT READY: {a.name} {a.version} - {a.action}")
"""
[docs]
@classmethod
def enrich(
cls,
sbom: dict[str, Any],
) -> tuple[dict[str, Any], list[ComponentAssessment]]:
"""Enrich a CycloneDX SBOM with PQC-readiness properties.
Args:
sbom: Parsed CycloneDX JSON SBOM (dict).
Returns:
(enriched_sbom, assessments):
enriched_sbom: The input SBOM with pqc-readiness properties added.
assessments: One ComponentAssessment per component.
"""
import copy
enriched = copy.deepcopy(sbom)
assessments: list[ComponentAssessment] = []
components = enriched.get("components", [])
for component in components:
name = component.get("name", "")
version = component.get("version")
assessment = _assess_component(name, version)
assessments.append(assessment)
# Add properties to the component
props = component.setdefault("properties", [])
# Remove any existing qs properties (idempotent enrichment)
props[:] = [p for p in props if not p.get("name", "").startswith("quantum-safe:")]
props.append(
{
"name": "quantum-safe:pqc-readiness",
"value": assessment.readiness.value,
}
)
props.append({"name": "quantum-safe:reason", "value": assessment.reason})
props.append({"name": "quantum-safe:action", "value": assessment.action})
if assessment.since_version:
props.append({"name": "quantum-safe:since", "value": assessment.since_version})
# Add a top-level metadata note
meta = enriched.setdefault("metadata", {})
meta_props = meta.setdefault("properties", [])
meta_props[:] = [p for p in meta_props if not p.get("name", "").startswith("quantum-safe:")]
meta_props.append(
{
"name": "quantum-safe:enriched-by",
"value": "quantum-safe v0.1.0",
}
)
ready_count = sum(1 for a in assessments if a.readiness == PQCReadiness.READY)
partial_count = sum(1 for a in assessments if a.readiness == PQCReadiness.PARTIAL)
not_ready_count = sum(1 for a in assessments if a.readiness == PQCReadiness.NOT_READY)
meta_props.append(
{
"name": "quantum-safe:summary",
"value": f"READY={ready_count},PARTIAL={partial_count},NOT_READY={not_ready_count}",
}
)
return enriched, assessments
[docs]
@classmethod
def from_requirements(
cls,
requirements_txt: str,
) -> list[ComponentAssessment]:
"""Assess PQC readiness from a requirements.txt string.
Does not require a full SBOM — useful as a quick check during CI.
Parses lines of the form::
cryptography==44.0.5
pycryptodome>=3.20.0
quantum-safe==0.1.0
Args:
requirements_txt: Contents of a requirements.txt file.
Returns:
List of ComponentAssessment objects.
"""
assessments = []
for line in requirements_txt.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Strip extras like [crypto] and environment markers
line = line.split(";")[0].strip()
line = re.sub(r"\[.*?\]", "", line).strip()
# Parse name and version
for sep in ("==", ">=", "<=", "~=", "!=", ">", "<"):
if sep in line:
parts = line.split(sep, 1)
name = parts[0].strip()
version = parts[1].strip().split(",")[0].strip()
break
else:
name = line
version = None
if name:
assessments.append(_assess_component(name, version))
return assessments