"""
quantum_safe.audit.compliance
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
NIST SP 800-208 and FIPS 203/204/205 compliance report generation.
This module maps scanner findings to specific NIST guidance documents
so that a security team or auditor can trace each finding to a published
requirement. The output is a structured compliance report that maps to
the CISA Post-Quantum Cryptography Migration checklist.
References
----------
NIST SP 800-208: Recommendation for Stateful Hash-Based Signature Schemes
FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard
FIPS 204: Module-Lattice-Based Digital Signature Standard
FIPS 205: Stateless Hash-Based Digital Signature Standard
CISA PQC Checklist: https://www.cisa.gov/quantum
Compliance levels
-----------------
COMPLIANT: Meets all applicable requirements
PARTIAL: Meets some requirements; specific gaps identified
NON_COMPLIANT: Does not meet applicable requirements
NOT_APPLICABLE: Requirement does not apply to this deployment
"""
from __future__ import annotations
import datetime
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, ClassVar
from quantum_safe.migrate.scanner import Finding, ScanReport, Severity
class ComplianceLevel(str, Enum):
COMPLIANT = "COMPLIANT"
PARTIAL = "PARTIAL"
NON_COMPLIANT = "NON_COMPLIANT"
NOT_APPLICABLE = "NOT_APPLICABLE"
@dataclass
class ComplianceControl:
"""A single compliance control / requirement.
Attributes:
control_id: Identifier from the source standard (e.g. "FIPS203-6.1").
standard: The standard or guidance document.
title: Short title of the control.
description: Full description of the requirement.
level: Compliance level assessed for this control.
evidence: Specific evidence supporting the level assessment.
remediation: Steps to achieve compliance if not already compliant.
"""
control_id: str
standard: str
title: str
description: str
level: ComplianceLevel
evidence: list[str] = field(default_factory=list)
remediation: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"control_id": self.control_id,
"standard": self.standard,
"title": self.title,
"description": self.description,
"level": self.level.value,
"evidence": self.evidence,
"remediation": self.remediation,
}
[docs]
@dataclass
class ComplianceReport:
"""Full NIST compliance report for a codebase.
Attributes:
generated_at: ISO 8601 timestamp.
target: What was assessed.
controls: All evaluated controls.
overall_level: Rolled-up compliance level.
"""
generated_at: str
target: str
controls: list[ComplianceControl]
overall_level: ComplianceLevel
metadata: dict[str, Any] = field(default_factory=dict)
@property
def non_compliant_controls(self) -> list[ComplianceControl]:
return [c for c in self.controls if c.level == ComplianceLevel.NON_COMPLIANT]
@property
def partial_controls(self) -> list[ComplianceControl]:
return [c for c in self.controls if c.level == ComplianceLevel.PARTIAL]
def to_dict(self) -> dict[str, Any]:
return {
"generated_at": self.generated_at,
"target": self.target,
"overall_level": self.overall_level.value,
"controls": [c.to_dict() for c in self.controls],
"summary": {
"total": len(self.controls),
"compliant": sum(1 for c in self.controls if c.level == ComplianceLevel.COMPLIANT),
"partial": len(self.partial_controls),
"non_compliant": len(self.non_compliant_controls),
"not_applicable": sum(
1 for c in self.controls if c.level == ComplianceLevel.NOT_APPLICABLE
),
},
"metadata": self.metadata,
}
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent)
[docs]
def summary_lines(self) -> list[str]:
"""Human-readable summary for terminal output."""
lines = [
"NIST PQC Compliance Report",
f"Target: {self.target}",
f"Generated: {self.generated_at}",
f"Overall: {self.overall_level.value}",
"",
]
d = self.to_dict()["summary"]
lines.append(
f"Controls: {d['total']} total, {d['compliant']} compliant, "
f"{d['partial']} partial, {d['non_compliant']} non-compliant"
)
lines.append("")
if self.non_compliant_controls:
lines.append("Non-compliant controls:")
for c in self.non_compliant_controls:
lines.append(f" [{c.control_id}] {c.title}")
lines.append(f" -> {c.remediation}")
return lines
[docs]
class NISTComplianceChecker:
"""Evaluates NIST SP 800-208 / FIPS 203/204/205 compliance.
Takes a ScanReport and produces a ComplianceReport that maps each
finding to a specific NIST control.
Usage::
from quantum_safe.audit.compliance import NISTComplianceChecker
from quantum_safe.migrate.scanner import Scanner
scan = Scanner.scan_directory("./src")
report = NISTComplianceChecker.check(scan, target="./src")
print(report.to_json())
"""
# NIST controls we evaluate. Each has a check function.
# The structure is intentionally readable so auditors can review it.
_CONTROLS: ClassVar[list[dict[str, Any]]] = [
{
"id": "FIPS203-2.1",
"standard": "FIPS 203",
"title": "ML-KEM key encapsulation",
"description": "Key encapsulation shall use ML-KEM-512, ML-KEM-768, or ML-KEM-1024 "
"as specified in FIPS 203. RSA and ECDH are not quantum-safe.",
"check_rule_ids": {"QS001", "QS002", "QS003", "QS011", "QS016"},
"remediation": "Replace RSA/ECDH key exchange with HybridKEM() "
"(X25519+ML-KEM-768 by default).",
},
{
"id": "FIPS204-2.1",
"standard": "FIPS 204",
"title": "ML-DSA digital signatures",
"description": "Digital signatures shall use ML-DSA-44, ML-DSA-65, or ML-DSA-87 "
"as specified in FIPS 204. RSA-PSS, ECDSA, DSA are not quantum-safe.",
"check_rule_ids": {"QS001", "QS010", "QS015"},
"remediation": "Replace ECDSA/RSA/DSA signatures with HybridSign() "
"(Ed25519+ML-DSA-65 by default).",
},
{
"id": "FIPS205-2.1",
"standard": "FIPS 205",
"title": "SLH-DSA stateless hash-based signatures",
"description": "Where long-term signing keys with hash-based security are required, "
"SLH-DSA variants from FIPS 205 should be considered as an alternative "
"to ML-DSA.",
"check_rule_ids": set(), # No specific scanner rule for SLH-DSA absence
"remediation": "Consider SLH-DSA for long-lived code-signing keys as a "
"non-lattice alternative.",
"informational": True,
},
{
"id": "SP800208-3.1",
"standard": "NIST SP 800-208",
"title": "Deprecated algorithm deprecation",
"description": "SHA-1 and MD5 must not be used for any cryptographic purpose. "
"AES-128 should be phased out in favor of AES-256.",
"check_rule_ids": {"QS030", "QS031", "QS020"},
"remediation": "Replace SHA-1/MD5 with SHA-256 or SHA3-256. "
"Replace AES-128 with AES-256.",
},
{
"id": "CISA-PQC-1",
"standard": "CISA PQC Migration Checklist",
"title": "Cryptographic inventory",
"description": "Organizations shall maintain an inventory of all cryptographic "
"assets, including algorithm, key size, and usage context.",
"check_rule_ids": set(),
"remediation": "Run qs-audit scan regularly and store results in your SBOM. "
"Use SBOMEnricher.enrich() to annotate dependencies.",
"informational": True,
},
{
"id": "CISA-PQC-2",
"standard": "CISA PQC Migration Checklist",
"title": "Hybrid transition",
"description": "During the transition period, hybrid classical+PQC algorithms "
"should be used to maintain backward compatibility while gaining "
"quantum resistance.",
"check_rule_ids": {"QS001", "QS010", "QS011"},
"remediation": "Use HybridKEM() and HybridSign() which combine classical and "
"PQC algorithms per IETF hybrid-design draft.",
},
{
"id": "CISA-PQC-3",
"standard": "CISA PQC Migration Checklist",
"title": "JWT and token security",
"description": "JWT tokens signed with RS256, ES256, or HS256 are not quantum-safe. "
"Transition to ML-DSA-based JWT signing.",
"check_rule_ids": {"QS040"},
"remediation": "Replace jwt.encode(..., algorithm='RS256') with "
"JWTSigner from quantum_safe.protocols.jwt.",
},
]
[docs]
@classmethod
def check(
cls,
scan_report: ScanReport,
target: str = "",
metadata: dict[str, Any] | None = None,
) -> ComplianceReport:
"""Generate a NIST compliance report from a ScanReport.
Args:
scan_report: Results from Scanner.scan_file/directory/source.
target: What was scanned (for the report header).
metadata: Arbitrary metadata to include in the report.
Returns:
ComplianceReport with one ComplianceControl per NIST requirement.
"""
# Index findings by rule_id for fast lookup
findings_by_rule: dict[str, list[Finding]] = {}
for f in scan_report.findings:
findings_by_rule.setdefault(f.rule_id, []).append(f)
controls: list[ComplianceControl] = []
for ctrl_def in cls._CONTROLS:
rule_ids = ctrl_def["check_rule_ids"]
is_info = ctrl_def.get("informational", False)
# Collect findings that triggered this control
triggered_findings: list[Finding] = []
for rule_id in rule_ids:
triggered_findings.extend(findings_by_rule.get(rule_id, []))
# Determine compliance level
if is_info and not triggered_findings:
level = ComplianceLevel.NOT_APPLICABLE
evidence = ["Informational control - no specific check performed"]
elif not rule_ids:
# No rules to check — mark as informational
level = ComplianceLevel.NOT_APPLICABLE
evidence = ["Manual review required"]
elif not triggered_findings:
level = ComplianceLevel.COMPLIANT
evidence = ["No classical crypto usage detected for this control"]
else:
# Have findings — is it PARTIAL or NON_COMPLIANT?
critical_or_high = [f for f in triggered_findings if f.severity >= Severity.HIGH]
if critical_or_high:
level = ComplianceLevel.NON_COMPLIANT
else:
level = ComplianceLevel.PARTIAL
evidence = [
f"{f.file}:{f.line} [{f.rule_id}] {f.message}"
for f in triggered_findings[:5] # cap evidence list
]
if len(triggered_findings) > 5:
evidence.append(f"... and {len(triggered_findings) - 5} more findings")
controls.append(
ComplianceControl(
control_id=ctrl_def["id"],
standard=ctrl_def["standard"],
title=ctrl_def["title"],
description=ctrl_def["description"],
level=level,
evidence=evidence,
remediation=ctrl_def.get("remediation", ""),
)
)
# Roll up overall level
levels = [c.level for c in controls]
if any(lvl == ComplianceLevel.NON_COMPLIANT for lvl in levels):
overall = ComplianceLevel.NON_COMPLIANT
elif any(lvl == ComplianceLevel.PARTIAL for lvl in levels):
overall = ComplianceLevel.PARTIAL
else:
overall = ComplianceLevel.COMPLIANT
return ComplianceReport(
generated_at=datetime.datetime.now(datetime.timezone.utc)
.isoformat()
.replace("+00:00", "Z"),
target=target or scan_report.root,
controls=controls,
overall_level=overall,
metadata=metadata or {},
)