use crate::model::{NormalizedSbom, SbomFormat};
use serde::{Deserialize, Serialize};
pub(crate) mod ai_shared;
mod bsi;
mod bsi_sbom_for_ai;
mod cisa2026;
mod context;
mod cra;
mod crypto;
mod eo14028;
mod eu_ai_act;
mod eucc;
mod fsct;
mod generic;
mod pci_dss;
mod registry;
mod selector;
mod shared;
mod ssdf;
use context::{ComplianceContext, checker_for};
pub use registry::{
CISA2026_SARIF_RULE_IDS, CNSA2_SARIF_RULE_IDS, COMPLIANCE_SARIF_RULE_IDS,
EO14028_SARIF_RULE_IDS, FDA_SARIF_RULE_IDS, FSCT_SARIF_RULE_IDS, NTIA_SARIF_RULE_IDS,
PCIDSS_SARIF_RULE_IDS, PQC_SARIF_RULE_IDS, RuleMeta, SSDF_SARIF_RULE_IDS, all_rule_ids,
rule_meta,
};
use registry::{REMEDIATION_GENERIC, lookup_static_rule_id};
pub use selector::StandardSelector;
use shared::{
has_known_supplier, has_known_value, is_valid_email_format, known_component_name, known_value,
manufacturer_scope_components, truncate_list,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CraPhase {
Phase1,
Phase2,
}
impl CraPhase {
pub const fn name(self) -> &'static str {
match self {
Self::Phase1 => "Phase 1 (2026)",
Self::Phase2 => "Phase 2 (2027)",
}
}
pub const fn deadline(self) -> &'static str {
match self {
Self::Phase1 => "11 September 2026",
Self::Phase2 => "11 December 2027",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ComplianceLevel {
Minimum,
Standard,
NtiaMinimum,
CraPhase1,
CraPhase2,
FdaMedicalDevice,
NistSsdf,
Eo14028,
Cnsa2,
NistPqc,
BsiTr03183_2,
CraOssSteward,
EuccSubstantial,
EuAiAct,
BsiSbomForAi,
Cisa2026,
PciDss632,
Fsct,
Comprehensive,
}
impl ComplianceLevel {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::Minimum => "Minimum",
Self::Standard => "Standard",
Self::NtiaMinimum => "NTIA Minimum Elements",
Self::CraPhase1 => "EU CRA Phase 1 (2026)",
Self::CraPhase2 => "EU CRA Phase 2 (2027)",
Self::FdaMedicalDevice => "FDA Medical Device",
Self::NistSsdf => "NIST SSDF (SP 800-218)",
Self::Eo14028 => "EO 14028 Section 4",
Self::Cnsa2 => "CNSA 2.0",
Self::NistPqc => "NIST PQC Readiness",
Self::BsiTr03183_2 => "BSI TR-03183-2",
Self::CraOssSteward => "CRA OSS Steward (Art. 24)",
Self::EuccSubstantial => "EUCC Substantial (Reg. 2024/482)",
Self::EuAiAct => "EU AI Act Annex IV Readiness",
Self::BsiSbomForAi => "BSI/G7 SBOM-for-AI Minimum Elements Readiness",
Self::Cisa2026 => "CISA 2026 Minimum Elements",
Self::PciDss632 => "PCI DSS v4.0.1 Req. 6.3.2",
Self::Fsct => "CISA Framing Software Component Transparency (3rd ed.)",
Self::Comprehensive => "Comprehensive",
}
}
#[must_use]
pub const fn short_name(&self) -> &'static str {
match self {
Self::Minimum => "Min",
Self::Standard => "Std",
Self::NtiaMinimum => "NTIA",
Self::CraPhase1 => "CRA-1",
Self::CraPhase2 => "CRA-2",
Self::FdaMedicalDevice => "FDA",
Self::NistSsdf => "SSDF",
Self::Eo14028 => "EO14028",
Self::Cnsa2 => "CNSA2",
Self::NistPqc => "PQC",
Self::BsiTr03183_2 => "BSI",
Self::CraOssSteward => "OSS",
Self::EuccSubstantial => "EUCC",
Self::EuAiAct => "AI-Act",
Self::BsiSbomForAi => "BSI-AI",
Self::Cisa2026 => "CISA26",
Self::PciDss632 => "PCI",
Self::Fsct => "FSCT",
Self::Comprehensive => "Full",
}
}
#[must_use]
pub const fn description(&self) -> &'static str {
match self {
Self::Minimum => "Basic component identification only",
Self::Standard => "Recommended fields for general use",
Self::NtiaMinimum => "NTIA minimum elements for software transparency",
Self::CraPhase1 => {
"CRA reporting obligations — product ID, SBOM format, manufacturer (Art. 14 applies from 11 Sep 2026)"
}
Self::CraPhase2 => {
"Full CRA compliance — adds vulnerability metadata, lifecycle, disclosure (regulation fully applies from 11 Dec 2027)"
}
Self::FdaMedicalDevice => "FDA premarket submission requirements for medical devices",
Self::NistSsdf => {
"Secure Software Development Framework — provenance, build integrity, VCS references"
}
Self::Eo14028 => {
"Executive Order 14028 — machine-readable SBOM, auto-generation, supply chain security"
}
Self::Cnsa2 => {
"CNSA 2.0 — AES-256, SHA-384+, ML-KEM-1024, ML-DSA-87, quantum security level 5"
}
Self::NistPqc => {
"NIST PQC — quantum-vulnerable algorithm detection, FIPS 203/204/205, SP 800-131A"
}
Self::BsiTr03183_2 => {
"BSI TR-03183-2 v2.1.0 — German national SBOM guideline (free, ENISA-cited): CycloneDX 1.6+/SPDX 3.0.1+ formats, required creator/timestamp, per-component version/licences/SHA-512 hash"
}
Self::CraOssSteward => {
"CRA Article 24 — Open-source software steward (lighter than full manufacturer obligations): SBOM + CVD policy + vuln-handling required, no DoC/module/manufacturer-email enforcement"
}
Self::EuccSubstantial => {
"EUCC Substantial (Reg. (EU) 2024/482) — reference-only check for Common-Criteria Protection Profile, Target of Evaluation, ITSEF, and certificate valid-until date"
}
Self::EuAiAct => {
"EU AI Act (Reg. (EU) 2024/1689) Annex IV technical-documentation READINESS — model description, training-data characteristics, validation/testing metrics, limitations (readiness only, not a legal-conformity guarantee; N/A for non-AI SBOMs)"
}
Self::BsiSbomForAi => {
"BSI/G7 SBOM-for-AI Minimum Elements (joint G7 final, May 2026) READINESS — scores an AI-BOM element-by-element across the Metadata, System-Level, Models, Datasets, Infrastructure, and Security clusters (readiness only, not a legal-conformity guarantee; N/A for non-AI SBOMs)"
}
Self::Cisa2026 => {
"2026 Minimum Elements for an SBOM (CISA et al., July 2026) — successor to NTIA 2021: author (person/org), signature, format name+version, generation context, timestamp, tool name+version, SBOM version, and per-component producer/name/version/identifiers/hash/license/dependencies. Frequency, distribution, and update accommodation are organizational practices with no in-document evidence and carry no rules."
}
Self::PciDss632 => {
"PCI DSS v4.0.1 Req. 6.3.2 software-inventory profile (required in assessments since 31 Mar 2025) — inventory completeness, per-component name/version/supplier/identifier, freshness, and vulnerability-management usability; 6.3.1/11.3.1.1 risk-ranking checks where vulnerability data is embedded. Assessor-side testing procedures (interviews, software comparison) are out of document reach."
}
Self::Fsct => {
"CISA Framing Software Component Transparency, 3rd ed. (2024) — baseline attributes (author, timestamp, primary component, name, version, supplier, identifiers, hashes, relationships, licenses, copyright) across the Minimum Expected (Error) / Recommended Practice (Warning) / Aspirational Goal (Info) maturity tiers"
}
Self::Comprehensive => "All recommended fields and best practices",
}
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::Minimum,
Self::Standard,
Self::NtiaMinimum,
Self::CraPhase1,
Self::CraPhase2,
Self::FdaMedicalDevice,
Self::NistSsdf,
Self::Eo14028,
Self::Cnsa2,
Self::NistPqc,
Self::BsiTr03183_2,
Self::CraOssSteward,
Self::EuccSubstantial,
Self::EuAiAct,
Self::BsiSbomForAi,
Self::Cisa2026,
Self::PciDss632,
Self::Fsct,
Self::Comprehensive,
]
}
#[must_use]
pub const fn is_cra(&self) -> bool {
matches!(
self,
Self::CraPhase1 | Self::CraPhase2 | Self::CraOssSteward
)
}
#[must_use]
pub const fn cra_phase(&self) -> Option<CraPhase> {
match self {
Self::CraPhase1 => Some(CraPhase::Phase1),
Self::CraPhase2 => Some(CraPhase::Phase2),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum StandardKind {
CraArticle,
CraAnnex,
Pren40000_1_3,
BsiTr03183_2,
NistSsdf,
Eo14028,
FdaPremarket,
NtiaMinimum,
Csaf2,
Cnsa2,
NistPqc,
EuAiAct,
BsiSbomForAi,
Eucc,
CisaMinimum2026,
PciDss4,
CisaFsct,
Other,
}
impl StandardKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::CraArticle => "CRA Article",
Self::CraAnnex => "CRA Annex",
Self::Pren40000_1_3 => "prEN 40000-1-3",
Self::BsiTr03183_2 => "BSI TR-03183-2",
Self::NistSsdf => "NIST SSDF",
Self::Eo14028 => "EO 14028",
Self::FdaPremarket => "FDA",
Self::NtiaMinimum => "NTIA",
Self::Csaf2 => "CSAF v2.0",
Self::Cnsa2 => "CNSA 2.0",
Self::NistPqc => "NIST PQC",
Self::EuAiAct => "EU AI Act",
Self::BsiSbomForAi => "BSI/G7 AI-SBOM",
Self::Eucc => "EUCC",
Self::CisaMinimum2026 => "CISA 2026",
Self::PciDss4 => "PCI DSS v4",
Self::CisaFsct => "CISA FSCT 3e",
Self::Other => "Other",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StandardRef {
pub standard: StandardKind,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help_uri: Option<String>,
}
impl StandardRef {
#[must_use]
pub fn new(standard: StandardKind, id: impl Into<String>) -> Self {
let id = id.into();
let help_uri = standard.canonical_help_uri(&id);
Self {
standard,
id,
help_uri,
}
}
#[must_use]
pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
self.help_uri = Some(uri.into());
self
}
}
impl StandardKind {
#[must_use]
pub fn canonical_help_uri(self, _id: &str) -> Option<String> {
let url = match self {
Self::CraArticle | Self::CraAnnex => {
"https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng"
}
Self::Pren40000_1_3 => return None,
Self::BsiTr03183_2 => "https://bsi.bund.de/dok/TR-03183-en",
Self::NistSsdf => "https://doi.org/10.6028/NIST.SP.800-218",
Self::Eo14028 => "https://www.federalregister.gov/d/2021-10460",
Self::FdaPremarket => "https://www.fda.gov/media/119933/download",
Self::NtiaMinimum => {
"https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom"
}
Self::Csaf2 => "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html",
Self::Cnsa2 => {
"https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF"
}
Self::NistPqc => "https://csrc.nist.gov/projects/post-quantum-cryptography",
Self::EuAiAct => "https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng",
Self::BsiSbomForAi => {
"https://www.cisa.gov/resources-tools/resources/software-bill-materials-ai-minimum-elements"
}
Self::Eucc => "https://eur-lex.europa.eu/eli/reg_impl/2024/482/oj/eng",
Self::CisaMinimum2026 => {
"https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom"
}
Self::PciDss4 => "https://www.pcisecuritystandards.org/document_library/",
Self::CisaFsct => {
"https://www.cisa.gov/resources-tools/resources/framing-software-component-transparency-2024"
}
Self::Other => return None,
};
Some(url.to_string())
}
}
#[derive(Debug, Clone)]
pub struct Violation {
pub severity: ViolationSeverity,
pub category: ViolationCategory,
pub message: String,
pub element: Option<String>,
pub component_id: Option<String>,
pub counts: Option<ViolationCounts>,
pub requirement: String,
pub rule_id: &'static str,
pub standard_refs: Vec<StandardRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViolationCounts {
pub affected: usize,
pub total: usize,
}
fn default_rule_id() -> &'static str {
"SBOM-CRA-GENERAL"
}
#[derive(Deserialize)]
struct ViolationPayload {
severity: ViolationSeverity,
category: ViolationCategory,
message: String,
element: Option<String>,
#[serde(default)]
component_id: Option<String>,
#[serde(default)]
counts: Option<ViolationCounts>,
requirement: String,
#[serde(default)]
rule_id: Option<String>,
#[serde(default)]
standard_refs: Vec<StandardRef>,
}
impl<'de> Deserialize<'de> for Violation {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let payload = ViolationPayload::deserialize(deserializer)?;
Ok(Self {
severity: payload.severity,
category: payload.category,
message: payload.message,
element: payload.element,
component_id: payload.component_id,
counts: payload.counts,
requirement: payload.requirement,
rule_id: payload
.rule_id
.as_deref()
.and_then(lookup_static_rule_id)
.unwrap_or_else(default_rule_id),
standard_refs: payload.standard_refs,
})
}
}
impl Serialize for Violation {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let has_refs = !self.standard_refs.is_empty();
let has_component_id = self.component_id.is_some();
let has_counts = self.counts.is_some();
let mut state = serializer.serialize_struct(
"Violation",
7 + usize::from(has_refs) + usize::from(has_component_id) + usize::from(has_counts),
)?;
state.serialize_field("severity", &self.severity)?;
state.serialize_field("category", &self.category)?;
state.serialize_field("message", &self.message)?;
state.serialize_field("element", &self.element)?;
if has_component_id {
state.serialize_field("component_id", &self.component_id)?;
} else {
state.skip_field("component_id")?;
}
if has_counts {
state.serialize_field("counts", &self.counts)?;
} else {
state.skip_field("counts")?;
}
state.serialize_field("requirement", &self.requirement)?;
state.serialize_field("rule_id", self.rule_id)?;
state.serialize_field("sarif_rule_id", self.sarif_rule_id())?;
if has_refs {
state.serialize_field("standard_refs", &self.standard_refs)?;
} else {
state.skip_field("standard_refs")?;
}
state.end()
}
}
impl Violation {
#[must_use]
pub fn registry_standard_refs(&self) -> Vec<StandardRef> {
rule_meta(self.rule_id)
.map(|m| {
m.refs
.iter()
.map(|(kind, id)| StandardRef::new(*kind, *id))
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn remediation_guidance(&self) -> &'static str {
rule_meta(self.rule_id).map_or(REMEDIATION_GENERIC, |m| m.remediation)
}
#[must_use]
pub fn sarif_rule_id(&self) -> &'static str {
rule_meta(self.rule_id).map_or("SBOM-CRA-GENERAL", |m| m.sarif_id)
}
}
#[must_use]
pub const fn generic_rule_id_for_level(level: ComplianceLevel) -> &'static str {
match level {
ComplianceLevel::Minimum | ComplianceLevel::Standard | ComplianceLevel::Comprehensive => {
"SBOM-QUALITY-GENERAL"
}
ComplianceLevel::NtiaMinimum => "SBOM-NTIA-GENERAL",
ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
| ComplianceLevel::CraOssSteward => "SBOM-CRA-GENERAL",
ComplianceLevel::FdaMedicalDevice => "SBOM-FDA-GENERAL",
ComplianceLevel::NistSsdf => "SBOM-SSDF-GENERAL",
ComplianceLevel::Eo14028 => "SBOM-EO14028-GENERAL",
ComplianceLevel::Cnsa2 => "SBOM-CNSA2-GENERAL",
ComplianceLevel::NistPqc => "SBOM-PQC-GENERAL",
ComplianceLevel::BsiTr03183_2 => "SBOM-BSI-TR-03183-2-GENERAL",
ComplianceLevel::EuccSubstantial => "SBOM-EUCC-GENERAL",
ComplianceLevel::EuAiAct => "SBOM-AIACT-GENERAL",
ComplianceLevel::BsiSbomForAi => "SBOM-BSIAI-GENERAL",
ComplianceLevel::Cisa2026 => "SBOM-CISA2026-GENERAL",
ComplianceLevel::PciDss632 => "SBOM-PCI-GENERAL",
ComplianceLevel::Fsct => "SBOM-FSCT-GENERAL",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ViolationSeverity {
Error,
Warning,
Info,
}
impl ViolationSeverity {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Error => "Error",
Self::Warning => "Warning",
Self::Info => "Info",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ViolationCategory {
DocumentMetadata,
ComponentIdentification,
DependencyInfo,
LicenseInfo,
SupplierInfo,
IntegrityInfo,
SecurityInfo,
FormatSpecific,
CryptographyInfo,
}
impl ViolationCategory {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::DocumentMetadata => "Document Metadata",
Self::ComponentIdentification => "Component Identification",
Self::DependencyInfo => "Dependency Information",
Self::LicenseInfo => "License Information",
Self::SupplierInfo => "Supplier Information",
Self::IntegrityInfo => "Integrity Information",
Self::SecurityInfo => "Security Information",
Self::FormatSpecific => "Format-Specific",
Self::CryptographyInfo => "Cryptography",
}
}
#[must_use]
pub const fn short_name(&self) -> &'static str {
match self {
Self::DocumentMetadata => "Doc Meta",
Self::ComponentIdentification => "Comp IDs",
Self::DependencyInfo => "Deps",
Self::LicenseInfo => "License",
Self::SupplierInfo => "Supplier",
Self::IntegrityInfo => "Integrity",
Self::SecurityInfo => "Security",
Self::FormatSpecific => "Format",
Self::CryptographyInfo => "Crypto",
}
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::SupplierInfo,
Self::ComponentIdentification,
Self::DocumentMetadata,
Self::IntegrityInfo,
Self::LicenseInfo,
Self::DependencyInfo,
Self::SecurityInfo,
Self::FormatSpecific,
Self::CryptographyInfo,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case", tag = "status", content = "reason")]
pub enum Applicability {
#[default]
Applicable,
NotApplicable(String),
}
const NOT_APPLICABLE_RULES: &[&str] = &["SBOM-AIACT-NA", "SBOM-BSIAI-NA"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceResult {
pub is_compliant: bool,
pub level: ComplianceLevel,
pub violations: Vec<Violation>,
pub error_count: usize,
pub warning_count: usize,
pub info_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conformity_summary: Option<ConformityAssessmentSummary>,
#[serde(default)]
pub applicability: Applicability,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformityAssessmentSummary {
pub product_class: crate::model::CraProductClass,
pub route: crate::model::ConformityRoute,
pub evidence: Vec<ConformityEvidence>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformityEvidence {
pub label: String,
pub detail: String,
pub satisfied: bool,
}
impl ComplianceResult {
#[must_use]
pub fn new(level: ComplianceLevel, violations: Vec<Violation>) -> Self {
let error_count = violations
.iter()
.filter(|v| v.severity == ViolationSeverity::Error)
.count();
let warning_count = violations
.iter()
.filter(|v| v.severity == ViolationSeverity::Warning)
.count();
let info_count = violations
.iter()
.filter(|v| v.severity == ViolationSeverity::Info)
.count();
let applicability = violations
.iter()
.find(|v| NOT_APPLICABLE_RULES.contains(&v.rule_id))
.map_or(Applicability::Applicable, |v| {
Applicability::NotApplicable(v.message.clone())
});
Self {
is_compliant: error_count == 0,
level,
violations,
conformity_summary: None,
applicability,
error_count,
warning_count,
info_count,
}
}
#[must_use]
pub fn is_applicable(&self) -> bool {
self.applicability == Applicability::Applicable
}
#[must_use]
pub fn score(&self) -> Option<u8> {
if !self.is_applicable() {
return None;
}
let actionable = self.error_count + self.warning_count;
#[allow(clippy::cast_possible_truncation)]
Some((100 / (actionable + 1)) as u8)
}
#[must_use]
pub fn violations_by_severity(&self, severity: ViolationSeverity) -> Vec<&Violation> {
self.violations
.iter()
.filter(|v| v.severity == severity)
.collect()
}
#[must_use]
pub fn violations_by_category(&self, category: ViolationCategory) -> Vec<&Violation> {
self.violations
.iter()
.filter(|v| v.category == category)
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ClassCheck {
VendorHashCoverage,
EolComponents,
Cycles,
DocReference,
EuccReference,
Psirt,
ModuleAttestation,
}
#[derive(Debug, Clone)]
pub struct ComplianceChecker {
level: ComplianceLevel,
sidecar: Option<crate::model::CraSidecarMetadata>,
product_class: Option<crate::model::CraProductClass>,
as_of: Option<chrono::DateTime<chrono::Utc>>,
}
impl ComplianceChecker {
#[must_use]
pub const fn new(level: ComplianceLevel) -> Self {
Self {
level,
sidecar: None,
product_class: None,
as_of: None,
}
}
#[must_use]
pub const fn with_as_of(mut self, as_of: chrono::DateTime<chrono::Utc>) -> Self {
self.as_of = Some(as_of);
self
}
pub(crate) fn now(&self) -> chrono::DateTime<chrono::Utc> {
self.as_of.unwrap_or_else(chrono::Utc::now)
}
#[must_use]
pub fn with_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
self.sidecar = Some(sidecar);
self
}
#[must_use]
pub const fn with_product_class(mut self, class: crate::model::CraProductClass) -> Self {
self.product_class = Some(class);
self
}
#[must_use]
pub fn effective_product_class(&self) -> crate::model::CraProductClass {
self.sidecar
.as_ref()
.and_then(|s| s.product_class)
.or(self.product_class)
.unwrap_or(crate::model::CraProductClass::Default)
}
#[must_use]
pub fn effective_route(&self) -> crate::model::ConformityRoute {
self.sidecar
.as_ref()
.and_then(|s| s.conformity_assessment_route)
.unwrap_or_else(|| self.effective_product_class().default_route())
}
#[must_use]
pub fn class_severity(&self, check: ClassCheck) -> Option<ViolationSeverity> {
use crate::model::CraProductClass as C;
let class = self.effective_product_class();
match (check, class) {
(ClassCheck::VendorHashCoverage, C::Default | C::ImportantClass1) => {
Some(ViolationSeverity::Warning)
}
(ClassCheck::VendorHashCoverage, C::ImportantClass2 | C::Critical) => {
Some(ViolationSeverity::Error)
}
(ClassCheck::EolComponents, C::Default | C::ImportantClass1) => {
Some(ViolationSeverity::Warning)
}
(ClassCheck::EolComponents, C::ImportantClass2 | C::Critical) => {
Some(ViolationSeverity::Error)
}
(ClassCheck::Cycles, C::Default | C::ImportantClass1) => {
Some(ViolationSeverity::Warning)
}
(ClassCheck::Cycles, C::ImportantClass2 | C::Critical) => {
Some(ViolationSeverity::Error)
}
(ClassCheck::DocReference, C::Default) => Some(ViolationSeverity::Info),
(ClassCheck::DocReference, C::ImportantClass1) => Some(ViolationSeverity::Warning),
(ClassCheck::DocReference, C::ImportantClass2 | C::Critical) => {
Some(ViolationSeverity::Error)
}
(ClassCheck::EuccReference, C::Default | C::ImportantClass1) => None,
(ClassCheck::EuccReference, C::ImportantClass2) => Some(ViolationSeverity::Info),
(ClassCheck::EuccReference, C::Critical) => Some(ViolationSeverity::Error),
(ClassCheck::Psirt, C::Default | C::ImportantClass1) => {
Some(ViolationSeverity::Warning)
}
(ClassCheck::Psirt, C::ImportantClass2 | C::Critical) => Some(ViolationSeverity::Error),
(ClassCheck::ModuleAttestation, C::Default) => None,
(ClassCheck::ModuleAttestation, C::ImportantClass1) => Some(ViolationSeverity::Warning),
(ClassCheck::ModuleAttestation, C::ImportantClass2 | C::Critical) => {
Some(ViolationSeverity::Error)
}
}
}
#[must_use]
pub fn vendor_hash_threshold(&self) -> f64 {
use crate::model::CraProductClass as C;
match self.effective_product_class() {
C::Default => 0.50,
C::ImportantClass1 | C::ImportantClass2 => 0.80,
C::Critical => 1.00,
}
}
#[must_use]
pub fn has_explicit_product_class(&self) -> bool {
self.product_class.is_some()
|| self
.sidecar
.as_ref()
.and_then(|s| s.product_class)
.is_some()
}
#[must_use]
pub fn check(&self, sbom: &NormalizedSbom) -> ComplianceResult {
let ctx = ComplianceContext::new(self, sbom);
let checker = checker_for(self.level);
debug_assert_eq!(
checker.level(),
self.level,
"dispatched checker must match the configured level"
);
let mut violations = checker.check(&ctx);
for v in &mut violations {
if v.standard_refs.is_empty() {
v.standard_refs = v.registry_standard_refs();
}
}
let mut result = ComplianceResult::new(self.level, violations);
if self.level.is_cra() && self.has_explicit_product_class() {
result.conformity_summary = Some(self.build_conformity_summary(sbom));
}
result
}
}
impl Default for ComplianceChecker {
fn default() -> Self {
Self::new(ComplianceLevel::Standard)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ntia_gates_on_missing_timestamp() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let comp = |sbom: &mut NormalizedSbom| {
let c = Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
sbom.add_component(c);
};
let mut no_ts = NormalizedSbom::new(DocumentMetadata::default());
no_ts.document.created = chrono::DateTime::UNIX_EPOCH;
comp(&mut no_ts);
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&no_ts);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP"
&& v.severity == ViolationSeverity::Error),
"missing timestamp must fail NTIA"
);
let mut with_ts = NormalizedSbom::new(DocumentMetadata::default()); comp(&mut with_ts);
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&with_ts);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP")
);
}
#[test]
fn eo14028_gates_on_missing_supplier() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-EO14028-SUPPLIER"
&& v.severity == ViolationSeverity::Error),
"missing supplier must be a gating Error under EO 14028"
);
}
#[test]
fn bsi_gates_on_nameless_component() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut c =
Component::new(String::new(), "ref-1".to_string()).with_version("1.0".to_string());
c.identifiers.purl = Some("pkg:cargo/x@1.0".to_string());
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-3"),
"a nameless component must fail BSI §5.2.2"
);
}
#[test]
fn cra_art24_honors_document_level_disclosure() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.document.vulnerability_disclosure_url =
Some("https://example.org/security".to_string());
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
assert!(
!r.violations.iter().any(|v| v.rule_id == "SBOM-CRA-ART-24"),
"a document-level disclosure URL must satisfy the Art.24 vuln-handling gate"
);
}
#[test]
fn ntia_gates_on_missing_identifier() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
let mut without_id = NormalizedSbom::new(DocumentMetadata::default());
let mut c =
Component::new("lib".to_string(), "lib@1".to_string()).with_version("1.0".to_string());
c.supplier = Some(Organization::new("LibCorp".to_string()));
without_id.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&without_id);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-IDENTIFIER"
&& v.severity == ViolationSeverity::Error),
"missing unique identifier must fail NTIA"
);
let mut with_id = NormalizedSbom::new(DocumentMetadata::default());
let mut c = Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
c.supplier = Some(Organization::new("LibCorp".to_string()));
with_id.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&with_id);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-IDENTIFIER"),
"PURL satisfies the NTIA identifier element"
);
}
#[test]
fn placeholder_values_do_not_satisfy_required_elements() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut c = Component::new("lib".to_string(), "lib@1".to_string())
.with_version("NOASSERTION".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
c.supplier = Some(Organization::new("NOASSERTION".to_string()));
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-VERSION"),
"NOASSERTION version must not satisfy the NTIA version element"
);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-SUPPLIER"),
"NOASSERTION supplier must not satisfy the NTIA supplier element"
);
}
#[test]
fn fda_gates_on_missing_timestamp() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.document.created = chrono::DateTime::UNIX_EPOCH;
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP"
&& v.severity == ViolationSeverity::Error),
"missing timestamp must fail FDA (NTIA baseline)"
);
}
#[test]
fn fda_warns_without_support_lifecycle() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom, Property};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.requirement.contains("Level of support")),
"missing support-lifecycle info must warn under FDA"
);
let mut with_eol = NormalizedSbom::new(DocumentMetadata::default());
let mut c = Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
c.extensions.properties.push(Property {
name: "end-of-support".to_string(),
value: "2030-01-01".to_string(),
});
with_eol.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&with_eol);
assert!(
!r.violations
.iter()
.any(|v| v.requirement.contains("Level of support")),
"component end-of-support property satisfies the FDA support check"
);
}
#[test]
fn eo14028_gates_on_missing_timestamp() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.document.created = chrono::DateTime::UNIX_EPOCH;
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-EO14028-TIMESTAMP"
&& v.severity == ViolationSeverity::Error),
"missing timestamp must fail EO 14028"
);
let mut with_ts = NormalizedSbom::new(DocumentMetadata::default());
with_ts.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&with_ts);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-EO14028-TIMESTAMP")
);
}
#[test]
fn eo14028_accepts_spdx_2_2() {
use crate::model::{DocumentMetadata, NormalizedSbom, SbomFormat};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.document.format = SbomFormat::Spdx;
sbom.document.spec_version = "2.2".to_string();
let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-EO14028-FORMAT"),
"SPDX 2.2 is machine-readable under EO 14028"
);
sbom.document.spec_version = "2.1".to_string();
let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-EO14028-FORMAT"),
"SPDX 2.1 still fails the machine-readable gate"
);
}
#[test]
fn eo14028_and_ssdf_accept_swhid_identifiers() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom, SwhidObject};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut c =
Component::new("lib".to_string(), "lib@1".to_string()).with_version("1.0".to_string());
c.identifiers.swhid.push(
SwhidObject::parse("swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2").unwrap(),
);
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-EO14028-IDENTIFIER"),
"SWHID satisfies the EO 14028 identifier element"
);
let r = ComplianceChecker::new(ComplianceLevel::NistSsdf).check(&sbom);
assert!(
!r.violations.iter().any(|v| v.rule_id == "SBOM-SSDF-RV1"),
"SWHID satisfies the SSDF RV.1 identifier check"
);
}
#[test]
fn dependency_graph_orphans_warn() {
use crate::model::{
CompletenessDeclaration, Component, DependencyEdge, DependencyType, DocumentMetadata,
NormalizedSbom,
};
let build = || {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut ids = Vec::new();
for i in 0..5 {
let c = Component::new(format!("lib{i}"), format!("lib{i}@1"))
.with_version("1.0".to_string())
.with_purl(format!("pkg:cargo/lib{i}@1.0"));
ids.push(c.canonical_id.clone());
sbom.add_component(c);
}
sbom.edges.push(DependencyEdge::new(
ids[0].clone(),
ids[1].clone(),
DependencyType::DependsOn,
));
sbom
};
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&build());
assert!(
r.violations.iter().any(|v| v
.message
.contains("participate in no dependency relationship")),
"3/5 orphaned components must produce a dependency-coverage warning"
);
let mut declared = build();
declared.document.completeness_declaration = CompletenessDeclaration::Incomplete;
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&declared);
assert!(
!r.violations.iter().any(|v| v
.message
.contains("participate in no dependency relationship")),
"declared-incomplete SBOMs are exempt from the orphan warning"
);
}
#[test]
fn primary_component_must_participate_in_graph() {
use crate::model::{
Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom,
};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let app = Component::new("app".to_string(), "app".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/app@1.0".to_string());
let lib_a = Component::new("liba".to_string(), "liba".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/liba@1.0".to_string());
let lib_b = Component::new("libb".to_string(), "libb".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/libb@1.0".to_string());
let app_id = app.canonical_id.clone();
let a_id = lib_a.canonical_id.clone();
let b_id = lib_b.canonical_id.clone();
sbom.primary_component_id = Some(app_id.clone());
sbom.add_component(app);
sbom.add_component(lib_a);
sbom.add_component(lib_b);
sbom.edges.push(DependencyEdge::new(
a_id.clone(),
b_id,
DependencyType::DependsOn,
));
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.message.contains("Primary component")
&& v.message.contains("no dependency relationship")),
"disconnected primary component must warn"
);
sbom.edges
.push(DependencyEdge::new(app_id, a_id, DependencyType::DependsOn));
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(
!r.violations
.iter()
.any(|v| v.message.contains("Primary component")
&& v.message.contains("no dependency relationship"))
);
}
#[test]
fn oss_steward_enforces_component_completeness() {
use crate::model::{Component, DocumentMetadata, ExternalRefType, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.document.vulnerability_disclosure_url =
Some("https://example.org/security".to_string());
for n in ["a", "b", "c"] {
let mut c = Component::new(n.to_string(), n.to_string());
c.external_refs.push(crate::model::ExternalReference {
ref_type: ExternalRefType::Advisories,
url: "https://example.org/advisories".to_string(),
comment: None,
hashes: Vec::new(),
});
sbom.add_component(c);
}
let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-COMPONENT-VERSION"
&& v.severity == ViolationSeverity::Error),
"steward components without versions must error"
);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ANNEX-I-IDENTIFIER"
&& v.severity == ViolationSeverity::Error),
"steward components without identifiers must error"
);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ANNEX-I-DEPENDENCY"
&& v.severity == ViolationSeverity::Error),
"steward SBOM without dependency edges must error"
);
assert!(!r.is_compliant, "incomplete steward SBOM must not pass");
}
#[test]
fn third_party_refs_do_not_satisfy_manufacturer_obligations() {
use crate::model::{
Component, DependencyEdge, DependencyType, DocumentMetadata, ExternalRefType,
ExternalReference, NormalizedSbom,
};
let build = |primary_has_contact: bool| {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut app = Component::new("app".to_string(), "app".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/app@1.0".to_string());
if primary_has_contact {
app.external_refs.push(ExternalReference {
ref_type: ExternalRefType::SecurityContact,
url: "https://acme.example/security".to_string(),
comment: None,
hashes: Vec::new(),
});
app.external_refs.push(ExternalReference {
ref_type: ExternalRefType::Advisories,
url: "https://acme.example/advisories".to_string(),
comment: None,
hashes: Vec::new(),
});
}
let mut lodash = Component::new("lodash".to_string(), "lodash".to_string())
.with_version("4.17.21".to_string())
.with_purl("pkg:npm/lodash@4.17.21".to_string());
lodash.external_refs.push(ExternalReference {
ref_type: ExternalRefType::Advisories,
url: "https://github.com/lodash/lodash/security/advisories".to_string(),
comment: None,
hashes: Vec::new(),
});
lodash.external_refs.push(ExternalReference {
ref_type: ExternalRefType::Support,
url: "https://lodash.com/docs".to_string(),
comment: None,
hashes: Vec::new(),
});
let app_id = app.canonical_id.clone();
let lodash_id = lodash.canonical_id.clone();
sbom.primary_component_id = Some(app_id.clone());
sbom.add_component(app);
sbom.add_component(lodash);
sbom.edges.push(DependencyEdge::new(
app_id,
lodash_id,
DependencyType::DependsOn,
));
sbom
};
let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(false));
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
"dep-level advisories/support refs must not satisfy Art. 13(17)"
);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-CVD-POLICY"),
"dep-level advisories ref must not satisfy Annex I Part II (5)"
);
let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(true));
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
"primary-component security contact satisfies Art. 13(17)"
);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-CVD-POLICY"),
"primary-component advisories ref satisfies Annex I Part II (5)"
);
}
#[test]
fn explicit_product_class_never_weakens_vendor_hash_gate() {
use crate::model::CraProductClass;
let mut sbom = NormalizedSbom::default();
for n in ["a", "b", "c", "d"] {
let c = vendor_component(n, true);
sbom.components.insert(c.canonical_id.clone(), c);
}
for n in ["e", "f", "g", "h", "i", "j"] {
let c = vendor_component(n, false);
sbom.components.insert(c.canonical_id.clone(), c);
}
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_product_class(CraProductClass::ImportantClass1)
.check(&sbom);
let v = result.violations.iter().find(|v| {
v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Error
});
assert!(
v.is_some(),
"40% coverage must stay an Error under CraPhase2 even with an explicit class"
);
}
#[test]
fn eucc_sidecar_fields_satisfy_critical_class_check() {
use crate::model::{Component, CraProductClass, CraSidecarMetadata, DocumentMetadata};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(
Component::new("fw".to_string(), "fw".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:generic/fw@1.0".to_string()),
);
let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_product_class(CraProductClass::Critical)
.check(&sbom);
assert!(
bare.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV"),
"Critical class without EUCC evidence must flag Annex IV"
);
let sidecar = CraSidecarMetadata {
eucc_protection_profile_id: Some("PP-CC-MFR-2024-01".to_string()),
eucc_target_of_evaluation: Some("TOE-fw-1.0".to_string()),
..Default::default()
};
let with_sidecar = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_product_class(CraProductClass::Critical)
.with_sidecar(sidecar)
.check(&sbom);
assert!(
!with_sidecar
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV"),
"sidecar EUCC evidence fields must satisfy the Critical-class check"
);
}
#[test]
fn genuine_none_named_package_passes_name_gate() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut c = Component::new("none".to_string(), "none@1".to_string())
.with_version("1.0.0".to_string())
.with_purl("pkg:npm/none@1.0.0".to_string());
c.supplier = Some(Organization::new("Acme".to_string()));
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(
!r.violations.iter().any(|v| v.rule_id == "SBOM-NTIA-NAME"),
"npm package genuinely named 'none' must not fail the name gate"
);
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(
Component::new("NOASSERTION".to_string(), "x@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:npm/realname@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(
r.violations.iter().any(|v| v.rule_id == "SBOM-NTIA-NAME"),
"NOASSERTION never satisfies the name gate"
);
}
#[test]
fn fda_support_not_satisfied_by_incidental_eol_substring() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom, Property};
let build = |name: &str, value: &str| {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut c = Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
c.extensions.properties.push(Property {
name: name.to_string(),
value: value.to_string(),
});
sbom.add_component(c);
ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom)
};
assert!(
build("geolocation", "enabled")
.violations
.iter()
.any(|v| v.requirement.contains("Level of support")),
"'geolocation' must not satisfy the support-lifecycle element"
);
assert!(
build("acme:eol", "2030-01-01")
.violations
.iter()
.all(|v| !v.requirement.contains("Level of support")),
"a real eol property with a value satisfies the element"
);
assert!(
build("end-of-support", "NOASSERTION")
.violations
.iter()
.any(|v| v.requirement.contains("Level of support")),
"a placeholder value must not satisfy the element"
);
}
#[test]
fn primary_with_declared_empty_deps_does_not_warn() {
use crate::model::{
Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom, Property,
};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut app = Component::new("app".to_string(), "app".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/app@1.0".to_string());
app.extensions.properties.push(Property {
name: crate::parsers::DECLARED_NO_DEPENDENCIES_PROPERTY.to_string(),
value: "true".to_string(),
});
let lib_a = Component::new("liba".to_string(), "liba".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/liba@1.0".to_string());
let lib_b = Component::new("libb".to_string(), "libb".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/libb@1.0".to_string());
let app_id = app.canonical_id.clone();
let a_id = lib_a.canonical_id.clone();
let b_id = lib_b.canonical_id.clone();
sbom.primary_component_id = Some(app_id);
sbom.add_component(app);
sbom.add_component(lib_a);
sbom.add_component(lib_b);
sbom.edges
.push(DependencyEdge::new(a_id, b_id, DependencyType::DependsOn));
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(
!r.violations
.iter()
.any(|v| v.message.contains("Primary component")),
"declared-no-dependencies primary must not warn"
);
}
#[test]
fn fda_warns_on_minority_orphans() {
use crate::model::{
Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom,
};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut ids = Vec::new();
for i in 0..4 {
let c = Component::new(format!("lib{i}"), format!("lib{i}@1"))
.with_version("1.0".to_string())
.with_purl(format!("pkg:cargo/lib{i}@1.0"));
ids.push(c.canonical_id.clone());
sbom.add_component(c);
}
sbom.edges.push(DependencyEdge::new(
ids[0].clone(),
ids[1].clone(),
DependencyType::DependsOn,
));
sbom.edges.push(DependencyEdge::new(
ids[1].clone(),
ids[2].clone(),
DependencyType::DependsOn,
));
let fda = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
assert!(
fda.violations.iter().any(|v| v
.message
.contains("participate in no dependency relationship")),
"FDA warns on any orphaned component"
);
let ntia = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(
!ntia.violations.iter().any(|v| v
.message
.contains("participate in no dependency relationship")),
"NTIA only warns when orphans form a majority"
);
}
#[test]
fn fda_findings_carry_fda_rule_ids() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.document.creators.clear();
for i in 0..2 {
sbom.add_component(
Component::new(format!("lib{i}"), format!("lib{i}@1"))
.with_version("1.0".to_string())
.with_purl(format!("pkg:cargo/lib{i}@1.0")),
);
}
let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
assert!(
r.violations.iter().any(|v| v.rule_id == "SBOM-FDA-CREATOR"),
"creators-empty must carry SBOM-FDA-CREATOR under FDA"
);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-FDA-DEPENDENCY"),
"dependency findings must carry SBOM-FDA-DEPENDENCY under FDA"
);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-FDA-NAMESPACE"),
"serial-number finding must carry SBOM-FDA-NAMESPACE under FDA"
);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-NTIA-DEPENDENCY"),
"FDA runs must not emit NTIA dependency rule identity"
);
}
#[test]
fn eucc_sidecar_empty_or_expired_evidence_does_not_satisfy() {
use crate::model::{Component, CraProductClass, CraSidecarMetadata, DocumentMetadata};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(
Component::new("fw".to_string(), "fw".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:generic/fw@1.0".to_string()),
);
let check = |sidecar: CraSidecarMetadata| {
ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_product_class(CraProductClass::Critical)
.with_sidecar(sidecar)
.check(&sbom)
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV")
};
assert!(
check(CraSidecarMetadata {
eucc_protection_profile_id: Some(" ".to_string()),
..Default::default()
}),
"an empty-string EUCC field must not satisfy the Annex IV gate"
);
assert!(
check(CraSidecarMetadata {
eucc_valid_until: Some(chrono::Utc::now() - chrono::Duration::days(365)),
..Default::default()
}),
"an expired EUCC validity date must not satisfy the Annex IV gate"
);
assert!(
!check(CraSidecarMetadata {
eucc_valid_until: Some(chrono::Utc::now() + chrono::Duration::days(365)),
..Default::default()
}),
"a live EUCC validity date satisfies the Annex IV gate"
);
}
#[test]
fn steward_supplier_refs_cite_art_24() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.document.vulnerability_disclosure_url =
Some("https://example.org/security".to_string());
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
let v = r
.violations
.iter()
.find(|v| v.rule_id == "SBOM-CRA-ART-24-SUPPLIER")
.expect("steward supplier warning fires");
assert!(
v.standard_refs
.iter()
.all(|sr| sr.id != "Art. 13(15)" && sr.id != "Art. 13(16)"),
"steward supplier refs must not cite Art. 13(15)/13(16)"
);
assert!(
v.standard_refs.iter().any(|sr| sr.id == "Art. 24"),
"steward supplier refs must cite Art. 24"
);
}
#[test]
fn manufacturer_scope_handles_cycles_and_sibling_roots() {
use crate::model::{
Component, DependencyEdge, DependencyType, DocumentMetadata, ExternalRefType,
ExternalReference, NormalizedSbom,
};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut a = Component::new("liba".to_string(), "liba".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/liba@1.0".to_string());
a.external_refs.push(ExternalReference {
ref_type: ExternalRefType::SecurityContact,
url: "https://acme.example/security".to_string(),
comment: None,
hashes: Vec::new(),
});
let b = Component::new("libb".to_string(), "libb".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/libb@1.0".to_string());
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
sbom.add_component(a);
sbom.add_component(b);
sbom.edges.push(DependencyEdge::new(
a_id.clone(),
b_id.clone(),
DependencyType::DependsOn,
));
sbom.edges
.push(DependencyEdge::new(b_id, a_id, DependencyType::DependsOn));
let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
"evidence in a fully-cyclic graph must still count (fallback to all components)"
);
}
#[test]
fn cra_dependency_cycles_scale_with_product_class() {
use crate::model::{
Component, CraProductClass, DependencyEdge, DependencyType, DocumentMetadata,
NormalizedSbom,
};
let build = |cyclic: bool| {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let a = Component::new("liba".to_string(), "liba".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/liba@1.0".to_string());
let b = Component::new("libb".to_string(), "libb".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/libb@1.0".to_string());
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
sbom.add_component(a);
sbom.add_component(b);
sbom.edges.push(DependencyEdge::new(
a_id.clone(),
b_id.clone(),
DependencyType::DependsOn,
));
if cyclic {
sbom.edges
.push(DependencyEdge::new(b_id, a_id, DependencyType::DependsOn));
}
sbom
};
let cycles = |r: &ComplianceResult| {
r.violations
.iter()
.find(|v| v.rule_id == "SBOM-CRA-CYCLES")
.map(|v| v.severity)
};
let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(true));
assert_eq!(
cycles(&r),
Some(ViolationSeverity::Warning),
"cycles warn at the default class"
);
let r = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_product_class(CraProductClass::Critical)
.check(&build(true));
assert_eq!(
cycles(&r),
Some(ViolationSeverity::Error),
"cycles error at Critical class"
);
let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(false));
assert_eq!(cycles(&r), None, "acyclic graphs are silent");
}
#[test]
fn not_applicable_result_has_no_score() {
use crate::model::{Component, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let r = ComplianceChecker::new(ComplianceLevel::EuAiAct).check(&sbom);
assert!(!r.is_applicable(), "non-AI SBOM must be NotApplicable");
assert!(
matches!(r.applicability, Applicability::NotApplicable(_)),
"applicability must carry the reason"
);
assert_eq!(r.score(), None, "unevaluated SBOMs have no score");
assert!(r.is_compliant, "the N/A is_compliant contract is preserved");
let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
assert!(r.is_applicable());
assert!(r.score().is_some());
}
#[test]
fn score_is_neutral_to_info_findings() {
let violation = |severity| Violation {
severity,
category: ViolationCategory::DocumentMetadata,
message: "x".to_string(),
element: None,
requirement: "x".to_string(),
rule_id: "SBOM-CRA-GENERAL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
};
let errors_only = ComplianceResult::new(
ComplianceLevel::NtiaMinimum,
(0..5)
.map(|_| violation(ViolationSeverity::Error))
.collect(),
);
let with_infos = ComplianceResult::new(
ComplianceLevel::NtiaMinimum,
(0..5)
.map(|_| violation(ViolationSeverity::Error))
.chain((0..20).map(|_| violation(ViolationSeverity::Info)))
.collect(),
);
assert_eq!(errors_only.score(), with_infos.score());
assert_eq!(errors_only.score(), Some(16));
let clean = ComplianceResult::new(ComplianceLevel::NtiaMinimum, Vec::new());
assert_eq!(clean.score(), Some(100));
}
#[test]
fn applicability_defaults_on_old_payloads() {
let r = ComplianceResult::new(ComplianceLevel::NtiaMinimum, Vec::new());
let mut json: serde_json::Value = serde_json::to_value(&r).unwrap();
json.as_object_mut().unwrap().remove("applicability");
let back: ComplianceResult = serde_json::from_value(json).unwrap();
assert_eq!(back.applicability, Applicability::Applicable);
}
#[test]
fn as_of_clock_pins_deadline_checks() {
use crate::model::{Component, CraSidecarMetadata, DocumentMetadata, NormalizedSbom};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(
Component::new("lib".to_string(), "lib@1".to_string())
.with_version("1.0".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string()),
);
let ts = |s: &str| {
chrono::DateTime::parse_from_rfc3339(s)
.unwrap()
.with_timezone(&chrono::Utc)
};
let art14_severity = |as_of: &str| {
ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_as_of(ts(as_of))
.check(&sbom)
.violations
.iter()
.find(|v| v.requirement.contains("Art. 14(2)(a)"))
.map(|v| v.severity)
};
assert_eq!(
art14_severity("2026-01-01T00:00:00Z"),
Some(ViolationSeverity::Info),
"pre-deadline Art. 14 findings are informational"
);
assert_eq!(
art14_severity("2027-01-01T00:00:00Z"),
Some(ViolationSeverity::Warning),
"post-deadline Art. 14 findings escalate"
);
let sidecar = CraSidecarMetadata {
eucc_protection_profile_id: Some("PP-1".to_string()),
eucc_target_of_evaluation: Some("TOE-1".to_string()),
eucc_itsef_identifier: Some("ITSEF-1".to_string()),
eucc_valid_until: Some(ts("2027-06-01T00:00:00Z")),
..Default::default()
};
let eucc_expired = |as_of: &str| {
ComplianceChecker::new(ComplianceLevel::EuccSubstantial)
.with_sidecar(sidecar.clone())
.with_as_of(ts(as_of))
.check(&sbom)
.violations
.iter()
.any(|v| {
v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-EUCC-VALIDITY"
})
};
assert!(
!eucc_expired("2027-01-01T00:00:00Z"),
"certificate valid at the pinned instant"
);
assert!(
eucc_expired("2028-01-01T00:00:00Z"),
"certificate expired at the pinned instant"
);
}
#[test]
fn test_compliance_level_names() {
assert_eq!(ComplianceLevel::Minimum.name(), "Minimum");
assert_eq!(ComplianceLevel::NtiaMinimum.name(), "NTIA Minimum Elements");
assert_eq!(ComplianceLevel::CraPhase1.name(), "EU CRA Phase 1 (2026)");
assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2027)");
assert_eq!(ComplianceLevel::NistSsdf.name(), "NIST SSDF (SP 800-218)");
assert_eq!(ComplianceLevel::Eo14028.name(), "EO 14028 Section 4");
}
#[test]
fn test_nist_ssdf_empty_sbom() {
let sbom = NormalizedSbom::default();
let checker = ComplianceChecker::new(ComplianceLevel::NistSsdf);
let result = checker.check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.requirement.contains("PS.1"))
);
}
#[test]
fn test_eo14028_empty_sbom() {
let sbom = NormalizedSbom::default();
let checker = ComplianceChecker::new(ComplianceLevel::Eo14028);
let result = checker.check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.requirement.contains("EO 14028"))
);
}
#[test]
fn test_compliance_result_counts() {
let violations = vec![
Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: "Error 1".to_string(),
element: None,
requirement: "Test".to_string(),
rule_id: "SBOM-CRA-GENERAL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
},
Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::LicenseInfo,
message: "Warning 1".to_string(),
element: None,
requirement: "Test".to_string(),
rule_id: "SBOM-CRA-GENERAL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
},
Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::FormatSpecific,
message: "Info 1".to_string(),
element: None,
requirement: "Test".to_string(),
rule_id: "SBOM-CRA-GENERAL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
},
];
let result = ComplianceResult::new(ComplianceLevel::Standard, violations);
assert!(!result.is_compliant);
assert_eq!(result.error_count, 1);
assert_eq!(result.warning_count, 1);
assert_eq!(result.info_count, 1);
}
fn make_crypto_sbom(algos: &[(&str, &str, Option<&str>, Option<u8>)]) -> NormalizedSbom {
use crate::model::{
AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
};
let mut sbom = NormalizedSbom::default();
for (name, family, param, ql) in algos {
let mut c = crate::model::Component::new(name.to_string(), format!("{name}@1.0"));
c.component_type = ComponentType::Cryptographic;
let mut algo = AlgorithmProperties::new(CryptoPrimitive::Ae)
.with_algorithm_family(family.to_string());
if let Some(p) = param {
algo = algo.with_parameter_set_identifier(p.to_string());
}
if let Some(level) = ql {
algo = algo.with_nist_quantum_security_level(*level);
}
c.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(algo),
);
sbom.add_component(c);
}
sbom
}
#[test]
fn test_cnsa2_aes128_violation() {
let sbom = make_crypto_sbom(&[("AES-128-GCM", "AES", Some("128"), Some(1))]);
let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
let result = checker.check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error && v.message.contains("AES-128")),
"CNSA 2.0 should flag AES-128"
);
}
#[test]
fn test_cnsa2_mlkem1024_passes() {
let sbom = make_crypto_sbom(&[("ML-KEM-1024", "ML-KEM", Some("1024"), Some(5))]);
let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
let result = checker.check(&sbom);
let algo_errors: Vec<_> = result
.violations
.iter()
.filter(|v| {
v.severity == ViolationSeverity::Error
&& v.element.as_deref() == Some("ML-KEM-1024")
})
.collect();
assert!(algo_errors.is_empty(), "ML-KEM-1024 should pass CNSA 2.0");
}
#[test]
fn test_pqc_quantum_vulnerable() {
let sbom = make_crypto_sbom(&[("RSA-2048", "RSA", None, Some(0))]);
let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
let result = checker.check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error
&& v.message.contains("quantum-vulnerable")),
"PQC should flag RSA-2048 as quantum-vulnerable"
);
}
#[test]
fn crypto_standards_fail_on_empty_inventory() {
let mut sbom = NormalizedSbom::default();
sbom.add_component(crate::model::Component::new(
"lodash".to_string(),
"lodash@4.17.21".to_string(),
));
for level in [ComplianceLevel::NistPqc, ComplianceLevel::Cnsa2] {
let result = ComplianceChecker::new(level).check(&sbom);
assert!(
!result.is_compliant,
"{level:?} must not report compliant with no crypto inventory"
);
assert!(
result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error
&& v.message.contains("No cryptographic inventory")),
"{level:?} must emit an inventory-absent error"
);
}
}
#[test]
fn pqc_flags_classical_crypto_with_unset_quantum_level() {
for family in ["RSA", "ECDSA", "ECDH", "DH", "DSA"] {
let sbom = make_crypto_sbom(&[("classical", family, None, None)]);
let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
!result.is_compliant,
"{family} with unset quantum level must fail PQC"
);
assert!(
result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-001"),
"{family} must raise the quantum-vulnerable error"
);
}
}
#[test]
fn cnsa2_flags_weak_sha2_in_either_encoding() {
for (family, param) in [
("SHA-256", None),
("SHA-224", None),
("SHA-2", Some("256")),
("SHA-2", Some("224")),
] {
let sbom = make_crypto_sbom(&[("hash", family, param, None)]);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
"{family}/{param:?} must fail CNSA 2.0 hash gate"
);
}
let ok = make_crypto_sbom(&[("hash", "SHA-384", None, None)]);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
assert!(
!result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
"SHA-384 must not trip the hash gate"
);
}
#[test]
fn test_pqc_approved_algorithm_info() {
let sbom = make_crypto_sbom(&[("ML-DSA-65", "ML-DSA", Some("65"), Some(3))]);
let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
let result = checker.check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Info && v.message.contains("approved")),
"PQC should report ML-DSA-65 as approved"
);
}
#[test]
fn cnsa2_allowlist_rejects_non_cnsa_algorithms() {
for (family, param, expected_rule) in [
("SHA-1", None, "SBOM-CNSA2-ALG-005"),
("DES", None, "SBOM-CNSA2-ALG-005"),
("ChaCha20", None, "SBOM-CNSA2-ALG-008"),
("Ed25519", None, "SBOM-CNSA2-ALG-006"),
("ECIES", None, "SBOM-CNSA2-ALG-006"),
("EC", None, "SBOM-CNSA2-ALG-006"),
("Kyber", Some("768"), "SBOM-CNSA2-ALG-003"),
("ML-KEM-768", None, "SBOM-CNSA2-ALG-003"),
("ML-KEM", None, "SBOM-CNSA2-ALG-003"), ("ML-DSA", Some("65"), "SBOM-CNSA2-ALG-004"),
("SLH-DSA", None, "SBOM-CNSA2-ALG-008"),
("SHA-3", Some("256"), "SBOM-CNSA2-ALG-008"),
("AES-128", None, "SBOM-CNSA2-ALG-001"), ] {
let sbom = make_crypto_sbom(&[("asset", family, param, None)]);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
result.violations.iter().any(|v| {
v.severity == ViolationSeverity::Error && v.rule_id == expected_rule
}),
"{family}/{param:?} must fail CNSA 2.0 with {expected_rule}; got {:?}",
result
.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
assert!(!result.is_compliant, "{family} must not be CNSA compliant");
}
}
#[test]
fn cnsa2_allowlist_accepts_full_cnsa_suite() {
let sbom = make_crypto_sbom(&[
("AES-256-GCM", "AES", Some("256"), Some(1)),
("SHA-384", "SHA-2", Some("384"), Some(2)),
("ML-KEM-1024", "ML-KEM", Some("1024"), Some(5)),
("ML-DSA-87", "ML-DSA", Some("87"), Some(5)),
("LMS", "LMS", None, Some(5)),
]);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
let errors: Vec<_> = result
.violations
.iter()
.filter(|v| v.severity == ViolationSeverity::Error)
.collect();
assert!(
errors.is_empty(),
"full CNSA 2.0 suite must have zero errors, got {:?}",
errors
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
assert!(result.is_compliant);
}
#[test]
fn cnsa2_unknown_algorithm_warns() {
let sbom = make_crypto_sbom(&[("mystery", "proprietary-frobnicator", None, None)]);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
result.violations.iter().any(|v| {
v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-CNSA2-ALG-UNKNOWN"
}),
"unknown algorithm must produce the cannot-verify warning"
);
assert!(
!result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error
&& v.rule_id.starts_with("SBOM-CNSA2-ALG")),
"unknown algorithm must not produce an algorithm Error"
);
}
#[test]
fn cnsa2_classifies_without_algorithm_family() {
use crate::model::{
AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
};
let mut sbom = NormalizedSbom::default();
let mut rsa = crate::model::Component::new("RSA-2048".into(), "algo-1".into());
rsa.component_type = ComponentType::Cryptographic;
rsa.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm)
.with_oid("1.2.840.113549.1.1.1".into())
.with_algorithm_properties(AlgorithmProperties::new(CryptoPrimitive::Pke)),
);
sbom.add_component(rsa);
let mut aes = crate::model::Component::new("AES-128-CBC".into(), "algo-2".into());
aes.component_type = ComponentType::Cryptographic;
aes.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm)
.with_algorithm_properties(AlgorithmProperties::new(CryptoPrimitive::BlockCipher)),
);
sbom.add_component(aes);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-006" && v.message.contains("RSA")),
"RSA must be flagged via OID without algorithmFamily"
);
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-001" && v.message.contains("AES-128")),
"AES-128 must be flagged via name without algorithmFamily/OID"
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-001" && v.message.contains("RSA")),
"PQC must flag OID-only RSA as quantum-vulnerable"
);
}
#[test]
fn pqc_flags_broken_spelling_variants() {
for family in ["SHA1", "TDES", "ARC4"] {
let sbom = make_crypto_sbom(&[("legacy", family, None, None)]);
let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-005"),
"{family} must fail SP 800-131A broken-algorithm detection"
);
}
let ok = make_crypto_sbom(&[("hash", "SHA-384", None, None)]);
let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&ok);
assert!(
!result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-005"),
"SHA-384 must not be reported broken"
);
}
fn make_protocol_sbom(version: &str, suite_name: &str, suite_algos: &[&str]) -> NormalizedSbom {
use crate::model::{
CipherSuite, ComponentType, CryptoAssetType, CryptoProperties, ProtocolProperties,
ProtocolType,
};
let mut sbom = NormalizedSbom::default();
let mut c = crate::model::Component::new("tls-endpoint".into(), "proto-1".into());
c.component_type = ComponentType::Cryptographic;
c.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
ProtocolProperties::new(ProtocolType::Tls)
.with_version(version.to_string())
.with_cipher_suites(vec![CipherSuite {
name: Some(suite_name.to_string()),
algorithms: suite_algos.iter().map(ToString::to_string).collect(),
identifiers: Vec::new(),
}]),
),
);
sbom.add_component(c);
sbom
}
#[test]
fn protocol_tls10_rc4_fails_both_standards() {
let sbom = make_protocol_sbom("1.0", "TLS_RSA_WITH_RC4_128_SHA", &[]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(!cnsa.is_compliant, "TLS 1.0 must fail CNSA 2.0");
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
"TLS 1.0 must trip the CNSA TLS-1.3 version gate"
);
assert!(
cnsa.violations.iter().any(|v| {
v.rule_id == "SBOM-CNSA2-PROTO-002"
&& v.message.contains("RC4")
&& v.message.contains("RSA")
}),
"cipher-suite scan must flag RC4 and RSA; got {:?}",
cnsa.violations
.iter()
.map(|v| v.message.clone())
.collect::<Vec<_>>()
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(!pqc.is_compliant, "TLS 1.0 must fail PQC readiness");
assert!(
pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
"TLS 1.0 must trip the PQC minimum-version gate"
);
assert!(
pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-PROTO-002" && v.message.contains("broken")),
"cipher-suite scan must flag broken algorithms under PQC"
);
}
#[test]
fn protocol_tls13_cnsa_suite_passes() {
let sbom = make_protocol_sbom("1.3", "TLS_AES_256_GCM_SHA384", &[]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
!cnsa
.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-CNSA2-PROTO")),
"TLS 1.3 + CNSA suite must raise no CNSA protocol violations; got {:?}",
cnsa.violations
.iter()
.map(|v| v.message.clone())
.collect::<Vec<_>>()
);
assert!(cnsa.is_compliant);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
!pqc.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-PQC-PROTO")),
"TLS 1.3 + CNSA suite must raise no PQC protocol violations"
);
}
#[test]
fn protocol_tls12_fails_cnsa_only() {
let sbom = make_protocol_sbom("1.2", "TLS_AES_256_GCM_SHA384", &[]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
"TLS 1.2 must fail the CNSA 2.0 version gate"
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
!pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
"TLS 1.2 must pass the PQC minimum-version gate"
);
}
#[test]
fn protocol_resolves_cipher_suite_algorithm_refs() {
use crate::model::{
AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
};
let mut sbom = make_protocol_sbom("1.3", "OPAQUE_SUITE_1", &["suite-algo-7"]);
let mut rsa = crate::model::Component::new("legacy-kx".into(), "suite-algo-7".into());
rsa.component_type = ComponentType::Cryptographic;
rsa.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Pke).with_algorithm_family("RSA".into()),
),
);
sbom.add_component(rsa);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| { v.rule_id == "SBOM-CNSA2-PROTO-002" && v.message.contains("RSA") }),
"resolved suite algorithm ref must be classified; got {:?}",
cnsa.violations
.iter()
.map(|v| v.message.clone())
.collect::<Vec<_>>()
);
}
fn make_cert_sbom(sig_ref: &str, algo_family: Option<&str>) -> NormalizedSbom {
use crate::model::{
AlgorithmProperties, CertificateProperties, ComponentType, CryptoAssetType,
CryptoPrimitive, CryptoProperties,
};
let mut sbom = NormalizedSbom::default();
if let Some(family) = algo_family {
let mut algo = crate::model::Component::new("sig-algorithm".into(), sig_ref.into());
algo.component_type = ComponentType::Cryptographic;
algo.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Signature)
.with_algorithm_family(family.to_string()),
),
);
sbom.add_component(algo);
}
let mut cert = crate::model::Component::new("server-cert".into(), "cert-1".into());
cert.component_type = ComponentType::Cryptographic;
cert.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Certificate).with_certificate_properties(
CertificateProperties::new().with_signature_algorithm_ref(sig_ref.to_string()),
),
);
sbom.add_component(cert);
sbom
}
#[test]
fn cnsa2_cert_resolves_opaque_signature_ref() {
let sbom = make_cert_sbom("sig-algo-42", Some("RSA"));
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
"opaque ref resolving to RSA must fire CERT-001; got {:?}",
result
.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-CERT-001"),
"opaque ref resolving to RSA must fire the PQC certificate rule"
);
}
#[test]
fn cnsa2_cert_approved_and_fallback_cases() {
let ok = make_cert_sbom("sig-algo-42", Some("ML-DSA-87"));
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
assert!(
!result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
"ML-DSA-87-signed certificate must pass CERT-001"
);
let dangling = make_cert_sbom("crypto/algorithm/ecdsa-p256", None);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&dangling);
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
"unresolvable ECDSA-named ref must still fire CERT-001 via token fallback"
);
}
#[test]
fn cnsa2_key_material_alone_does_not_satisfy_inventory_gate() {
use crate::model::{
ComponentType, CryptoAssetType, CryptoMaterialType, CryptoProperties,
RelatedCryptoMaterialProperties,
};
let mut sbom = NormalizedSbom::default();
let mut key = crate::model::Component::new("some-key".into(), "key-1".into());
key.component_type = ComponentType::Cryptographic;
key.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
.with_related_crypto_material_properties(
RelatedCryptoMaterialProperties::new(CryptoMaterialType::PublicKey)
.with_size(2048),
),
);
sbom.add_component(key);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-000"),
"unevaluable key material must not satisfy the CNSA2-000 inventory gate"
);
assert!(!result.is_compliant);
}
#[test]
fn compound_family_spellings_fail_both_standards() {
let sbom = make_crypto_sbom(&[("legacy-cipher", "DES-CBC", None, None)]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(!cnsa.is_compliant, "DES-CBC must not be CNSA 2.0 compliant");
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-005" && v.message.contains("DES")),
"DES-CBC must fire the broken-algorithm rule; got {:?}",
cnsa.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations.iter().any(|v| v.rule_id == "SBOM-PQC-005"),
"DES-CBC must fire SP 800-131A under PQC"
);
let sbom = make_crypto_sbom(&[("aes-cbc", "AES-128-CBC", None, None)]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-001" && v.message.contains("AES-128")),
"AES-128-CBC must fire the AES-256-only rule"
);
let ok = make_crypto_sbom(&[("aead", "AES-256-GCM", None, None)]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
assert!(
!cnsa
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error),
"AES-256-GCM must pass the CNSA 2.0 allowlist; got {:?}",
cnsa.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn cnsa2_flags_truncated_sha2_variants() {
for family in ["SHA-512/256", "SHA-512/224"] {
let sbom = make_crypto_sbom(&[("hash", family, None, None)]);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
"{family} must fail the CNSA 2.0 hash gate; got {:?}",
result
.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
}
let ok = make_crypto_sbom(&[("hash", "SHA-512", None, None)]);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
assert!(
!result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
"SHA-512 must not trip the hash gate"
);
}
#[test]
fn name_fallback_reports_most_severe_token() {
use crate::model::{
AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
};
for name in ["sha384-rsa-signature", "rsa-sha384-signature"] {
let mut sbom = NormalizedSbom::default();
let mut c = crate::model::Component::new(name.into(), "algo-1".into());
c.component_type = ComponentType::Cryptographic;
c.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Signature),
),
);
sbom.add_component(c);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-006" && v.message.contains("RSA")),
"'{name}' must be flagged quantum-vulnerable under CNSA 2.0; got {:?}",
cnsa.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-001" && v.message.contains("RSA")),
"'{name}' must be flagged quantum-vulnerable under PQC"
);
}
}
#[test]
fn cert_unknown_signature_ref_warns_both_standards() {
let sbom = make_cert_sbom("sig-algo-42", None);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations.iter().any(|v| {
v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-CNSA2-CERT-UNKNOWN"
}),
"opaque dangling sig ref must warn under CNSA 2.0; got {:?}",
cnsa.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
assert!(
!cnsa
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
"an unverifiable ref is a Warning, not a CERT-001 Error"
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations.iter().any(|v| {
v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-PQC-CERT-UNKNOWN"
}),
"opaque dangling sig ref must warn under PQC"
);
let ok = make_cert_sbom("sig-algo-42", Some("ML-DSA-87"));
for level in [ComplianceLevel::Cnsa2, ComplianceLevel::NistPqc] {
let result = ComplianceChecker::new(level).check(&ok);
assert!(
!result
.violations
.iter()
.any(|v| v.rule_id.ends_with("CERT-UNKNOWN")),
"{level:?}: resolvable approved sig ref must not warn"
);
}
}
#[test]
fn protocol_without_evidence_warns_both_standards() {
use crate::model::{
ComponentType, CryptoAssetType, CryptoProperties, ProtocolProperties, ProtocolType,
};
let mut sbom = NormalizedSbom::default();
let mut c = crate::model::Component::new("ssh-endpoint".into(), "proto-1".into());
c.component_type = ComponentType::Cryptographic;
c.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Protocol)
.with_protocol_properties(ProtocolProperties::new(ProtocolType::Ssh)),
);
sbom.add_component(c);
for (level, rule) in [
(ComplianceLevel::Cnsa2, "SBOM-CNSA2-PROTO-UNKNOWN"),
(ComplianceLevel::NistPqc, "SBOM-PQC-PROTO-UNKNOWN"),
] {
let result = ComplianceChecker::new(level).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Warning && v.rule_id == rule),
"{level:?}: evidence-free protocol must warn with {rule}; got {:?}",
result
.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
}
let ok = make_protocol_sbom("1.3", "TLS_AES_256_GCM_SHA384", &[]);
for level in [ComplianceLevel::Cnsa2, ComplianceLevel::NistPqc] {
let result = ComplianceChecker::new(level).check(&ok);
assert!(
!result
.violations
.iter()
.any(|v| v.rule_id.ends_with("PROTO-UNKNOWN")),
"{level:?}: evaluable protocol must not warn"
);
}
}
#[test]
fn ikev2_opaque_transform_refs_warn_both_standards() {
use crate::model::{
ComponentType, CryptoAssetType, CryptoProperties, Ikev2TransformTypes,
ProtocolProperties, ProtocolType,
};
let mut sbom = NormalizedSbom::default();
let mut c = crate::model::Component::new("ipsec-tunnel".into(), "proto-1".into());
c.component_type = ComponentType::Cryptographic;
c.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
ProtocolProperties::new(ProtocolType::Ikev2).with_ikev2_transform_types(
Ikev2TransformTypes {
encr: vec!["transform-encr-7".into()],
prf: vec!["transform-prf-3".into()],
integ: vec!["transform-integ-2".into()],
ke: vec!["transform-ke-9".into()],
},
),
),
);
sbom.add_component(c);
for (level, rule) in [
(ComplianceLevel::Cnsa2, "SBOM-CNSA2-PROTO-UNKNOWN"),
(ComplianceLevel::NistPqc, "SBOM-PQC-PROTO-UNKNOWN"),
] {
let result = ComplianceChecker::new(level).check(&sbom);
assert!(
result.violations.iter().any(|v| {
v.severity == ViolationSeverity::Warning
&& v.rule_id == rule
&& v.message.contains("transform-encr-7")
}),
"{level:?}: opaque IKEv2 transforms must warn with {rule}; got {:?}",
result
.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
}
}
#[test]
fn tls_version_spellings_parse_tolerantly() {
for version in ["TLSv1.3", "tls1.3", "1.3.0", " 1.3", "v1.3"] {
let sbom = make_protocol_sbom(version, "TLS_AES_256_GCM_SHA384", &[]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
!cnsa
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
"'{version}' must count as TLS 1.3; got {:?}",
cnsa.violations
.iter()
.map(|v| v.message.clone())
.collect::<Vec<_>>()
);
}
let sbom = make_protocol_sbom("TLSv1.0", "TLS_AES_256_GCM_SHA384", &[]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
"TLSv1.0 must fail the CNSA 2.0 version gate"
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
"TLSv1.0 must fail the PQC minimum-version gate (previously silent)"
);
let sbom = make_protocol_sbom("quantum-safe", "TLS_AES_256_GCM_SHA384", &[]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
"an unparseable version cannot affirm TLS 1.3"
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Warning
&& v.rule_id == "SBOM-PQC-PROTO-UNKNOWN"
&& v.message.contains("quantum-safe")),
"an unparseable version must be a cannot-verify Warning under PQC"
);
}
#[test]
fn protocol_ref_resolution_keeps_declared_security_level() {
use crate::model::{
AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
ProtocolProperties, ProtocolType,
};
let make = |classical_level: u32| {
let mut sbom = NormalizedSbom::default();
let mut aes = crate::model::Component::new("aes-gcm-cipher".into(), "algo-aes".into());
aes.component_type = ComponentType::Cryptographic;
aes.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Ae)
.with_algorithm_family("AES".into())
.with_classical_security_level(classical_level)
.with_nist_quantum_security_level(5),
),
);
sbom.add_component(aes);
let mut proto = crate::model::Component::new("tls-endpoint".into(), "proto-1".into());
proto.component_type = ComponentType::Cryptographic;
proto.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
ProtocolProperties::new(ProtocolType::Tls)
.with_version("1.3".into())
.with_crypto_ref_array(vec!["algo-aes".into()]),
),
);
sbom.add_component(proto);
sbom
};
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&make(256));
assert!(
!result
.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error),
"AES with classicalSecurityLevel=256 must pass via ref too; got {:?}",
result
.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&make(128));
assert!(
result
.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-PROTO-002" && v.message.contains("AES")),
"AES-128 referenced from a protocol must still fire PROTO-002"
);
}
#[test]
fn national_algorithms_fail_both_standards() {
for family in ["SM2", "GOST R 34.10", "brainpoolP256r1"] {
let sbom = make_crypto_sbom(&[("national-sig", family, None, None)]);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
pqc.violations
.iter()
.any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-001"),
"{family} must fire the quantum-vulnerable rule; got {:?}",
pqc.violations
.iter()
.map(|v| (v.rule_id, v.message.clone()))
.collect::<Vec<_>>()
);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-006"),
"{family} must fail the CNSA 2.0 allowlist as quantum-vulnerable"
);
}
for family in ["SM4", "Streebog"] {
let sbom = make_crypto_sbom(&[("national-prim", family, None, None)]);
let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
assert!(
cnsa.violations
.iter()
.any(|v| v.rule_id == "SBOM-CNSA2-ALG-008"),
"{family} must be recognized as not CNSA 2.0-approved"
);
let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
assert!(
!pqc.violations
.iter()
.any(|v| v.rule_id == "SBOM-PQC-001" || v.rule_id == "SBOM-PQC-005"),
"{family} must not be reported broken or quantum-vulnerable"
);
}
}
fn refs_for(rule_id: &'static str) -> Vec<StandardRef> {
let v = Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: String::new(),
element: None,
requirement: String::new(),
rule_id,
component_id: None,
counts: None,
standard_refs: Vec::new(),
};
v.registry_standard_refs()
}
#[test]
fn registry_refs_for_machine_readable_include_annex_and_pren() {
let refs = refs_for("SBOM-CRA-MACHINE-READABLE");
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II (1)"),
"expected CRA Annex I Part II (1); got {refs:?}"
);
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-04"),
"expected prEN PRE-7-RQ-04; got {refs:?}"
);
}
#[test]
fn registry_refs_for_annex_i_identifier_include_pren_07() {
let refs = refs_for("SBOM-CRA-ANNEX-I-IDENTIFIER");
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07"),
"expected PRE-7-RQ-07; got {refs:?}"
);
let pren_count = refs
.iter()
.filter(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07")
.count();
assert_eq!(pren_count, 1, "PRE-7-RQ-07 should appear exactly once");
}
#[test]
fn registry_refs_for_supply_chain_include_annex_and_pren() {
let refs = refs_for("SBOM-CRA-ANNEX-I-SUPPLY-CHAIN");
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II"),
"expected Annex I Part II; got {refs:?}"
);
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-01"),
"expected PRE-7-RQ-01; got {refs:?}"
);
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-03"),
"expected PRE-7-RQ-03; got {refs:?}"
);
}
#[test]
fn registry_refs_for_cvd_policy_include_annex_and_pren_rls() {
let refs = refs_for("SBOM-CRA-CVD-POLICY");
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II (5)"),
"expected CRA Annex I Part II (5); got {refs:?}"
);
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "RLS-2-RQ-03-RE"),
"expected RLS-2-RQ-03-RE; got {refs:?}"
);
}
#[test]
fn registry_refs_for_ssdf_ps2() {
let refs = refs_for("SBOM-SSDF-PS2");
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::NistSsdf && r.id == "PS.2"),
"expected NIST SSDF PS.2; got {refs:?}"
);
}
#[test]
fn every_emitted_violation_has_a_registered_rule_id() {
let sbom = NormalizedSbom::default();
for level in ComplianceLevel::all() {
let result = ComplianceChecker::new(*level).check(&sbom);
for v in &result.violations {
assert!(
rule_meta(v.rule_id).is_some(),
"level {level:?}: violation {:?} has unregistered rule_id {:?}",
v.requirement,
v.rule_id
);
}
}
}
#[test]
fn check_populates_standard_refs_for_cra_violations() {
let sbom = NormalizedSbom::default();
let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
let result = checker.check(&sbom);
let cra_violations: Vec<_> = result
.violations
.iter()
.filter(|v| v.requirement.to_lowercase().contains("cra"))
.collect();
assert!(
!cra_violations.is_empty(),
"empty SBOM should produce some CRA violations"
);
for v in &cra_violations {
assert!(
!v.standard_refs.is_empty(),
"CRA violation {:?} should have standard_refs populated",
v.requirement
);
}
}
#[test]
fn sidecar_supplies_security_contact_downgrades_art_13_17() {
use crate::model::CraSidecarMetadata;
let sbom = NormalizedSbom::default();
let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let art_13_17_warning = bare.violations.iter().find(|v| {
v.requirement.contains("Art. 13(17)") && v.severity == ViolationSeverity::Warning
});
assert!(
art_13_17_warning.is_some(),
"Without sidecar, Art. 13(17) should be a Warning"
);
let sidecar = CraSidecarMetadata {
security_contact: Some("security@example.com".to_string()),
..Default::default()
};
let withsc = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_sidecar(sidecar)
.check(&sbom);
let art_13_17_info = withsc.violations.iter().find(|v| {
v.requirement.contains("Art. 13(17)") && v.severity == ViolationSeverity::Info
});
assert!(
art_13_17_info.is_some(),
"With sidecar, Art. 13(17) should be downgraded to Info"
);
assert!(
!withsc
.violations
.iter()
.any(|v| v.requirement.contains("Art. 13(17)")
&& v.severity == ViolationSeverity::Warning),
"With sidecar, no Warning-level Art. 13(17) violation should remain"
);
}
#[test]
fn sidecar_supplies_product_name_downgrades_art_13_15() {
use crate::model::CraSidecarMetadata;
let sbom = NormalizedSbom::default();
let sidecar = CraSidecarMetadata {
product_name: Some("Demo Product".to_string()),
..Default::default()
};
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_sidecar(sidecar)
.check(&sbom);
let downgraded = result.violations.iter().find(|v| {
v.requirement.contains("Art. 13(15)") && v.severity == ViolationSeverity::Info
});
assert!(
downgraded.is_some(),
"Sidecar product_name should downgrade Art. 13(15) to Info"
);
}
#[test]
fn sidecar_supplies_manufacturer_downgrades_art_13_16() {
use crate::model::CraSidecarMetadata;
let sbom = NormalizedSbom::default();
let sidecar = CraSidecarMetadata {
manufacturer_name: Some("Demo Corp".to_string()),
..Default::default()
};
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_sidecar(sidecar)
.check(&sbom);
let downgraded = result.violations.iter().find(|v| {
v.requirement.contains("Art. 13(16)") && v.severity == ViolationSeverity::Info
});
assert!(
downgraded.is_some(),
"Sidecar manufacturer_name should downgrade Art. 13(16) to Info"
);
}
#[test]
fn sidecar_supplies_cvd_url_downgrades_cvd_policy() {
use crate::model::CraSidecarMetadata;
let sbom = NormalizedSbom::default();
let sidecar = CraSidecarMetadata {
vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
..Default::default()
};
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_sidecar(sidecar)
.check(&sbom);
let downgraded = result.violations.iter().find(|v| {
v.requirement.contains("Annex I Part II (5)") && v.severity == ViolationSeverity::Info
});
assert!(
downgraded.is_some(),
"Sidecar CVD URL should downgrade the Annex I Part II (5) CVD-policy finding to Info"
);
}
fn vendor_component(name: &str, with_hash: bool) -> crate::model::Component {
use crate::model::{Component, Hash, HashAlgorithm, Organization};
let mut c = Component::new(name.to_string(), name.to_string())
.with_purl(format!("pkg:cargo/{name}@1.0.0"));
c.supplier = Some(Organization::new("VendorCorp".to_string()));
if with_hash {
c.hashes.push(Hash::new(
HashAlgorithm::Sha256,
"0000000000000000000000000000000000000000000000000000000000000000".to_string(),
));
}
c
}
fn hw_component(
name: &str,
kind: crate::model::ComponentType,
with_purl: bool,
with_supplier: bool,
version: Option<&str>,
) -> crate::model::Component {
use crate::model::{Component, Organization};
let mut c = Component::new(name.to_string(), name.to_string());
c.component_type = kind;
if with_purl {
c = c.with_purl(format!("pkg:generic/{name}"));
}
if with_supplier {
c.supplier = Some(Organization::new("HardwareCorp".to_string()));
}
if let Some(v) = version {
c = c.with_version(v.to_string());
}
c
}
#[test]
fn hardware_check_skipped_for_software_only_sbom() {
let mut sbom = NormalizedSbom::default();
let c = vendor_component("software", true);
sbom.components.insert(c.canonical_id.clone(), c);
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
!result
.violations
.iter()
.any(|v| v.requirement.contains("PRE-8-RQ-02")),
"Software-only SBOM should produce no PRE-8-RQ-02 violations"
);
}
#[test]
fn hardware_check_passes_for_complete_firmware() {
use crate::model::ComponentType;
let mut sbom = NormalizedSbom::default();
let c = hw_component(
"router-fw",
ComponentType::Firmware,
true,
true,
Some("1.2.3"),
);
sbom.components.insert(c.canonical_id.clone(), c);
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
!result
.violations
.iter()
.any(|v| v.requirement.contains("PRE-8-RQ-02")),
"Complete firmware component should pass [PRE-8-RQ-02]"
);
}
#[test]
fn hardware_check_flags_firmware_without_version() {
use crate::model::ComponentType;
let mut sbom = NormalizedSbom::default();
let c = hw_component("router-fw", ComponentType::Firmware, true, true, None);
sbom.components.insert(c.canonical_id.clone(), c);
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
result.violations.iter().any(|v| {
v.requirement.contains("Firmware version") && v.severity == ViolationSeverity::Error
}),
"Firmware without version should produce an Error"
);
}
#[test]
fn hardware_check_flags_missing_producer() {
use crate::model::ComponentType;
let mut sbom = NormalizedSbom::default();
let c = hw_component("router", ComponentType::Device, true, false, Some("1.0"));
sbom.components.insert(c.canonical_id.clone(), c);
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
result.violations.iter().any(|v| {
v.requirement.contains("Hardware producer")
&& v.severity == ViolationSeverity::Error
}),
"Hardware without producer should produce an Error"
);
}
#[test]
fn hardware_check_flags_synthetic_identifier() {
use crate::model::{Component, ComponentType, Organization};
let mut sbom = NormalizedSbom::default();
let mut c = Component::new("router".to_string(), "router".to_string())
.with_version("1.0".to_string());
c.component_type = ComponentType::Device;
c.supplier = Some(Organization::new("HardwareCorp".to_string()));
sbom.components.insert(c.canonical_id.clone(), c);
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
result.violations.iter().any(|v| {
v.requirement.contains("Hardware identifier")
&& v.severity == ViolationSeverity::Error
}),
"Hardware with synthetic ID should produce an Error"
);
}
#[test]
fn hardware_check_device_with_firmware_dep_passes() {
use crate::model::{ComponentType, DependencyEdge, DependencyType};
let mut sbom = NormalizedSbom::default();
let device = hw_component("router", ComponentType::Device, true, true, None);
let firmware = hw_component(
"router-fw",
ComponentType::Firmware,
true,
true,
Some("1.2.3"),
);
let device_id = device.canonical_id.clone();
let firmware_id = firmware.canonical_id.clone();
sbom.components.insert(device_id.clone(), device);
sbom.components.insert(firmware_id.clone(), firmware);
sbom.edges.push(DependencyEdge::new(
device_id,
firmware_id,
DependencyType::DependsOn,
));
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
!result
.violations
.iter()
.any(|v| { v.requirement.contains("Device firmware association") }),
"Device with firmware dependency should not trigger version warning"
);
}
#[test]
fn vendor_hash_coverage_full() {
use crate::quality::HashQualityMetrics;
let mut sbom = NormalizedSbom::default();
for n in ["a", "b", "c", "d", "e"] {
let c = vendor_component(n, true);
sbom.components.insert(c.canonical_id.clone(), c);
}
let m = HashQualityMetrics::from_sbom(&sbom);
assert_eq!(m.vendor_components_total, 5);
assert_eq!(m.vendor_components_with_hash, 5);
assert_eq!(m.vendor_hash_coverage(), Some(1.0));
}
#[test]
fn vendor_hash_coverage_partial_triggers_warning() {
let mut sbom = NormalizedSbom::default();
for n in ["a", "b", "c", "d", "e", "f", "g"] {
let c = vendor_component(n, true);
sbom.components.insert(c.canonical_id.clone(), c);
}
for n in ["h", "i", "j"] {
let c = vendor_component(n, false);
sbom.components.insert(c.canonical_id.clone(), c);
}
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let v = result.violations.iter().find(|v| {
v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Warning
});
assert!(
v.is_some(),
"70% vendor-hash coverage should produce a Warning under CraPhase2"
);
}
#[test]
fn vendor_hash_coverage_below_50_triggers_error() {
let mut sbom = NormalizedSbom::default();
for n in ["a", "b", "c", "d"] {
let c = vendor_component(n, true);
sbom.components.insert(c.canonical_id.clone(), c);
}
for n in ["e", "f", "g", "h", "i", "j"] {
let c = vendor_component(n, false);
sbom.components.insert(c.canonical_id.clone(), c);
}
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let v = result.violations.iter().find(|v| {
v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Error
});
assert!(
v.is_some(),
"40% vendor-hash coverage should produce an Error under CraPhase2"
);
}
#[test]
fn vendor_hash_coverage_no_vendor_components_no_violation() {
let mut sbom = NormalizedSbom::default();
use crate::model::Component;
for n in ["a", "b", "c"] {
let c = Component::new(n.to_string(), n.to_string());
sbom.components.insert(c.canonical_id.clone(), c);
}
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
assert!(
!result
.violations
.iter()
.any(|v| v.requirement.contains("PRE-7-RQ-07-RE")),
"No vendor components → no [PRE-7-RQ-07-RE] violation"
);
}
#[test]
fn art_13_2_warns_when_no_risk_assessment_referenced() {
let sbom = NormalizedSbom::default();
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let v = result.violations.iter().find(|v| {
v.requirement.contains("Art. 13(2)") && v.severity == ViolationSeverity::Warning
});
assert!(v.is_some(), "Empty SBOM should produce Art. 13(2) Warning");
}
#[test]
fn art_13_2_silenced_by_sidecar_risk_assessment_url() {
use crate::model::CraSidecarMetadata;
let sbom = NormalizedSbom::default();
let sidecar = CraSidecarMetadata {
risk_assessment_url: Some("https://example.com/ra.pdf".to_string()),
..Default::default()
};
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_sidecar(sidecar)
.check(&sbom);
assert!(
!result
.violations
.iter()
.any(|v| v.requirement.contains("Art. 13(2)")),
"Sidecar risk_assessment_url should suppress Art. 13(2) violation"
);
}
#[test]
fn article_14_pre_deadline_emits_info_only() {
let sbom = NormalizedSbom::default();
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let art14_count = result
.violations
.iter()
.filter(|v| v.requirement.contains("Art. 14"))
.count();
assert!(
art14_count >= 4,
"Art. 14 readiness should produce ≥4 violations (PSIRT, 14(1), 14(2), 14(7)); got {art14_count}"
);
}
#[test]
fn article_14_pre_deadline_mocked_clock_emits_4_infos() {
let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
let mut violations = Vec::new();
let now = chrono::DateTime::parse_from_rfc3339("2026-04-26T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
checker.check_article_14_readiness_at(now, &mut violations);
let infos = violations
.iter()
.filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
.count();
let warnings = violations
.iter()
.filter(|v| {
v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
})
.count();
assert_eq!(
infos, 4,
"Pre-deadline expects 4 Info-level Art. 14 findings; got {infos} (full list: {violations:?})"
);
assert_eq!(
warnings, 0,
"Pre-deadline expects 0 Warning-level Art. 14 findings"
);
}
#[test]
fn article_14_post_deadline_mocked_clock_emits_3_warnings_1_info() {
let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
let mut violations = Vec::new();
let now = chrono::DateTime::parse_from_rfc3339("2026-12-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
checker.check_article_14_readiness_at(now, &mut violations);
let infos = violations
.iter()
.filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
.count();
let warnings = violations
.iter()
.filter(|v| {
v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
})
.count();
assert_eq!(
warnings, 3,
"Post-deadline expects 3 Warning-level Art. 14 findings (PSIRT/14(1)/14(2)); got {warnings} (full: {violations:?})"
);
assert_eq!(
infos, 1,
"Post-deadline expects 1 Info-level Art. 14 finding (Art. 14(7) ENISA platform stays Info regardless of date)"
);
}
#[test]
fn article_14_sidecar_suppresses_psirt_warning() {
use crate::model::CraSidecarMetadata;
let sbom = NormalizedSbom::default();
let sidecar = CraSidecarMetadata {
psirt_url: Some("https://example.com/psirt".to_string()),
early_warning_contact: Some("psirt@example.com".to_string()),
incident_report_contact: Some("ir@example.com".to_string()),
..Default::default()
};
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_sidecar(sidecar)
.check(&sbom);
let art_14_psirt = result
.violations
.iter()
.any(|v| v.requirement.contains("Art. 14: PSIRT"));
let art_14_1 = result
.violations
.iter()
.any(|v| v.requirement.contains("Art. 14(1)"));
let art_14_2 = result
.violations
.iter()
.any(|v| v.requirement.contains("Art. 14(2)"));
assert!(
!art_14_psirt,
"Sidecar psirt_url should suppress PSIRT check"
);
assert!(
!art_14_1,
"Sidecar early_warning_contact should suppress 14(1)"
);
assert!(
!art_14_2,
"Sidecar incident_report_contact should suppress 14(2)"
);
}
#[test]
fn direct_dep_missing_supplier_is_error_under_cra_phase2() {
use crate::model::{Component, DependencyEdge, DependencyType};
let mut sbom = NormalizedSbom::default();
let app = Component::new("app".to_string(), "app".to_string())
.with_purl("pkg:cargo/app@1.0".to_string());
let lib = Component::new("lib".to_string(), "lib".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
let app_id = app.canonical_id.clone();
let lib_id = lib.canonical_id.clone();
sbom.primary_component_id = Some(app_id.clone());
sbom.components.insert(app_id.clone(), app);
sbom.components.insert(lib_id.clone(), lib);
sbom.edges.push(DependencyEdge::new(
app_id,
lib_id,
DependencyType::DependsOn,
));
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let v = result.violations.iter().find(|v| {
v.requirement.contains("Direct dependency supplier")
&& v.severity == ViolationSeverity::Error
});
assert!(
v.is_some(),
"Direct dep without supplier should produce an Error under CraPhase2"
);
}
#[test]
fn transitive_dep_missing_supplier_is_softer_than_direct() {
use crate::model::{Component, DependencyEdge, DependencyType, Organization};
let mut sbom = NormalizedSbom::default();
let mut app = Component::new("app".to_string(), "app".to_string())
.with_purl("pkg:cargo/app@1.0".to_string());
app.supplier = Some(Organization::new("AppCorp".to_string()));
let mut lib = Component::new("lib".to_string(), "lib".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
lib.supplier = Some(Organization::new("LibCorp".to_string()));
let deep = Component::new("deep".to_string(), "deep".to_string())
.with_purl("pkg:cargo/deep@1.0".to_string());
let app_id = app.canonical_id.clone();
let lib_id = lib.canonical_id.clone();
let deep_id = deep.canonical_id.clone();
sbom.primary_component_id = Some(app_id.clone());
sbom.components.insert(app_id.clone(), app);
sbom.components.insert(lib_id.clone(), lib);
sbom.components.insert(deep_id.clone(), deep);
sbom.edges.push(DependencyEdge::new(
app_id,
lib_id.clone(),
DependencyType::DependsOn,
));
sbom.edges.push(DependencyEdge::new(
lib_id,
deep_id,
DependencyType::DependsOn,
));
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let direct_err = result.violations.iter().any(|v| {
v.requirement.contains("Direct dependency supplier")
&& v.severity == ViolationSeverity::Error
});
let transitive = result
.violations
.iter()
.find(|v| v.requirement.contains("Transitive dependency supplier"));
assert!(
!direct_err,
"No direct deps lack a supplier; should not error"
);
assert!(transitive.is_some(), "Transitive dep should be reported");
assert_ne!(
transitive.unwrap().severity,
ViolationSeverity::Error,
"Transitive supplier missing should never be Error (it's recommended, not mandatory)"
);
}
fn bsi_ok_component(name: &str) -> crate::model::Component {
use crate::model::{Component, Hash, HashAlgorithm, LicenseExpression, Organization};
let mut c = Component::new(name.to_string(), name.to_string())
.with_purl(format!("pkg:cargo/{name}@1.0"))
.with_version("1.0".to_string());
c.hashes
.push(Hash::new(HashAlgorithm::Sha512, "f".repeat(128)));
c.supplier = Some(Organization::new(format!("{name}-vendor")));
c.licenses
.add_declared(LicenseExpression::new("MIT".to_string()));
c
}
#[test]
fn bsi_tr_03183_2_empty_sbom_emits_errors() {
let sbom = NormalizedSbom::default();
let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
result
.violations
.iter()
.any(|v| v.requirement.contains("BSI TR-03183-2 §5.2.1")
&& v.severity == ViolationSeverity::Error),
"Empty SBOM should fail BSI §5.2.1 (creator missing)"
);
}
#[test]
fn bsi_tr_03183_2_requires_sha512_hash() {
use crate::model::{Hash, HashAlgorithm};
let check = |alg: Option<HashAlgorithm>, hexlen: usize| {
let mut sbom = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.hashes.clear();
if let Some(alg) = alg {
c.hashes.push(Hash::new(alg, "0".repeat(hexlen)));
}
sbom.add_component(c);
ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom)
};
let md5 = check(Some(HashAlgorithm::Md5), 32);
assert!(
md5.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
&& v.severity == ViolationSeverity::Error),
"MD5-only component must fail the §5.2.2 SHA-512 requirement"
);
let sha256 = check(Some(HashAlgorithm::Sha256), 64);
assert!(
sha256
.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
&& v.severity == ViolationSeverity::Error),
"SHA-256-only component must now FAIL the hash rule (§5.2.2 names SHA-512)"
);
let sha512 = check(Some(HashAlgorithm::Sha512), 128);
assert!(
!sha512
.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-4")),
"SHA-512 component must satisfy the hash rule"
);
let none = check(None, 0);
assert!(
none.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4-MISSING"
&& v.severity == ViolationSeverity::Warning),
"hash-less component must warn (§3.2.1 legitimate-omission escape)"
);
assert!(
!none
.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"),
"hash-less component must not also fire the wrong-algorithm Error"
);
}
#[test]
fn bsi_tr_03183_2_format_gate() {
use crate::model::SbomFormat;
let check = |format: SbomFormat, version: &str| {
let mut sbom = NormalizedSbom::default();
sbom.document.format = format;
sbom.document.spec_version = version.to_string();
sbom.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
r.violations.iter().any(|v| {
v.rule_id == "SBOM-BSI-TR-03183-2-4" && v.severity == ViolationSeverity::Error
})
};
assert!(
check(SbomFormat::CycloneDx, "1.5"),
"CycloneDX 1.5 must fail the §4 format gate"
);
assert!(
!check(SbomFormat::CycloneDx, "1.6"),
"CycloneDX 1.6 must pass the §4 format gate"
);
assert!(
!check(SbomFormat::CycloneDx, "1.7"),
"CycloneDX 1.7 must pass the §4 format gate"
);
assert!(
check(SbomFormat::Spdx, "2.3"),
"SPDX 2.3 must fail the §4 format gate"
);
assert!(
check(SbomFormat::Spdx, "3.0"),
"SPDX 3.0 is below the 3.0.1 minimum and must fail the §4 gate"
);
assert!(
!check(SbomFormat::Spdx, "3.0.1"),
"SPDX 3.0.1 must pass the §4 format gate"
);
assert!(
!check(SbomFormat::CycloneDx, ""),
"a document with no spec_version must skip the §4 gate"
);
}
#[test]
fn bsi_tr_03183_2_does_not_require_tool_creator() {
use crate::model::{Creator, CreatorType};
let mut sbom = NormalizedSbom::default();
sbom.document.creators.push(Creator {
creator_type: CreatorType::Person,
name: "Jane Doe".to_string(),
email: Some("jane@example.org".to_string()),
});
sbom.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
!r.violations.iter().any(|v| v.message.contains("tool")),
"no violation may demand a generation tool (not mandated in any TR tier)"
);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
"a Person creator with an email satisfies §5.2.1 entirely"
);
}
#[test]
fn bsi_tr_03183_2_creator_contact_granularity() {
use crate::model::{Creator, CreatorType};
let base = || {
let mut sbom = NormalizedSbom::default();
sbom.add_component(bsi_ok_component("lib"));
sbom
};
let empty = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&base());
assert!(
empty
.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"
&& v.severity == ViolationSeverity::Error),
"no creator at all must be a §5.2.1 Error"
);
let mut contactless = base();
contactless.document.creators.push(Creator {
creator_type: CreatorType::Organization,
name: "Acme".to_string(),
email: None,
});
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&contactless);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1-CONTACT"
&& v.severity == ViolationSeverity::Warning),
"a creator without email/URL must be a §5.2.1 Warning"
);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"),
"a contactless creator is present — the absence Error must not fire"
);
let mut with_url = base();
with_url.document.creators.push(Creator {
creator_type: CreatorType::Organization,
name: "https://acme.example".to_string(),
email: None,
});
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&with_url);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
"a creator URL satisfies §5.2.1 (URL fallback when no email exists)"
);
}
#[test]
fn bsi_tr_03183_2_tools_only_creators_fail_creator_gate() {
use crate::model::{Creator, CreatorType};
let mut sbom = NormalizedSbom::default();
sbom.document.creators.push(Creator {
creator_type: CreatorType::Tool,
name: "syft 1.18.0".to_string(),
email: None,
});
sbom.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"
&& v.severity == ViolationSeverity::Error),
"a tools-only creators list must fail the §5.2.1 creator gate"
);
sbom.document.creators.push(Creator {
creator_type: CreatorType::Organization,
name: "Demo Corp".to_string(),
email: Some("sbom@demo.example".to_string()),
});
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
"an Organization creator with email satisfies §5.2.1 entirely"
);
}
#[test]
fn bsi_tr_03183_2_contact_warning_ignores_tool_contacts() {
use crate::model::{Creator, CreatorType};
let mut sbom = NormalizedSbom::default();
sbom.document.creators.push(Creator {
creator_type: CreatorType::Tool,
name: "sbom-gen (https://sbom-gen.example)".to_string(),
email: None,
});
sbom.document.creators.push(Creator {
creator_type: CreatorType::Organization,
name: "Acme".to_string(),
email: None,
});
sbom.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1-CONTACT"
&& v.severity == ViolationSeverity::Warning),
"a tool's URL must not satisfy the creator contact requirement"
);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"),
"the Organization creator is present — only the contact Warning may fire"
);
}
#[test]
fn bsi_tr_03183_2_sha512_counts_only_authored_hashes() {
use crate::model::{Hash, HashAlgorithm};
let check = |hashes: Vec<Hash>| {
let mut sbom = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.hashes = hashes;
sbom.add_component(c);
ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom)
};
let r = check(vec![
Hash::new(HashAlgorithm::Sha256, "0".repeat(64)),
Hash::enriched(HashAlgorithm::Sha512, "0".repeat(128)),
]);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
&& v.severity == ViolationSeverity::Error),
"an enriched SHA-512 must not mask a missing authored SHA-512"
);
let r = check(vec![Hash::new(HashAlgorithm::Sha512, "f".repeat(128))]);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-4")),
"an authored SHA-512 satisfies §5.2.2"
);
let r = check(vec![Hash::enriched(HashAlgorithm::Sha512, "0".repeat(128))]);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4-MISSING"
&& v.severity == ViolationSeverity::Warning),
"enriched-only hashes must count as no authored hash at all"
);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"),
"enriched-only hashes must not fire the wrong-algorithm Error"
);
}
#[test]
fn bsi_tr_03183_2_gates_on_missing_version() {
let mut sbom = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.version = None;
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-VERSION"
&& v.severity == ViolationSeverity::Error),
"a version-less component must fail BSI §5.2.2"
);
let mut ok = NormalizedSbom::default();
ok.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&ok);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-VERSION"),
"a versioned component must not fire the version rule"
);
}
#[test]
fn bsi_tr_03183_2_licence_rules() {
use crate::model::LicenseExpression;
let mut unlicensed = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.licenses.declared.clear();
c.licenses.concluded = None;
unlicensed.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&unlicensed);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE"
&& v.severity == ViolationSeverity::Error),
"a component without distribution licences must fail §5.2.2"
);
let mut non_spdx = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.licenses.declared.clear();
c.licenses.add_declared(LicenseExpression::new(
"Standard Commercial Terms, see LICENSE.txt".to_string(),
));
non_spdx.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&non_spdx);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE-SPDX"
&& v.severity == ViolationSeverity::Warning),
"a non-SPDX licence expression must warn under §6.1"
);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE"),
"a declared (non-SPDX) licence still counts as present"
);
let mut spdx_ok = NormalizedSbom::default();
spdx_ok.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&spdx_ok);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-LICENSE")),
"an SPDX-named licence satisfies both §5.2.2 and §6.1"
);
}
#[test]
fn bsi_tr_03183_2_warns_on_missing_component_creator() {
let mut sbom = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.supplier = None;
c.author = None;
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-CREATOR"
&& v.severity == ViolationSeverity::Warning),
"a component without supplier/author must warn under §5.2.2"
);
let mut ok = NormalizedSbom::default();
ok.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&ok);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-CREATOR"),
"a supplied component must not fire the creator rule"
);
}
#[test]
fn bsi_tr_03183_2_identifier_is_warning_not_error() {
let mut sbom = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.identifiers.purl = None;
c.canonical_id = c.identifiers.canonical_id();
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
let id_violations: Vec<_> = r
.violations
.iter()
.filter(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-2-4")
.collect();
assert!(
!id_violations.is_empty(),
"a purl-less component must fire the §5.2.4 identifier rule"
);
assert!(
id_violations
.iter()
.all(|v| v.severity == ViolationSeverity::Warning),
"the §5.2.4 identifier rule is additional-tier: Warning, not Error"
);
}
#[test]
fn bsi_tr_03183_2_warns_on_undeclared_completeness() {
use crate::model::CompletenessDeclaration;
let mut sbom = NormalizedSbom::default();
sbom.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-5-COMPLETENESS"
&& v.severity == ViolationSeverity::Warning),
"an SBOM without a completeness declaration must warn under §5.2.2"
);
sbom.document.completeness_declaration = CompletenessDeclaration::Complete;
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-5-COMPLETENESS"),
"a declared completeness must not warn"
);
}
#[test]
fn bsi_tr_03183_2_warns_on_embedded_vulnerabilities() {
use crate::model::{VulnerabilityRef, VulnerabilitySource};
let mut sbom = NormalizedSbom::default();
let mut c = bsi_ok_component("lib");
c.vulnerabilities.push(VulnerabilityRef::new(
"CVE-2026-0001".to_string(),
VulnerabilitySource::Cve,
));
sbom.add_component(c);
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-3-1"
&& v.severity == ViolationSeverity::Warning),
"embedded vulnerability information must warn under §3.1"
);
let mut clean = NormalizedSbom::default();
clean.add_component(bsi_ok_component("lib"));
let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&clean);
assert!(
!r.violations
.iter()
.any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-3-1"),
"a vulnerability-free SBOM must not fire §3.1"
);
}
#[test]
fn bsi_tr_03183_2_passes_for_complete_component() {
use crate::model::{Creator, CreatorType, DependencyEdge, DependencyType};
let mut sbom = NormalizedSbom::default();
sbom.document.creators.push(Creator {
creator_type: CreatorType::Person,
name: "Release Engineering".to_string(),
email: Some("sbom@example.org".to_string()),
});
let a = bsi_ok_component("a");
let b = bsi_ok_component("b");
let a_id = a.canonical_id.clone();
let b_id = b.canonical_id.clone();
sbom.components.insert(a_id.clone(), a);
sbom.components.insert(b_id.clone(), b);
sbom.edges
.push(DependencyEdge::new(a_id, b_id, DependencyType::DependsOn));
let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
let errors: Vec<_> = result
.violations
.iter()
.filter(|v| v.severity == ViolationSeverity::Error)
.collect();
assert!(
errors.is_empty(),
"Complete BSI-compliant SBOM should produce no Errors; got: {errors:?}"
);
}
#[test]
fn bsi_tr_03183_2_in_compliance_level_all() {
assert_eq!(ComplianceLevel::all().len(), 19);
assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiTr03183_2));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::CraOssSteward));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuccSubstantial));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuAiAct));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiSbomForAi));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::Cisa2026));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::PciDss632));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::Fsct));
}
#[test]
fn sidecar_does_not_override_present_sbom_field() {
use crate::model::{CraSidecarMetadata, Creator, CreatorType};
let mut sbom = NormalizedSbom::default();
sbom.document.creators.push(Creator {
creator_type: CreatorType::Organization,
name: "SbomDeclaredCorp".to_string(),
email: None,
});
let sidecar = CraSidecarMetadata {
manufacturer_name: Some("SidecarCorp".to_string()),
..Default::default()
};
let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
.with_sidecar(sidecar)
.check(&sbom);
assert!(
!result.violations.iter().any(|v| v
.requirement
.contains("Art. 13(16): Manufacturer identification")),
"When SBOM provides manufacturer, no Art. 13(16) violation should be emitted"
);
}
}