use crate::model::{NormalizedSbom, SbomFormat};
use serde::{Deserialize, Serialize};
#[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 (2027)",
Self::Phase2 => "Phase 2 (2029)",
}
}
pub const fn deadline(self) -> &'static str {
match self {
Self::Phase1 => "11 December 2027",
Self::Phase2 => "11 December 2029",
}
}
}
#[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,
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 (2027)",
Self::CraPhase2 => "EU CRA Phase 2 (2029)",
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::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::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 (deadline: 11 Dec 2027)"
}
Self::CraPhase2 => {
"Full CRA compliance — adds vulnerability metadata, lifecycle, disclosure (deadline: 11 Dec 2029)"
}
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 — German national SBOM guideline (free, ENISA-cited): mandatory hashes, identifiers, ISO-8601 timestamps"
}
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::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::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,
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::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://www.bsi.bund.de/EN/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/Technische-Richtlinien/TR-nach-Thema-sortiert/tr03183/TR-03183_node.html"
}
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/regulatory-information/search-fda-guidance-documents/cybersecurity-medical-devices-quality-system-considerations-and-content-premarket-submissions"
}
Self::NtiaMinimum => {
"https://www.ntia.doc.gov/files/ntia/publications/sbom_minimum_elements_report.pdf"
}
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::Other => return None,
};
Some(url.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Violation {
pub severity: ViolationSeverity,
pub category: ViolationCategory,
pub message: String,
pub element: Option<String>,
pub requirement: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub standard_refs: Vec<StandardRef>,
}
impl Violation {
#[must_use]
pub fn derive_standard_refs(&self) -> Vec<StandardRef> {
let req = self.requirement.to_lowercase();
let mut refs: Vec<StandardRef> = Vec::new();
let cra_articles: &[(&str, &str)] = &[
("art. 13(2)", "Art. 13(2)"),
("art. 13(3)", "Art. 13(3)"),
("art. 13(4)", "Art. 13(4)"),
("art. 13(5)", "Art. 13(5)"),
("art. 13(6)", "Art. 13(6)"),
("art. 13(7)", "Art. 13(7)"),
("art. 13(8)", "Art. 13(8)"),
("art. 13(9)", "Art. 13(9)"),
("art. 13(11)", "Art. 13(11)"),
("art. 13(12)", "Art. 13(12)"),
("art. 13(15)", "Art. 13(15)"),
("art. 14", "Art. 14"),
];
for (needle, id) in cra_articles {
if req.contains(needle) {
refs.push(StandardRef::new(StandardKind::CraArticle, *id));
}
}
if req.contains("annex i, part iii") || req.contains("annex i part iii") {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex I Part III"));
}
if req.contains("annex i, part ii") || req.contains("annex i part ii") {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex I Part II"));
}
if req.contains("annex i,") || req.contains("annex i:") || req.contains("annex i ") {
let already = refs
.iter()
.any(|r| r.standard == StandardKind::CraAnnex && r.id.starts_with("Annex I"));
if !already {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex I"));
}
}
if req.contains("annex iii") {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex III"));
}
if req.contains("annex iv") {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex IV"));
}
if req.contains("annex v") && !req.contains("annex vii") {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex V"));
}
if req.contains("annex vii") {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex VII"));
}
if req.contains("annex viii") {
refs.push(StandardRef::new(StandardKind::CraAnnex, "Annex VIII"));
}
for token in [
"PRE-7-RQ-01",
"PRE-7-RQ-03",
"PRE-7-RQ-04",
"PRE-7-RQ-06",
"PRE-7-RQ-07",
"PRE-7-RQ-07-RE",
"PRE-8-RQ-02",
"RLS-2-RQ-03-RE",
] {
if self.requirement.contains(token) {
refs.push(StandardRef::new(StandardKind::Pren40000_1_3, token));
}
}
if req.contains("art. 13(4)")
&& req.contains("machine-readable")
&& !refs.iter().any(|r| r.id == "PRE-7-RQ-04")
{
refs.push(StandardRef::new(StandardKind::Pren40000_1_3, "PRE-7-RQ-04"));
}
if req.contains("art. 13(7)") && !refs.iter().any(|r| r.id == "RLS-2-RQ-03-RE") {
refs.push(StandardRef::new(
StandardKind::Pren40000_1_3,
"RLS-2-RQ-03-RE",
));
}
if req.contains("annex i, part iii") || req.contains("annex i part iii") {
if !refs.iter().any(|r| r.id == "PRE-7-RQ-01") {
refs.push(StandardRef::new(StandardKind::Pren40000_1_3, "PRE-7-RQ-01"));
}
if !refs.iter().any(|r| r.id == "PRE-7-RQ-03") {
refs.push(StandardRef::new(StandardKind::Pren40000_1_3, "PRE-7-RQ-03"));
}
}
if (req.contains("annex i") && req.contains("identifier"))
&& !refs.iter().any(|r| r.id == "PRE-7-RQ-07")
{
refs.push(StandardRef::new(StandardKind::Pren40000_1_3, "PRE-7-RQ-07"));
}
if req.contains("art. 13(12)")
&& req.contains("version")
&& !refs.iter().any(|r| r.id == "PRE-7-RQ-06")
{
refs.push(StandardRef::new(StandardKind::Pren40000_1_3, "PRE-7-RQ-06"));
}
if (req.contains("csaf") || req.contains("iso/iec 20153"))
&& !refs.iter().any(|r| r.standard == StandardKind::Csaf2)
{
refs.push(StandardRef::new(StandardKind::Csaf2, "CSAF v2.0"));
}
if req.contains("nist ssdf") || req.contains("sp 800-218") {
for needle in [
"ps.1", "ps.2", "ps.3", "po.1", "po.3", "pw.4", "pw.6", "rv.1",
] {
if req.contains(needle) {
refs.push(StandardRef::new(
StandardKind::NistSsdf,
needle.to_uppercase(),
));
}
}
if !refs.iter().any(|r| r.standard == StandardKind::NistSsdf) {
refs.push(StandardRef::new(StandardKind::NistSsdf, "SP 800-218"));
}
}
if req.contains("eo 14028") || req.contains("executive order 14028") {
refs.push(StandardRef::new(StandardKind::Eo14028, "EO 14028 §4"));
}
if req.contains("fda") {
refs.push(StandardRef::new(
StandardKind::FdaPremarket,
"FDA Premarket",
));
}
if req.contains("ntia") {
refs.push(StandardRef::new(
StandardKind::NtiaMinimum,
"NTIA Minimum Elements",
));
}
if req.contains("cnsa") {
refs.push(StandardRef::new(StandardKind::Cnsa2, "CNSA 2.0"));
}
if req.contains("nist pqc")
|| req.contains("fips 203")
|| req.contains("fips 204")
|| req.contains("fips 205")
{
refs.push(StandardRef::new(StandardKind::NistPqc, "NIST PQC"));
}
refs
}
#[must_use]
pub fn remediation_guidance(&self) -> &'static str {
let req = self.requirement.to_lowercase();
if req.contains("art. 13(4)") {
"Ensure the SBOM is produced in CycloneDX 1.4+ (JSON or XML), SPDX 2.3+ (JSON or tag-value), or SPDX 3.0+ (JSON-LD). Older format versions may not be recognized as machine-readable under the CRA."
} else if req.contains("art. 13(6)") && req.contains("vulnerability metadata") {
"Add severity (e.g., CVSS score) and remediation details to each vulnerability entry. CycloneDX: use vulnerability.ratings[].score and vulnerability.analysis. SPDX: use annotation or externalRef."
} else if req.contains("art. 13(6)") {
"Add a security contact or vulnerability disclosure URL. CycloneDX: add a component externalReference with type 'security-contact' or set metadata.manufacturer.contact. SPDX: add an SECURITY external reference."
} else if req.contains("art. 13(7)") {
"Reference a coordinated vulnerability disclosure policy. CycloneDX: add an externalReference of type 'advisories' linking to your disclosure policy. SPDX: add an external document reference."
} else if req.contains("art. 13(8)") {
"Specify when security updates will no longer be provided. CycloneDX 1.5+: use component.releaseNotes or metadata properties. SPDX: use an annotation with end-of-support date."
} else if req.contains("art. 13(11)") {
"Include lifecycle or end-of-support metadata for components. CycloneDX: use component properties (e.g., cdx:lifecycle:status). SPDX: use annotations."
} else if req.contains("art. 13(12)") && req.contains("version") {
"Every component must have a version string. Use the actual release version (e.g., '1.2.3'), not a range or placeholder."
} else if req.contains("art. 13(12)") {
"The SBOM must identify the product by name. CycloneDX: set metadata.component.name. SPDX: set documentDescribes with the primary package name."
} else if req.contains("art. 13(15)") && req.contains("email") {
"Provide a valid contact email for the manufacturer. The email must contain an @ sign with valid local and domain parts."
} else if req.contains("art. 13(15)") {
"Identify the manufacturer/supplier. CycloneDX: set metadata.manufacturer or component.supplier. SPDX: set PackageSupplier."
} else if req.contains("annex vii") {
"Reference the EU Declaration of Conformity. CycloneDX: add an externalReference of type 'attestation' or 'certification'. SPDX: add an external document reference."
} else if req.contains("annex i") && req.contains("identifier") {
"Add a PURL, CPE, or SWID tag to each component for unique identification. PURLs are preferred (e.g., pkg:npm/lodash@4.17.21)."
} else if req.contains("annex i") && req.contains("dependency") {
"Add dependency relationships between components. CycloneDX: use the dependencies array. SPDX: use DEPENDS_ON relationships."
} else if req.contains("annex i") && req.contains("primary") {
"Identify the top-level product component. CycloneDX: set metadata.component. SPDX: use documentDescribes to point to the primary package."
} else if req.contains("annex i") && req.contains("hash") {
"Add cryptographic hashes (SHA-256 or stronger) to components for integrity verification."
} else if req.contains("annex i") && req.contains("traceability") {
"The primary product component needs a stable unique identifier (PURL or CPE) that persists across software updates for traceability."
} else if req.contains("art. 13(3)") {
"Regenerate the SBOM when components are added, removed, or updated. CRA Art. 13(3) requires timely updates reflecting the current state of the software."
} else if req.contains("art. 13(5)") {
"Ensure every component has license information. CycloneDX: use component.licenses[]. SPDX 2.x: use PackageLicenseDeclared / PackageLicenseConcluded. SPDX 3.0: use HAS_DECLARED_LICENSE / HAS_CONCLUDED_LICENSE relationships."
} else if req.contains("art. 13(9)") {
"Include vulnerability data or add a vulnerability-assertion external reference stating no known vulnerabilities. CycloneDX: use the vulnerabilities array. SPDX: use annotations or external references."
} else if req.contains("annex i") && req.contains("supply chain") {
"Populate the supplier field for all components, especially transitive dependencies. CycloneDX: use component.supplier. SPDX: use PackageSupplier."
} else if req.contains("annex iii") {
"Add document-level integrity metadata: a serial number (CycloneDX: serialNumber, SPDX: documentNamespace), or a digital signature/attestation with a cryptographic hash."
} else if req.contains("nist ssdf") || req.contains("sp 800-218") {
"Follow NIST SP 800-218 SSDF practices: include tool provenance, source VCS references, build metadata, and cryptographic hashes for all components."
} else if req.contains("eo 14028") {
"Follow EO 14028 Section 4(e) requirements: use a machine-readable format (CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+), auto-generate the SBOM, include unique identifiers, versions, hashes, dependencies, and supplier information."
} else {
"Review the requirement and update the SBOM accordingly. Consult the EU CRA regulation (EU 2024/2847) for detailed guidance."
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ViolationSeverity {
Error,
Warning,
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, 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>,
}
#[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();
Self {
is_compliant: error_count == 0,
level,
violations,
conformity_summary: None,
error_count,
warning_count,
info_count,
}
}
#[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>,
}
impl ComplianceChecker {
#[must_use]
pub const fn new(level: ComplianceLevel) -> Self {
Self {
level,
sidecar: None,
product_class: None,
}
}
#[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 mut violations = Vec::new();
match self.level {
ComplianceLevel::NistSsdf => {
self.check_nist_ssdf(sbom, &mut violations);
}
ComplianceLevel::Eo14028 => {
self.check_eo14028(sbom, &mut violations);
}
ComplianceLevel::Cnsa2 => {
self.check_cnsa2(sbom, &mut violations);
}
ComplianceLevel::NistPqc => {
self.check_nist_pqc(sbom, &mut violations);
}
ComplianceLevel::BsiTr03183_2 => {
self.check_bsi_tr_03183_2(sbom, &mut violations);
}
ComplianceLevel::CraOssSteward => {
self.check_cra_oss_steward(sbom, &mut violations);
}
ComplianceLevel::EuccSubstantial => {
self.check_eucc_substantial(sbom, &mut violations);
}
_ => {
self.check_document_metadata(sbom, &mut violations);
self.check_components(sbom, &mut violations);
self.check_dependencies(sbom, &mut violations);
self.check_vulnerability_metadata(sbom, &mut violations);
self.check_format_specific(sbom, &mut violations);
if self.level.is_cra() {
self.check_cra_gaps(sbom, &mut violations);
self.check_hardware_components(sbom, &mut violations);
}
}
}
for v in &mut violations {
if v.standard_refs.is_empty() {
v.standard_refs = v.derive_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
}
fn build_conformity_summary(&self, sbom: &NormalizedSbom) -> ConformityAssessmentSummary {
use crate::model::{ConformityRoute, ExternalRefType};
let class = self.effective_product_class();
let route = self.effective_route();
let any_ext = |needles: &[ExternalRefType]| -> bool {
sbom.components.values().any(|c| {
c.external_refs.iter().any(|r| {
needles
.iter()
.any(|n| std::mem::discriminant(&r.ref_type) == std::mem::discriminant(n))
})
})
};
let any_ext_url_contains = |types: &[ExternalRefType], substr: &str| -> bool {
sbom.components.values().any(|c| {
c.external_refs.iter().any(|r| {
types
.iter()
.any(|t| std::mem::discriminant(&r.ref_type) == std::mem::discriminant(t))
&& r.url.to_lowercase().contains(substr)
})
})
};
let doc_or_ce = any_ext(&[ExternalRefType::Attestation, ExternalRefType::Certification])
|| self
.sidecar
.as_ref()
.is_some_and(|s| s.ce_marking_reference.is_some());
let attestation_present =
any_ext(&[ExternalRefType::Attestation, ExternalRefType::Certification]);
let eucc_present = any_ext_url_contains(
&[ExternalRefType::Certification, ExternalRefType::Attestation],
"eucc",
) || any_ext_url_contains(
&[ExternalRefType::Certification, ExternalRefType::Attestation],
"common-criteria",
);
let mut evidence: Vec<ConformityEvidence> = Vec::new();
evidence.push(ConformityEvidence {
label: "EU Declaration of Conformity".to_string(),
detail: "Annex V — manufacturer's signed declaration. Provide via Attestation/Certification external ref or sidecar ceMarkingReference.".to_string(),
satisfied: doc_or_ce,
});
match route {
ConformityRoute::ModuleA => {
evidence.push(ConformityEvidence {
label: "Internal-control technical file".to_string(),
detail: "Module A — manufacturer holds the technical file at their premises. No external attestation required.".to_string(),
satisfied: true,
});
}
ConformityRoute::ModuleBC => {
evidence.push(ConformityEvidence {
label: "EU-type examination certificate (Module B)".to_string(),
detail: "Notified-body certificate of EU-type examination — Attestation/Certification external ref.".to_string(),
satisfied: attestation_present,
});
evidence.push(ConformityEvidence {
label: "Production conformity statement (Module C)".to_string(),
detail: "Manufacturer's declaration that production conforms to the type examined under Module B.".to_string(),
satisfied: doc_or_ce,
});
}
ConformityRoute::ModuleH => {
evidence.push(ConformityEvidence {
label: "Quality-management-system certification (Module H)".to_string(),
detail: "Notified-body QMS certification (typically ISO 9001 / ISO/IEC 27001 family) — Certification external ref.".to_string(),
satisfied: attestation_present,
});
evidence.push(ConformityEvidence {
label: "QMS surveillance plan".to_string(),
detail: "Notified-body surveillance / re-assessment record — referenced via Attestation external ref.".to_string(),
satisfied: attestation_present,
});
}
ConformityRoute::Eucc => {
evidence.push(ConformityEvidence {
label: "EUCC / Common Criteria certificate".to_string(),
detail: "Common Criteria certificate from an EUCC-accredited ITSEF — Certification external ref whose URL references EUCC or common-criteria.".to_string(),
satisfied: eucc_present,
});
evidence.push(ConformityEvidence {
label: "Target of Evaluation reference".to_string(),
detail: "Reference to the ToE (and Protection Profile, when applicable) that the EUCC certificate covers.".to_string(),
satisfied: eucc_present,
});
}
}
let psirt = self.sidecar.as_ref().is_some_and(|s| s.psirt_url.is_some());
evidence.push(ConformityEvidence {
label: "PSIRT contact (Art. 14)".to_string(),
detail: "Public PSIRT URL for receiving external vulnerability reports.".to_string(),
satisfied: psirt,
});
let _ = class; ConformityAssessmentSummary {
product_class: class,
route,
evidence,
}
}
fn check_document_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::{CreatorType, ExternalRefType};
if sbom.document.creators.is_empty() {
violations.push(Violation {
severity: match self.level {
ComplianceLevel::Minimum => ViolationSeverity::Warning,
_ => ViolationSeverity::Error,
},
category: ViolationCategory::DocumentMetadata,
message: "SBOM must have creator/tool information".to_string(),
element: None,
requirement: "Document creator identification".to_string(),
standard_refs: Vec::new(),
});
}
if self.level.is_cra() {
let has_org = sbom
.document
.creators
.iter()
.any(|c| c.creator_type == CreatorType::Organization);
let sidecar_has_manufacturer = self
.sidecar
.as_ref()
.is_some_and(|s| s.manufacturer_name.is_some());
if !has_org {
if sidecar_has_manufacturer {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DocumentMetadata,
message:
"[CRA Art. 13(15)] Manufacturer identified via CRA sidecar (consider adding to the SBOM directly for portability)"
.to_string(),
element: None,
requirement: "CRA Art. 13(15): Manufacturer identification".to_string(),
standard_refs: Vec::new(),
});
} else {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message:
"[CRA Art. 13(15)] SBOM should identify the manufacturer (organization)"
.to_string(),
element: None,
requirement: "CRA Art. 13(15): Manufacturer identification".to_string(),
standard_refs: Vec::new(),
});
}
}
for creator in &sbom.document.creators {
if creator.creator_type == CreatorType::Organization
&& let Some(email) = &creator.email
&& !is_valid_email_format(email)
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[CRA Art. 13(15)] Manufacturer email '{email}' appears invalid"
),
element: None,
requirement: "CRA Art. 13(15): Valid contact information".to_string(),
standard_refs: Vec::new(),
});
}
}
if sbom.document.name.is_none() {
let sidecar_has_product_name = self
.sidecar
.as_ref()
.is_some_and(|s| s.product_name.is_some());
if sidecar_has_product_name {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DocumentMetadata,
message: "[CRA Art. 13(12)] Product name provided via CRA sidecar (consider adding metadata.component.name to the SBOM)".to_string(),
element: None,
requirement: "CRA Art. 13(12): Product identification".to_string(),
standard_refs: Vec::new(),
});
} else {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CRA Art. 13(12)] SBOM should include the product name"
.to_string(),
element: None,
requirement: "CRA Art. 13(12): Product identification".to_string(),
standard_refs: Vec::new(),
});
}
}
let has_doc_security_contact = sbom.document.security_contact.is_some()
|| sbom.document.vulnerability_disclosure_url.is_some();
let has_component_security_contact = sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
ExternalRefType::SecurityContact
| ExternalRefType::Support
| ExternalRefType::Advisories
)
})
});
if !has_doc_security_contact && !has_component_security_contact {
let sidecar_has_security = self.sidecar.as_ref().is_some_and(|s| {
s.security_contact.is_some() || s.vulnerability_disclosure_url.is_some()
});
if sidecar_has_security {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 13(6)] Security contact provided via CRA sidecar (consider adding a security-contact externalReference to the SBOM)".to_string(),
element: None,
requirement: "CRA Art. 13(6): Vulnerability disclosure contact".to_string(),
standard_refs: Vec::new(),
});
} else {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 13(6)] SBOM should include a security contact or vulnerability disclosure reference".to_string(),
element: None,
requirement: "CRA Art. 13(6): Vulnerability disclosure contact".to_string(),
standard_refs: Vec::new(),
});
}
}
if sbom.primary_component_id.is_none() && sbom.components.len() > 1 {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CRA Annex I] SBOM should identify the primary product component (CycloneDX metadata.component or SPDX documentDescribes)".to_string(),
element: None,
requirement: "CRA Annex I: Primary product identification".to_string(),
standard_refs: Vec::new(),
});
}
if sbom.document.support_end_date.is_none() {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 13(8)] Consider specifying a support end date for security updates".to_string(),
element: None,
requirement: "CRA Art. 13(8): Support period disclosure".to_string(),
standard_refs: Vec::new(),
});
}
let format_ok = match sbom.document.format {
SbomFormat::CycloneDx => {
let v = &sbom.document.spec_version;
!(v.starts_with("1.0")
|| v.starts_with("1.1")
|| v.starts_with("1.2")
|| v.starts_with("1.3"))
}
SbomFormat::Spdx => {
let v = &sbom.document.spec_version;
v.starts_with("2.3") || v.starts_with("3.")
}
};
if !format_ok {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::FormatSpecific,
message: format!(
"[CRA Art. 13(4)] SBOM format version {} {} may not meet CRA machine-readable requirements; use CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+",
sbom.document.format, sbom.document.spec_version
),
element: None,
requirement: "CRA Art. 13(4): Machine-readable SBOM format".to_string(),
standard_refs: Vec::new(),
});
}
if let Some(ref primary_id) = sbom.primary_component_id
&& let Some(primary) = sbom.components.get(primary_id)
&& primary.identifiers.purl.is_none()
&& primary.identifiers.cpe.is_empty()
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CRA Annex I, Part II] Primary component '{}' missing unique identifier (PURL/CPE) for cross-update traceability",
primary.name
),
element: Some(primary.name.clone()),
requirement: "CRA Annex I, Part II, 1: Product identifier traceability across updates".to_string(),
standard_refs: Vec::new(),
});
}
}
if matches!(self.level, ComplianceLevel::CraPhase2) {
let has_vuln_disclosure_policy = sbom.document.vulnerability_disclosure_url.is_some()
|| sbom.components.values().any(|comp| {
comp.external_refs
.iter()
.any(|r| matches!(r.ref_type, ExternalRefType::Advisories))
});
if !has_vuln_disclosure_policy {
let sidecar_has_cvd = self
.sidecar
.as_ref()
.is_some_and(|s| s.vulnerability_disclosure_url.is_some());
if sidecar_has_cvd {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 13(7)] CVD policy URL provided via CRA sidecar (consider adding an advisories externalReference to the SBOM)".to_string(),
element: None,
requirement: "CRA Art. 13(7): Coordinated vulnerability disclosure policy".to_string(),
standard_refs: Vec::new(),
});
} else {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 13(7)] SBOM should reference a coordinated vulnerability disclosure policy (advisories URL or disclosure URL)".to_string(),
element: None,
requirement: "CRA Art. 13(7): Coordinated vulnerability disclosure policy".to_string(),
standard_refs: Vec::new(),
});
}
}
let has_lifecycle_info = sbom.document.support_end_date.is_some()
|| sbom.components.values().any(|comp| {
comp.extensions.properties.iter().any(|p| {
let name_lower = p.name.to_lowercase();
name_lower.contains("lifecycle")
|| name_lower.contains("end-of-life")
|| name_lower.contains("eol")
|| name_lower.contains("end-of-support")
})
});
if !has_lifecycle_info {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 13(11)] Consider including component lifecycle/end-of-support information".to_string(),
element: None,
requirement: "CRA Art. 13(11): Component lifecycle status".to_string(),
standard_refs: Vec::new(),
});
}
let has_conformity_ref = sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
ExternalRefType::Attestation | ExternalRefType::Certification
) || (matches!(r.ref_type, ExternalRefType::Other(ref s) if s.to_lowercase().contains("declaration-of-conformity"))
)
})
});
let sidecar_has_doc_ref = self
.sidecar
.as_ref()
.is_some_and(|s| s.ce_marking_reference.is_some());
if !has_conformity_ref && !sidecar_has_doc_ref {
let severity = self
.class_severity(ClassCheck::DocReference)
.unwrap_or(ViolationSeverity::Info);
violations.push(Violation {
severity,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[CRA Annex VII] Missing reference to the EU Declaration of Conformity (attestation or certification external reference) for product class {}",
self.effective_product_class().label()
),
element: None,
requirement: "CRA Annex VII: EU Declaration of Conformity reference".to_string(),
standard_refs: Vec::new(),
});
}
}
if matches!(self.level, ComplianceLevel::FdaMedicalDevice) {
let has_org = sbom
.document
.creators
.iter()
.any(|c| c.creator_type == CreatorType::Organization);
if !has_org {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "FDA: SBOM should have manufacturer (organization) as creator"
.to_string(),
element: None,
requirement: "FDA: Manufacturer identification".to_string(),
standard_refs: Vec::new(),
});
}
let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
if !has_contact {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "FDA: SBOM creators should include contact email".to_string(),
element: None,
requirement: "FDA: Contact information".to_string(),
standard_refs: Vec::new(),
});
}
if sbom.document.name.is_none() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "FDA: SBOM should have a document name/title".to_string(),
element: None,
requirement: "FDA: Document identification".to_string(),
standard_refs: Vec::new(),
});
}
}
if matches!(
self.level,
ComplianceLevel::NtiaMinimum | ComplianceLevel::Comprehensive
) {
}
if matches!(
self.level,
ComplianceLevel::Standard
| ComplianceLevel::FdaMedicalDevice
| ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
| ComplianceLevel::Comprehensive
) && sbom.document.serial_number.is_none()
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "SBOM should have a serial number/unique identifier".to_string(),
element: None,
requirement: "Document unique identification".to_string(),
standard_refs: Vec::new(),
});
}
}
fn check_components(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::HashAlgorithm;
for comp in sbom.components.values() {
if comp.name.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: "Component must have a name".to_string(),
element: Some(comp.identifiers.format_id.clone()),
requirement: "Component name (required)".to_string(),
standard_refs: Vec::new(),
});
}
if matches!(
self.level,
ComplianceLevel::NtiaMinimum
| ComplianceLevel::FdaMedicalDevice
| ComplianceLevel::Standard
| ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
| ComplianceLevel::Comprehensive
) && comp.version.is_none()
{
let (req, msg) = match self.level {
ComplianceLevel::FdaMedicalDevice => (
"FDA: Component version".to_string(),
format!("Component '{}' missing version", comp.name),
),
ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
"CRA Art. 13(12): Component version".to_string(),
format!(
"[CRA Art. 13(12)] Component '{}' missing version",
comp.name
),
),
_ => (
"NTIA: Component version".to_string(),
format!("Component '{}' missing version", comp.name),
),
};
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: msg,
element: Some(comp.name.clone()),
requirement: req,
standard_refs: Vec::new(),
});
}
if matches!(
self.level,
ComplianceLevel::Standard
| ComplianceLevel::FdaMedicalDevice
| ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
| ComplianceLevel::Comprehensive
) && !comp.identifiers.has_cra_identifier()
{
let severity = if matches!(
self.level,
ComplianceLevel::FdaMedicalDevice
| ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
) {
ViolationSeverity::Error
} else {
ViolationSeverity::Warning
};
let (message, requirement) = match self.level {
ComplianceLevel::FdaMedicalDevice => (
format!(
"Component '{}' missing unique identifier (PURL/CPE/SWHID/SWID)",
comp.name
),
"FDA: Unique component identifier".to_string(),
),
ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
format!(
"[CRA Annex I, [PRE-7-RQ-07]] Component '{}' missing unique identifier (PURL/CPE/SWHID/SWID)",
comp.name
),
"CRA Annex I / prEN 40000-1-3 [PRE-7-RQ-07]: Unique component identifier (PURL/CPE/SWHID/SWID)".to_string(),
),
_ => (
format!(
"Component '{}' missing unique identifier (PURL/CPE/SWHID/SWID)",
comp.name
),
"Standard identifier (PURL/CPE/SWHID)".to_string(),
),
};
violations.push(Violation {
severity,
category: ViolationCategory::ComponentIdentification,
message,
element: Some(comp.name.clone()),
requirement,
standard_refs: Vec::new(),
});
}
if matches!(
self.level,
ComplianceLevel::NtiaMinimum
| ComplianceLevel::FdaMedicalDevice
| ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
| ComplianceLevel::Comprehensive
) && comp.supplier.is_none()
&& comp.author.is_none()
{
let severity = match self.level {
ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => {
ViolationSeverity::Warning
}
_ => ViolationSeverity::Error,
};
let (message, requirement) = match self.level {
ComplianceLevel::FdaMedicalDevice => (
format!("Component '{}' missing supplier/manufacturer", comp.name),
"FDA: Supplier/manufacturer information".to_string(),
),
ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
format!(
"[CRA Art. 13(15)] Component '{}' missing supplier/manufacturer",
comp.name
),
"CRA Art. 13(15): Supplier/manufacturer information".to_string(),
),
_ => (
format!("Component '{}' missing supplier/manufacturer", comp.name),
"NTIA: Supplier information".to_string(),
),
};
violations.push(Violation {
severity,
category: ViolationCategory::SupplierInfo,
message,
element: Some(comp.name.clone()),
requirement,
standard_refs: Vec::new(),
});
}
if matches!(
self.level,
ComplianceLevel::Standard | ComplianceLevel::Comprehensive
) && comp.licenses.declared.is_empty()
&& comp.licenses.concluded.is_none()
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::LicenseInfo,
message: format!("Component '{}' should have license information", comp.name),
element: Some(comp.name.clone()),
requirement: "License declaration".to_string(),
standard_refs: Vec::new(),
});
}
if matches!(
self.level,
ComplianceLevel::FdaMedicalDevice | ComplianceLevel::Comprehensive
) {
if comp.hashes.is_empty() {
violations.push(Violation {
severity: if self.level == ComplianceLevel::FdaMedicalDevice {
ViolationSeverity::Error
} else {
ViolationSeverity::Warning
},
category: ViolationCategory::IntegrityInfo,
message: format!("Component '{}' missing cryptographic hash", comp.name),
element: Some(comp.name.clone()),
requirement: if self.level == ComplianceLevel::FdaMedicalDevice {
"FDA: Cryptographic hash for integrity".to_string()
} else {
"Integrity verification (hashes)".to_string()
},
standard_refs: Vec::new(),
});
} else if self.level == ComplianceLevel::FdaMedicalDevice {
let has_strong_hash = comp.hashes.iter().any(|h| {
matches!(
h.algorithm,
HashAlgorithm::Sha256
| HashAlgorithm::Sha384
| HashAlgorithm::Sha512
| HashAlgorithm::Sha3_256
| HashAlgorithm::Sha3_384
| HashAlgorithm::Sha3_512
| HashAlgorithm::Blake2b256
| HashAlgorithm::Blake2b384
| HashAlgorithm::Blake2b512
| HashAlgorithm::Blake3
| HashAlgorithm::Streebog256
| HashAlgorithm::Streebog512
)
});
if !has_strong_hash {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::IntegrityInfo,
message: format!(
"Component '{}' has only weak hash algorithm (use SHA-256+)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "FDA: Strong cryptographic hash (SHA-256 or better)"
.to_string(),
standard_refs: Vec::new(),
});
}
}
}
if self.level.is_cra() && comp.hashes.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CRA Annex I] Component '{}' missing cryptographic hash (recommended for integrity)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CRA Annex I: Component integrity information (hash)".to_string(),
standard_refs: Vec::new(),
});
}
}
}
fn check_dependencies(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
if matches!(
self.level,
ComplianceLevel::NtiaMinimum
| ComplianceLevel::FdaMedicalDevice
| ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
| ComplianceLevel::Comprehensive
) {
let has_deps = !sbom.edges.is_empty();
let has_multiple_components = sbom.components.len() > 1;
if has_multiple_components && !has_deps {
let (message, requirement) = match self.level {
ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
"[CRA Annex I] SBOM with multiple components must include dependency relationships".to_string(),
"CRA Annex I: Dependency relationships".to_string(),
),
_ => (
"SBOM with multiple components must include dependency relationships".to_string(),
"NTIA: Dependency relationships".to_string(),
),
};
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message,
element: None,
requirement,
standard_refs: Vec::new(),
});
}
}
if self.level.is_cra() && sbom.components.len() > 1 && sbom.primary_component_id.is_none() {
use std::collections::HashSet;
let mut incoming: HashSet<&crate::model::CanonicalId> = HashSet::new();
for edge in &sbom.edges {
incoming.insert(&edge.to);
}
let root_count = sbom.components.len().saturating_sub(incoming.len());
if root_count > 1 {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DependencyInfo,
message: "[CRA Annex I] SBOM appears to have multiple root components; identify a primary product component for top-level dependencies".to_string(),
element: None,
requirement: "CRA Annex I: Top-level dependency clarity".to_string(),
standard_refs: Vec::new(),
});
}
}
}
fn check_vulnerability_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
if !matches!(self.level, ComplianceLevel::CraPhase2) {
return;
}
for (comp, vuln) in sbom.all_vulnerabilities() {
if vuln.severity.is_none() && vuln.cvss.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::SecurityInfo,
message: format!(
"[CRA Art. 13(6)] Vulnerability '{}' in '{}' lacks severity or CVSS score",
vuln.id, comp.name
),
element: Some(comp.name.clone()),
requirement: "CRA Art. 13(6): Vulnerability metadata completeness".to_string(),
standard_refs: Vec::new(),
});
}
if let Some(remediation) = &vuln.remediation
&& remediation.fixed_version.is_none()
&& remediation.description.is_none()
{
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: format!(
"[CRA Art. 13(6)] Vulnerability '{}' in '{}' has remediation without details",
vuln.id, comp.name
),
element: Some(comp.name.clone()),
requirement: "CRA Art. 13(6): Remediation detail".to_string(),
standard_refs: Vec::new(),
});
}
}
}
fn check_cra_gaps(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
let age_days = (chrono::Utc::now() - sbom.document.created).num_days();
if age_days > 90 {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[CRA Art. 13(3)] SBOM is {age_days} days old; CRA requires timely updates when components change"
),
element: None,
requirement: "CRA Art. 13(3): SBOM update frequency".to_string(),
standard_refs: Vec::new(),
});
} else if age_days > 30 {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[CRA Art. 13(3)] SBOM is {age_days} days old; consider regenerating after component changes"
),
element: None,
requirement: "CRA Art. 13(3): SBOM update frequency".to_string(),
standard_refs: Vec::new(),
});
}
let total = sbom.components.len();
let without_license = sbom
.components
.values()
.filter(|c| c.licenses.declared.is_empty() && c.licenses.concluded.is_none())
.count();
if without_license > 0 {
let pct = (without_license * 100) / total.max(1);
let severity = if pct > 50 {
ViolationSeverity::Warning
} else {
ViolationSeverity::Info
};
violations.push(Violation {
severity,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CRA Art. 13(5)] {without_license}/{total} components ({pct}%) missing license information"
),
element: None,
requirement: "CRA Art. 13(5): Licensed component tracking".to_string(),
standard_refs: Vec::new(),
});
}
let has_vuln_data = sbom
.components
.values()
.any(|c| !c.vulnerabilities.is_empty());
let has_vuln_assertion = sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
crate::model::ExternalRefType::VulnerabilityAssertion
| crate::model::ExternalRefType::ExploitabilityStatement
)
})
});
if !has_vuln_data && !has_vuln_assertion {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message:
"[CRA Art. 13(9)] No vulnerability data or vulnerability assertion found; \
include vulnerability information or a statement of no known vulnerabilities"
.to_string(),
element: None,
requirement: "CRA Art. 13(9): Known vulnerabilities statement".to_string(),
standard_refs: Vec::new(),
});
}
if !sbom.edges.is_empty() {
let direct_ids = sbom.direct_dependency_ids();
let mut direct_missing: Vec<String> = Vec::new();
let mut transitive_missing: Vec<String> = Vec::new();
for comp in sbom.components.values() {
if comp.supplier.is_some() || comp.author.is_some() {
continue;
}
if direct_ids.contains(&comp.canonical_id) {
direct_missing.push(comp.name.clone());
} else {
transitive_missing.push(comp.name.clone());
}
}
if !direct_missing.is_empty() {
let severity = if matches!(self.level, ComplianceLevel::CraPhase2) {
ViolationSeverity::Error
} else {
ViolationSeverity::Warning
};
let n = direct_missing.len();
violations.push(Violation {
severity,
category: ViolationCategory::SupplierInfo,
message: format!(
"[CRA Annex I, Part III / [PRE-7-RQ-03]] {n} direct dependencies missing supplier (mandatory): {}",
truncate_list(&direct_missing, 5)
),
element: None,
requirement: "CRA Annex I, Part III / prEN 40000-1-3 [PRE-7-RQ-03]: Direct dependency supplier (mandatory)"
.to_string(),
standard_refs: Vec::new(),
});
}
let transitive_n = transitive_missing.len();
if transitive_n > 0 {
let denom = total.max(1);
let pct = (transitive_n * 100) / denom;
let severity = if matches!(self.level, ComplianceLevel::CraPhase2) && pct > 30 {
ViolationSeverity::Warning
} else {
ViolationSeverity::Info
};
violations.push(Violation {
severity,
category: ViolationCategory::SupplierInfo,
message: format!(
"[CRA Annex I, Part III / [PRE-7-RQ-03]] {transitive_n}/{denom} transitive dependencies ({pct}%) missing supplier (recommended): {}",
truncate_list(&transitive_missing, 5)
),
element: None,
requirement: "CRA Annex I, Part III / prEN 40000-1-3 [PRE-7-RQ-03]: Transitive dependency supplier (recommended)"
.to_string(),
standard_refs: Vec::new(),
});
}
}
{
let metrics = crate::quality::HashQualityMetrics::from_sbom(sbom);
if let Some(coverage) = metrics.vendor_hash_coverage() {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pct = (coverage * 100.0).round() as usize;
let (severity, threshold_msg) = if self.has_explicit_product_class() {
let threshold = self.vendor_hash_threshold();
if coverage < threshold {
let sev = self
.class_severity(ClassCheck::VendorHashCoverage)
.unwrap_or(ViolationSeverity::Warning);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let thr_pct = (threshold * 100.0).round() as usize;
(
sev,
format!(
"below {thr_pct}% threshold for product class {}",
self.effective_product_class().label()
),
)
} else {
(ViolationSeverity::Info, String::new())
}
} else {
match self.level {
ComplianceLevel::CraPhase2 if coverage < 0.50 => {
(ViolationSeverity::Error, "below 50% threshold".to_string())
}
ComplianceLevel::CraPhase2 if coverage < 0.80 => (
ViolationSeverity::Warning,
"below 80% threshold".to_string(),
),
ComplianceLevel::CraPhase1 if coverage < 0.50 => (
ViolationSeverity::Warning,
"below 50% threshold".to_string(),
),
_ => (ViolationSeverity::Info, String::new()),
}
};
if !threshold_msg.is_empty() {
violations.push(Violation {
severity,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CRA Annex I, Part II / [PRE-7-RQ-07-RE]] Only {}/{} vendor-supplied components ({pct}%) carry an upstream hash — {threshold_msg}",
metrics.vendor_components_with_hash, metrics.vendor_components_total
),
element: None,
requirement: "CRA Annex I Part II / prEN 40000-1-3 [PRE-7-RQ-07-RE]: Vendor hash carry-through".to_string(),
standard_refs: Vec::new(),
});
}
}
}
let has_doc_integrity = sbom.document.serial_number.is_some()
|| sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
crate::model::ExternalRefType::Attestation
| crate::model::ExternalRefType::Certification
) && !r.hashes.is_empty()
})
});
if !has_doc_integrity {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::IntegrityInfo,
message: "[CRA Annex III] Consider adding document-level integrity metadata \
(serial number, digital signature, or attestation with hash)"
.to_string(),
element: None,
requirement: "CRA Annex III: Document signature/integrity".to_string(),
standard_refs: Vec::new(),
});
}
if matches!(self.level, ComplianceLevel::CraPhase2) {
let has_ref_in_sbom = sbom.components.values().any(|comp| {
comp.external_refs
.iter()
.any(|r| matches!(r.ref_type, crate::model::ExternalRefType::RiskAssessment))
}) || sbom.document.creators.iter().any(|c| {
c.name.to_lowercase().contains("risk assessment")
});
let sidecar_has_ref = self
.sidecar
.as_ref()
.is_some_and(|s| s.risk_assessment_url.is_some());
if !has_ref_in_sbom && !sidecar_has_ref {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CRA Art. 13(2)] No documented risk assessment referenced — add an externalReference of type 'risk-assessment' or supply riskAssessmentUrl in the CRA sidecar".to_string(),
element: None,
requirement: "CRA Art. 13(2): Documented risk assessment".to_string(),
standard_refs: Vec::new(),
});
}
}
if self.level.is_cra() {
self.check_article_14_readiness_at(chrono::Utc::now(), violations);
}
let eol_count = sbom
.components
.values()
.filter(|c| {
c.eol
.as_ref()
.is_some_and(|e| e.status == crate::model::EolStatus::EndOfLife)
})
.count();
if eol_count > 0 {
let severity = if self.has_explicit_product_class() {
self.class_severity(ClassCheck::EolComponents)
.unwrap_or(ViolationSeverity::Warning)
} else {
ViolationSeverity::Warning
};
violations.push(Violation {
severity,
category: ViolationCategory::SecurityInfo,
message: format!(
"[CRA Art. 13(8)] {eol_count} component(s) have reached end-of-life and no longer receive security updates"
),
element: None,
requirement: "CRA Art. 13(8): Support period / lifecycle management".to_string(),
standard_refs: Vec::new(),
});
}
let approaching_eol_count = sbom
.components
.values()
.filter(|c| {
c.eol
.as_ref()
.is_some_and(|e| e.status == crate::model::EolStatus::ApproachingEol)
})
.count();
if approaching_eol_count > 0 {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: format!(
"[CRA Art. 13(11)] {approaching_eol_count} component(s) are approaching end-of-life within 6 months"
),
element: None,
requirement: "CRA Art. 13(11): Component lifecycle monitoring".to_string(),
standard_refs: Vec::new(),
});
}
if sbom.document.format == crate::model::SbomFormat::Spdx
&& sbom.document.spec_version.starts_with("3.")
{
let has_vulns = sbom
.components
.values()
.any(|c| !c.vulnerabilities.is_empty());
let has_security_profile = sbom
.document
.distribution_classification
.as_ref()
.is_some_and(|p| p.to_lowercase().contains("security"));
if has_vulns && !has_security_profile {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DocumentMetadata,
message:
"[CRA Art. 13(6)] SPDX 3.0 document contains vulnerabilities but does not declare Security profile conformance; declare profileConformance: [\"security\"] for CRA Art. 13(6) compliance"
.to_string(),
element: None,
requirement: "CRA Art. 13(6): SPDX 3.0 Security profile conformance"
.to_string(),
standard_refs: Vec::new(),
});
}
let has_licenses = sbom
.components
.values()
.any(|c| !c.licenses.declared.is_empty() || c.licenses.concluded.is_some());
let has_licensing_profile = sbom
.document
.distribution_classification
.as_ref()
.is_some_and(|p| {
p.to_lowercase().contains("simplelicensing")
|| p.to_lowercase().contains("licensing")
});
if has_licenses && !has_licensing_profile {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::LicenseInfo,
message:
"[CRA Art. 13(5)] SPDX 3.0 document tracks licenses but does not declare SimpleLicensing profile conformance; declare profileConformance: [\"simpleLicensing\"] for completeness"
.to_string(),
element: None,
requirement: "CRA Art. 13(5): SPDX 3.0 SimpleLicensing profile conformance"
.to_string(),
standard_refs: Vec::new(),
});
}
}
if self.has_explicit_product_class() {
self.check_class_eucc_reference(sbom, violations);
self.check_class_module_attestation(sbom, violations);
}
self.check_controls_assertion(violations);
}
fn check_controls_assertion(&self, violations: &mut Vec<Violation>) {
let Some(sidecar) = self.sidecar.as_ref() else {
return;
};
if sidecar.annex_i_part_i_controls.is_empty() {
return;
}
for (id, claim) in &sidecar.annex_i_part_i_controls {
if claim.satisfied && claim.evidence_url.is_none() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[CRA Annex I Part I {id}] Sidecar claims control satisfied but provides no `evidence_url` — un-evidenced claims should be reviewed before submission"
),
element: None,
requirement: format!(
"CRA Annex I Part I {id}: controls-assertion evidence (prEN 40000-1-2)"
),
standard_refs: Vec::new(),
});
}
}
}
fn check_class_eucc_reference(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
let Some(severity) = self.class_severity(ClassCheck::EuccReference) else {
return;
};
let has_eucc_ref = sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
let url_lower = r.url.to_lowercase();
matches!(
r.ref_type,
crate::model::ExternalRefType::Certification
| crate::model::ExternalRefType::Attestation
) && (url_lower.contains("eucc")
|| url_lower.contains("common-criteria")
|| url_lower.contains("commoncriteria"))
})
});
if !has_eucc_ref {
violations.push(Violation {
severity,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[CRA Annex IV / EUCC] Product class {} requires (or strongly recommends) a reference to a Common Criteria / EUCC certificate or Target of Evaluation",
self.effective_product_class().label()
),
element: None,
requirement: "CRA Annex IV: EUCC reference (Common Criteria certificate)"
.to_string(),
standard_refs: Vec::new(),
});
}
}
fn check_class_module_attestation(
&self,
sbom: &NormalizedSbom,
violations: &mut Vec<Violation>,
) {
use crate::model::ConformityRoute as R;
let Some(severity) = self.class_severity(ClassCheck::ModuleAttestation) else {
return;
};
let route = self.effective_route();
if matches!(route, R::ModuleA) {
return; }
let has_attestation = sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
crate::model::ExternalRefType::Attestation
| crate::model::ExternalRefType::Certification
)
})
});
if !has_attestation {
violations.push(Violation {
severity,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[CRA Annex VIII / {}] No attestation or certification external reference found — required for the {} conformity route",
route.label(),
route.label()
),
element: None,
requirement: format!(
"CRA Annex VIII: {} attestation reference",
route.label()
),
standard_refs: Vec::new(),
});
}
}
fn check_article_14_readiness_at(
&self,
now: chrono::DateTime<chrono::Utc>,
violations: &mut Vec<Violation>,
) {
let deadline: chrono::DateTime<chrono::Utc> =
chrono::DateTime::parse_from_rfc3339("2026-09-11T00:00:00Z")
.expect("hard-coded deadline literal is RFC-3339")
.into();
let art_14_active = now >= deadline;
let post_deadline_severity = if art_14_active {
if self.has_explicit_product_class() {
self.class_severity(ClassCheck::Psirt)
.unwrap_or(ViolationSeverity::Warning)
} else {
ViolationSeverity::Warning
}
} else {
ViolationSeverity::Info
};
let sidecar = self.sidecar.as_ref();
let psirt_present = sidecar.is_some_and(|s| s.psirt_url.is_some());
if !psirt_present {
let prefix = if art_14_active {
"[CRA Art. 14] PSIRT URL missing — required to handle external vulnerability reports"
} else {
"[CRA Art. 14] PSIRT URL missing — Article 14 obligations begin 2026-09-11; document the PSIRT channel ahead of the deadline"
};
violations.push(Violation {
severity: post_deadline_severity,
category: ViolationCategory::SecurityInfo,
message: prefix.to_string(),
element: None,
requirement: "CRA Art. 14: PSIRT contact for external vulnerability reports"
.to_string(),
standard_refs: Vec::new(),
});
}
let ew_present = sidecar.is_some_and(|s| s.early_warning_contact.is_some());
if !ew_present {
let msg = if art_14_active {
"[CRA Art. 14(1)] 24-hour early-warning channel missing — required when an actively-exploited vulnerability is identified"
} else {
"[CRA Art. 14(1)] 24-hour early-warning channel missing — document the ENISA/CSIRT contact before 2026-09-11"
};
violations.push(Violation {
severity: post_deadline_severity,
category: ViolationCategory::SecurityInfo,
message: msg.to_string(),
element: None,
requirement: "CRA Art. 14(1): 24-hour early-warning channel".to_string(),
standard_refs: Vec::new(),
});
}
let ir_present = sidecar.is_some_and(|s| s.incident_report_contact.is_some());
if !ir_present {
let msg = if art_14_active {
"[CRA Art. 14(2)] 72-hour incident-report channel missing — required for severe incidents impacting product security"
} else {
"[CRA Art. 14(2)] 72-hour incident-report channel missing — document this contact before 2026-09-11"
};
violations.push(Violation {
severity: post_deadline_severity,
category: ViolationCategory::SecurityInfo,
message: msg.to_string(),
element: None,
requirement: "CRA Art. 14(2): 72-hour incident-report channel".to_string(),
standard_refs: Vec::new(),
});
}
let enisa_present = sidecar.is_some_and(|s| s.enisa_reporting_platform_id.is_some());
if !enisa_present {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 14(7)] No ENISA single reporting platform identifier — track ENISA publication and add `enisaReportingPlatformId` to the CRA sidecar when available"
.to_string(),
element: None,
requirement: "CRA Art. 14(7): ENISA single reporting platform".to_string(),
standard_refs: Vec::new(),
});
}
}
fn check_hardware_components(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::{ComponentType, IdSource};
let is_hardware_kind = |t: &ComponentType| {
matches!(
t,
ComponentType::Device | ComponentType::Firmware | ComponentType::DeviceDriver
)
};
let hardware_components: Vec<_> = sbom
.components
.values()
.filter(|c| is_hardware_kind(&c.component_type))
.collect();
if hardware_components.is_empty() {
return;
}
for comp in hardware_components {
if comp.supplier.is_none() && comp.author.is_none() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::SupplierInfo,
message: format!(
"[CRA prEN 40000-1-3 [PRE-8-RQ-02]] Hardware component '{}' missing producer (supplier or author)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CRA prEN 40000-1-3 [PRE-8-RQ-02]: Hardware producer".to_string(),
standard_refs: Vec::new(),
});
}
if matches!(
comp.canonical_id.source(),
IdSource::Synthetic | IdSource::FormatSpecific
) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CRA prEN 40000-1-3 [PRE-8-RQ-02]] Hardware component '{}' missing unique identifier (PURL/CPE/SWHID/SWID)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CRA prEN 40000-1-3 [PRE-8-RQ-02]: Hardware identifier".to_string(),
standard_refs: Vec::new(),
});
}
if matches!(comp.component_type, ComponentType::Firmware) && comp.version.is_none() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CRA prEN 40000-1-3 [PRE-8-RQ-02]] Firmware component '{}' missing firmware version",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CRA prEN 40000-1-3 [PRE-8-RQ-02]: Firmware version".to_string(),
standard_refs: Vec::new(),
});
}
if matches!(comp.component_type, ComponentType::Device) && comp.version.is_none() {
let has_firmware_dep = sbom.edges.iter().any(|e| {
e.from == comp.canonical_id
&& sbom.components.get(&e.to).is_some_and(|child| {
matches!(child.component_type, ComponentType::Firmware)
})
});
if !has_firmware_dep {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CRA prEN 40000-1-3 [PRE-8-RQ-02]] Device component '{}' has no version and no associated firmware component",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CRA prEN 40000-1-3 [PRE-8-RQ-02]: Device firmware association".to_string(),
standard_refs: Vec::new(),
});
}
}
}
}
fn check_nist_ssdf(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::ExternalRefType;
if sbom.document.creators.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message:
"SBOM must identify its creator (tool or organization) for provenance tracking"
.to_string(),
element: None,
requirement: "NIST SSDF PS.1: Provenance — creator identification".to_string(),
standard_refs: Vec::new(),
});
}
let has_tool_creator = sbom
.document
.creators
.iter()
.any(|c| c.creator_type == crate::model::CreatorType::Tool);
if !has_tool_creator {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "SBOM should identify the generation tool for automated provenance"
.to_string(),
element: None,
requirement: "NIST SSDF PS.1: Provenance — tool identification".to_string(),
standard_refs: Vec::new(),
});
}
let total = sbom.components.len();
let without_hash = sbom
.components
.values()
.filter(|c| c.hashes.is_empty())
.count();
if without_hash > 0 {
let pct = (without_hash * 100) / total.max(1);
violations.push(Violation {
severity: if pct > 50 {
ViolationSeverity::Error
} else {
ViolationSeverity::Warning
},
category: ViolationCategory::IntegrityInfo,
message: format!(
"{without_hash}/{total} components ({pct}%) missing cryptographic hashes for build integrity"
),
element: None,
requirement: "NIST SSDF PS.2: Build integrity — component hashes".to_string(),
standard_refs: Vec::new(),
});
}
let has_vcs_ref = sbom.components.values().any(|comp| {
comp.external_refs
.iter()
.any(|r| matches!(r.ref_type, ExternalRefType::Vcs))
});
if !has_vcs_ref {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::ComponentIdentification,
message: "No components reference a VCS repository; include source repository links for traceability"
.to_string(),
element: None,
requirement: "NIST SSDF PO.1: Source code provenance — VCS references".to_string(),
standard_refs: Vec::new(),
});
}
let has_build_ref = sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
ExternalRefType::BuildMeta | ExternalRefType::BuildSystem
)
})
});
if !has_build_ref {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DocumentMetadata,
message: "No build metadata references found; include build system information for reproducibility"
.to_string(),
element: None,
requirement: "NIST SSDF PO.3: Build provenance — build metadata".to_string(),
standard_refs: Vec::new(),
});
}
if sbom.components.len() > 1 && sbom.edges.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message: "SBOM with multiple components must include dependency relationships"
.to_string(),
element: None,
requirement: "NIST SSDF PW.4: Dependency management — relationships".to_string(),
standard_refs: Vec::new(),
});
}
let has_vuln_info = sbom
.components
.values()
.any(|c| !c.vulnerabilities.is_empty());
let has_security_ref = sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
ExternalRefType::Advisories
| ExternalRefType::SecurityContact
| ExternalRefType::VulnerabilityAssertion
)
})
});
if !has_vuln_info && !has_security_ref {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::SecurityInfo,
message: "No vulnerability or security advisory references found; \
include vulnerability data or security contact for incident response"
.to_string(),
element: None,
requirement: "NIST SSDF PW.6: Vulnerability information".to_string(),
standard_refs: Vec::new(),
});
}
let without_id = sbom
.components
.values()
.filter(|c| {
c.identifiers.purl.is_none()
&& c.identifiers.cpe.is_empty()
&& c.identifiers.swid.is_none()
})
.count();
if without_id > 0 {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::ComponentIdentification,
message: format!(
"{without_id}/{total} components missing unique identifier (PURL/CPE/SWID)"
),
element: None,
requirement: "NIST SSDF RV.1: Component identification — unique identifiers"
.to_string(),
standard_refs: Vec::new(),
});
}
let without_supplier = sbom
.components
.values()
.filter(|c| c.supplier.is_none() && c.author.is_none())
.count();
if without_supplier > 0 {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::SupplierInfo,
message: format!(
"{without_supplier}/{total} components missing supplier/author information"
),
element: None,
requirement: "NIST SSDF PS.3: Supplier identification".to_string(),
standard_refs: Vec::new(),
});
}
}
fn check_eo14028(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::ExternalRefType;
let format_ok = match sbom.document.format {
crate::model::SbomFormat::CycloneDx => {
let v = &sbom.document.spec_version;
!(v.starts_with("1.0")
|| v.starts_with("1.1")
|| v.starts_with("1.2")
|| v.starts_with("1.3"))
}
crate::model::SbomFormat::Spdx => {
let v = &sbom.document.spec_version;
v.starts_with("2.3") || v.starts_with("3.")
}
};
if !format_ok {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::FormatSpecific,
message: format!(
"SBOM format {} {} does not meet EO 14028 machine-readable requirements; \
use CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+",
sbom.document.format, sbom.document.spec_version
),
element: None,
requirement: "EO 14028 Sec 4(e): Machine-readable SBOM format".to_string(),
standard_refs: Vec::new(),
});
}
let has_tool = sbom
.document
.creators
.iter()
.any(|c| c.creator_type == crate::model::CreatorType::Tool);
if !has_tool {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "SBOM should be auto-generated by a tool; no tool creator identified"
.to_string(),
element: None,
requirement: "EO 14028 Sec 4(e): Automated SBOM generation".to_string(),
standard_refs: Vec::new(),
});
}
if sbom.document.creators.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "SBOM must identify its creator (vendor or tool)".to_string(),
element: None,
requirement: "EO 14028 Sec 4(e): SBOM creator identification".to_string(),
standard_refs: Vec::new(),
});
}
let total = sbom.components.len();
let without_id = sbom
.components
.values()
.filter(|c| {
c.identifiers.purl.is_none()
&& c.identifiers.cpe.is_empty()
&& c.identifiers.swid.is_none()
})
.count();
if without_id > 0 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"{without_id}/{total} components missing unique identifier (PURL/CPE/SWID)"
),
element: None,
requirement: "EO 14028 Sec 4(e): Component unique identification".to_string(),
standard_refs: Vec::new(),
});
}
if sbom.components.len() > 1 && sbom.edges.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message: "SBOM with multiple components must include dependency relationships"
.to_string(),
element: None,
requirement: "EO 14028 Sec 4(e): Dependency relationships".to_string(),
standard_refs: Vec::new(),
});
}
let without_version = sbom
.components
.values()
.filter(|c| c.version.is_none())
.count();
if without_version > 0 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"{without_version}/{total} components missing version information"
),
element: None,
requirement: "EO 14028 Sec 4(e): Component version".to_string(),
standard_refs: Vec::new(),
});
}
let without_hash = sbom
.components
.values()
.filter(|c| c.hashes.is_empty())
.count();
if without_hash > 0 {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::IntegrityInfo,
message: format!("{without_hash}/{total} components missing cryptographic hashes"),
element: None,
requirement: "EO 14028 Sec 4(e): Component integrity verification".to_string(),
standard_refs: Vec::new(),
});
}
let has_security_ref = sbom.document.security_contact.is_some()
|| sbom.document.vulnerability_disclosure_url.is_some()
|| sbom.components.values().any(|comp| {
comp.external_refs.iter().any(|r| {
matches!(
r.ref_type,
ExternalRefType::SecurityContact | ExternalRefType::Advisories
)
})
});
if !has_security_ref {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::SecurityInfo,
message: "No security contact or vulnerability disclosure reference found"
.to_string(),
element: None,
requirement: "EO 14028 Sec 4(g): Vulnerability disclosure process".to_string(),
standard_refs: Vec::new(),
});
}
let without_supplier = sbom
.components
.values()
.filter(|c| c.supplier.is_none() && c.author.is_none())
.count();
if without_supplier > 0 {
let pct = (without_supplier * 100) / total.max(1);
if pct > 30 {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::SupplierInfo,
message: format!(
"{without_supplier}/{total} components ({pct}%) missing supplier information"
),
element: None,
requirement: "EO 14028 Sec 4(e): Supplier identification".to_string(),
standard_refs: Vec::new(),
});
}
}
}
fn check_format_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
match sbom.document.format {
SbomFormat::CycloneDx => {
self.check_cyclonedx_specific(sbom, violations);
}
SbomFormat::Spdx => {
self.check_spdx_specific(sbom, violations);
}
}
}
fn check_cyclonedx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
let version = &sbom.document.spec_version;
if version.starts_with("1.3") || version.starts_with("1.2") || version.starts_with("1.1") {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::FormatSpecific,
message: format!("CycloneDX {version} is outdated, consider upgrading to 1.7+"),
element: None,
requirement: "Current CycloneDX version".to_string(),
standard_refs: Vec::new(),
});
}
for comp in sbom.components.values() {
if comp.identifiers.format_id == comp.name {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::FormatSpecific,
message: format!("Component '{}' may be missing bom-ref", comp.name),
element: Some(comp.name.clone()),
requirement: "CycloneDX: bom-ref for dependency tracking".to_string(),
standard_refs: Vec::new(),
});
}
}
}
fn check_spdx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
let version = &sbom.document.spec_version;
if !version.starts_with("2.") && !version.starts_with("3.") {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::FormatSpecific,
message: format!("Unknown SPDX version: {version}"),
element: None,
requirement: "Valid SPDX version".to_string(),
standard_refs: Vec::new(),
});
}
let is_spdx3 = version.starts_with("3.");
for comp in sbom.components.values() {
let valid_id = if is_spdx3 {
comp.identifiers.format_id.contains(':')
} else {
comp.identifiers.format_id.starts_with("SPDXRef-")
};
if !valid_id {
let expected = if is_spdx3 {
"SPDX 3.0: URN/IRI identifier format"
} else {
"SPDX 2.x: SPDXRef- identifier format"
};
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::FormatSpecific,
message: format!(
"Component '{}' has non-standard SPDX identifier format",
comp.name
),
element: Some(comp.name.clone()),
requirement: expected.to_string(),
standard_refs: Vec::new(),
});
}
}
}
fn check_cnsa2(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::{ComponentType, CryptoAssetType};
for comp in sbom.components.values() {
if comp.component_type != ComponentType::Cryptographic {
continue;
}
let Some(cp) = &comp.crypto_properties else {
continue;
};
match cp.asset_type {
CryptoAssetType::Algorithm => {
if let Some(algo) = &cp.algorithm_properties {
if let Some(ql) = algo.nist_quantum_security_level
&& ql < 5
{
let is_symmetric_or_hash = matches!(
algo.primitive,
crate::model::CryptoPrimitive::Ae
| crate::model::CryptoPrimitive::BlockCipher
| crate::model::CryptoPrimitive::Hash
| crate::model::CryptoPrimitive::Mac
| crate::model::CryptoPrimitive::Kdf
);
if !is_symmetric_or_hash && ql == 0 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' is quantum-vulnerable (level {}), must migrate to PQC",
comp.name, ql
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 PQC Migration".to_string(),
standard_refs: Vec::new(),
});
} else if !is_symmetric_or_hash && ql < 5 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' quantum level {} < 5, CNSA 2.0 requires Level 5",
comp.name, ql
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 Level 5".to_string(),
standard_refs: Vec::new(),
});
}
}
if let Some(family) = &algo.algorithm_family {
let upper = family.to_uppercase();
if upper == "AES"
&& let Some(param) = &algo.parameter_set_identifier
&& param != "256"
{
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' uses AES-{}, CNSA 2.0 requires AES-256 only",
comp.name, param
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 Symmetric".to_string(),
standard_refs: Vec::new(),
});
}
if (upper == "SHA-2" || upper == "SHA2")
&& let Some(param) = &algo.parameter_set_identifier
&& param == "256"
{
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' uses SHA-256, CNSA 2.0 requires SHA-384 or SHA-512",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 Hash".to_string(),
standard_refs: Vec::new(),
});
}
if upper == "ML-KEM"
&& let Some(param) = &algo.parameter_set_identifier
&& param != "1024"
{
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' uses ML-KEM-{}, CNSA 2.0 requires ML-KEM-1024 only",
comp.name, param
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 KEM".to_string(),
standard_refs: Vec::new(),
});
}
if upper == "ML-DSA"
&& let Some(param) = &algo.parameter_set_identifier
&& param != "87"
{
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' uses ML-DSA-{}, CNSA 2.0 requires ML-DSA-87 only",
comp.name, param
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 Signature".to_string(),
standard_refs: Vec::new(),
});
}
const CNSA2_VULNERABLE: &[&str] = &[
"RSA", "DSA", "DH", "ECDSA", "ECDH", "EDDSA", "X25519", "X448",
];
if CNSA2_VULNERABLE.iter().any(|v| upper == *v) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' ({}) is quantum-vulnerable, must migrate to CNSA 2.0 approved algorithm",
comp.name, family
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 PQC Migration".to_string(),
standard_refs: Vec::new(),
});
}
}
}
}
CryptoAssetType::Certificate => {
if let Some(cert) = &cp.certificate_properties
&& let Some(sig_ref) = &cert.signature_algorithm_ref
{
let sig_lower = sig_ref.to_lowercase();
let is_pqc_sig = sig_lower.contains("ml-dsa")
|| sig_lower.contains("slh-dsa")
|| sig_lower.contains("lms")
|| sig_lower.contains("xmss");
if !is_pqc_sig
&& (sig_lower.contains("rsa")
|| sig_lower.contains("ecdsa")
|| sig_lower.contains("dsa"))
{
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"Certificate '{}' signed with non-CNSA 2.0 algorithm (ref: {})",
comp.name, sig_ref
),
element: Some(comp.name.clone()),
requirement: "CNSA 2.0 Certificate".to_string(),
standard_refs: Vec::new(),
});
}
}
}
_ => {}
}
}
}
fn check_bsi_tr_03183_2(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::{CreatorType, HashAlgorithm};
if sbom.document.creators.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[BSI TR-03183-2 §5.1] SBOM author/creator missing".to_string(),
element: None,
requirement: "BSI TR-03183-2 §5.1: Author/creator identification".to_string(),
standard_refs: Vec::new(),
});
}
let has_tool_creator = sbom
.document
.creators
.iter()
.any(|c| c.creator_type == CreatorType::Tool);
if !has_tool_creator {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[BSI TR-03183-2 §5.1] SBOM must identify the generation tool".to_string(),
element: None,
requirement: "BSI TR-03183-2 §5.1: Tool identification".to_string(),
standard_refs: Vec::new(),
});
}
let created = sbom.document.created;
if created.timestamp() <= 0 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[BSI TR-03183-2 §5.2] SBOM created timestamp missing or invalid"
.to_string(),
element: None,
requirement: "BSI TR-03183-2 §5.2: ISO-8601 timestamp".to_string(),
standard_refs: Vec::new(),
});
}
for comp in sbom.components.values() {
if !comp.identifiers.has_cra_identifier() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[BSI TR-03183-2 §5.3] Component '{}' missing unique identifier (PURL/CPE/SWHID/SWID)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "BSI TR-03183-2 §5.3: Component identifier".to_string(),
standard_refs: Vec::new(),
});
}
}
let strong = |a: &HashAlgorithm| {
matches!(
a,
HashAlgorithm::Sha256
| HashAlgorithm::Sha384
| HashAlgorithm::Sha512
| HashAlgorithm::Sha3_256
| HashAlgorithm::Sha3_384
| HashAlgorithm::Sha3_512
| HashAlgorithm::Blake2b256
| HashAlgorithm::Blake2b384
| HashAlgorithm::Blake2b512
| HashAlgorithm::Blake3
)
};
for comp in sbom.components.values() {
let has_strong_hash = comp.hashes.iter().any(|h| strong(&h.algorithm));
if !has_strong_hash {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[BSI TR-03183-2 §5.4] Component '{}' missing SHA-256+ cryptographic hash",
comp.name
),
element: Some(comp.name.clone()),
requirement: "BSI TR-03183-2 §5.4: Component cryptographic hash (SHA-256+)"
.to_string(),
standard_refs: Vec::new(),
});
}
}
if sbom.edges.is_empty() && sbom.components.len() > 1 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message: "[BSI TR-03183-2 §5.5] SBOM declares multiple components but no dependency relationships"
.to_string(),
element: None,
requirement: "BSI TR-03183-2 §5.5: Dependency relationships".to_string(),
standard_refs: Vec::new(),
});
}
let total = sbom.components.len();
let without_license = sbom
.components
.values()
.filter(|c| c.licenses.declared.is_empty() && c.licenses.concluded.is_none())
.count();
if without_license > 0 {
let pct = (without_license * 100) / total.max(1);
violations.push(Violation {
severity: if pct > 50 {
ViolationSeverity::Warning
} else {
ViolationSeverity::Info
},
category: ViolationCategory::LicenseInfo,
message: format!(
"[BSI TR-03183-2 §6] {without_license}/{total} components ({pct}%) missing license information"
),
element: None,
requirement: "BSI TR-03183-2 §6: Component license (recommended)".to_string(),
standard_refs: Vec::new(),
});
}
let without_supplier = sbom
.components
.values()
.filter(|c| c.supplier.is_none() && c.author.is_none())
.count();
if without_supplier > 0 {
let pct = (without_supplier * 100) / total.max(1);
violations.push(Violation {
severity: if pct > 50 {
ViolationSeverity::Warning
} else {
ViolationSeverity::Info
},
category: ViolationCategory::SupplierInfo,
message: format!(
"[BSI TR-03183-2 §6] {without_supplier}/{total} components ({pct}%) missing supplier information"
),
element: None,
requirement: "BSI TR-03183-2 §6: Component supplier (recommended)".to_string(),
standard_refs: Vec::new(),
});
}
}
fn check_cra_oss_steward(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
self.check_components(sbom, violations);
self.check_dependencies(sbom, violations);
let has_vuln_handling = sbom.components.values().any(|c| {
c.external_refs.iter().any(|r| {
matches!(
r.ref_type,
crate::model::ExternalRefType::SecurityContact
| crate::model::ExternalRefType::Advisories
| crate::model::ExternalRefType::VulnerabilityAssertion
)
})
}) || self
.sidecar
.as_ref()
.is_some_and(|s| s.psirt_url.is_some() || s.vulnerability_disclosure_url.is_some());
if !has_vuln_handling {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 24 / Annex I Part II] OSS steward must operate a vulnerability-handling process — declare a SecurityContact / Advisories external reference, or set psirt_url / vulnerability_disclosure_url in the sidecar".to_string(),
element: None,
requirement: "CRA Art. 24: Vulnerability-handling process (steward floor)"
.to_string(),
standard_refs: Vec::new(),
});
}
let has_cvd_policy = sbom.components.values().any(|c| {
c.external_refs
.iter()
.any(|r| matches!(r.ref_type, crate::model::ExternalRefType::Advisories))
}) || self
.sidecar
.as_ref()
.is_some_and(|s| s.coordinated_disclosure_policy_url.is_some());
if !has_cvd_policy {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 13(7)] OSS steward should publish a coordinated vulnerability disclosure (CVD) policy — add an Advisories external reference or set coordinated_disclosure_policy_url in the sidecar".to_string(),
element: None,
requirement: "CRA Art. 13(7): Coordinated vulnerability disclosure policy"
.to_string(),
standard_refs: Vec::new(),
});
}
self.check_format_specific(sbom, violations);
}
fn check_eucc_substantial(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
let sidecar = self.sidecar.as_ref();
let pp_present = sidecar
.and_then(|s| s.eucc_protection_profile_id.as_deref())
.is_some_and(|s| !s.is_empty());
if !pp_present {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[EUCC] Missing Common Criteria Protection Profile reference — set sidecar `eucc_protection_profile_id`".to_string(),
element: None,
requirement: "EUCC Substantial: Protection Profile reference".to_string(),
standard_refs: Vec::new(),
});
}
let toe_present = sidecar
.and_then(|s| s.eucc_target_of_evaluation.as_deref())
.is_some_and(|s| !s.is_empty());
if !toe_present {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[EUCC] Missing Target of Evaluation reference — set sidecar `eucc_target_of_evaluation`".to_string(),
element: None,
requirement: "EUCC Substantial: Target of Evaluation reference".to_string(),
standard_refs: Vec::new(),
});
}
let itsef_present = sidecar
.and_then(|s| s.eucc_itsef_identifier.as_deref())
.is_some_and(|s| !s.is_empty());
if !itsef_present {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[EUCC] Missing ITSEF (IT Security Evaluation Facility) identifier — set sidecar `eucc_itsef_identifier`".to_string(),
element: None,
requirement: "EUCC Substantial: ITSEF identifier".to_string(),
standard_refs: Vec::new(),
});
}
match sidecar.and_then(|s| s.eucc_valid_until) {
None => {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[EUCC] Missing certificate valid-until date — set sidecar `eucc_valid_until`".to_string(),
element: None,
requirement: "EUCC Substantial: certificate valid-until date".to_string(),
standard_refs: Vec::new(),
});
}
Some(until) if until <= chrono::Utc::now() => {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[EUCC] EUCC certificate has expired (valid-until {})",
until.format("%Y-%m-%d")
),
element: None,
requirement: "EUCC Substantial: certificate validity".to_string(),
standard_refs: Vec::new(),
});
}
Some(until) if until <= chrono::Utc::now() + chrono::Duration::days(180) => {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: format!(
"[EUCC] EUCC certificate expires within 180 days ({})",
until.format("%Y-%m-%d")
),
element: None,
requirement: "EUCC Substantial: certificate validity".to_string(),
standard_refs: Vec::new(),
});
}
Some(_) => {}
}
let eucc_ref_present = sbom.components.values().any(|c| {
c.external_refs.iter().any(|r| {
let url = r.url.to_lowercase();
matches!(
r.ref_type,
crate::model::ExternalRefType::Certification
| crate::model::ExternalRefType::Attestation
) && (url.contains("eucc") || url.contains("common-criteria"))
})
});
if !eucc_ref_present {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[EUCC] No Certification/Attestation external reference points at an EUCC URL (recommended)".to_string(),
element: None,
requirement: "EUCC Substantial: Certification external reference".to_string(),
standard_refs: Vec::new(),
});
}
}
fn check_nist_pqc(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::{ComponentType, CryptoAssetType};
const BROKEN: &[&str] = &[
"MD5", "MD4", "MD2", "SHA-1", "DES", "3DES", "TDEA", "RC2", "RC4", "BLOWFISH", "IDEA",
"CAST5",
];
for comp in sbom.components.values() {
if comp.component_type != ComponentType::Cryptographic {
continue;
}
let Some(cp) = &comp.crypto_properties else {
continue;
};
if cp.asset_type == CryptoAssetType::Algorithm
&& let Some(algo) = &cp.algorithm_properties
{
if algo.nist_quantum_security_level == Some(0) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' has nistQuantumSecurityLevel=0, quantum-vulnerable (IR 8547)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "IR 8547: quantum-vulnerable".to_string(),
standard_refs: Vec::new(),
});
}
if algo.nist_quantum_security_level.is_none() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::CryptographyInfo,
message: format!("'{}' missing nistQuantumSecurityLevel field", comp.name),
element: Some(comp.name.clone()),
requirement: "IR 8547: quantum assessment required".to_string(),
standard_refs: Vec::new(),
});
}
if let Some(family) = &algo.algorithm_family {
let upper = family.to_uppercase();
if BROKEN.iter().any(|b| upper == *b) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' ({}) is broken/disallowed per SP 800-131A",
comp.name, family
),
element: Some(comp.name.clone()),
requirement: "SP 800-131A: disallowed".to_string(),
standard_refs: Vec::new(),
});
}
}
if algo.mode == Some(crate::model::CryptoMode::Ecb) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' uses ECB mode, disallowed per SP 800-131A Rev 3",
comp.name
),
element: Some(comp.name.clone()),
requirement: "SP 800-131A Rev 3: ECB disallowed".to_string(),
standard_refs: Vec::new(),
});
}
if let Some(family) = &algo.algorithm_family {
let upper = family.to_uppercase();
if matches!(upper.as_str(), "ML-KEM" | "ML-DSA" | "SLH-DSA") {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' uses NIST-approved PQC algorithm (FIPS 203/204/205)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "FIPS 203/204/205: approved".to_string(),
standard_refs: Vec::new(),
});
}
}
if algo.is_hybrid_pqc() {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' is a hybrid PQC combiner — good migration practice",
comp.name
),
element: Some(comp.name.clone()),
requirement: "IR 8547: recommended transition".to_string(),
standard_refs: Vec::new(),
});
}
}
if cp.asset_type == CryptoAssetType::RelatedCryptoMaterial
&& let Some(mat) = &cp.related_crypto_material_properties
&& let Some(size) = mat.size
{
let is_symmetric = matches!(
mat.material_type,
crate::model::CryptoMaterialType::SymmetricKey
| crate::model::CryptoMaterialType::SecretKey
);
if is_symmetric && size < 128 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::CryptographyInfo,
message: format!(
"'{}' symmetric key size {} bits < 128 minimum",
comp.name, size
),
element: Some(comp.name.clone()),
requirement: "NIST: minimum key size".to_string(),
standard_refs: Vec::new(),
});
}
}
}
}
}
impl Default for ComplianceChecker {
fn default() -> Self {
Self::new(ComplianceLevel::Standard)
}
}
fn truncate_list(items: &[String], max: usize) -> String {
if items.len() <= max {
items.join(", ")
} else {
let head = items[..max].join(", ");
let rest = items.len() - max;
format!("{head}, …and {rest} more")
}
}
fn is_valid_email_format(email: &str) -> bool {
if email.contains(' ') || email.is_empty() {
return false;
}
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[0];
let domain = parts[1];
if local.is_empty() {
return false;
}
if domain.is_empty()
|| !domain.contains('.')
|| domain.starts_with('.')
|| domain.ends_with('.')
{
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[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 (2027)");
assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2029)");
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(),
standard_refs: Vec::new(),
},
Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::LicenseInfo,
message: "Warning 1".to_string(),
element: None,
requirement: "Test".to_string(),
standard_refs: Vec::new(),
},
Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::FormatSpecific,
message: "Info 1".to_string(),
element: None,
requirement: "Test".to_string(),
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 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"
);
}
fn make_violation(req: &str) -> Violation {
Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: req.to_string(),
element: None,
requirement: req.to_string(),
standard_refs: Vec::new(),
}
}
#[test]
fn standard_refs_extracts_cra_article() {
let v = make_violation("CRA Art. 13(4): Machine-readable SBOM format");
let refs = v.derive_standard_refs();
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::CraArticle && r.id == "Art. 13(4)"),
"expected CRA Art. 13(4); got {refs:?}"
);
}
#[test]
fn standard_refs_infers_pren_id_from_art_13_4() {
let v = make_violation("CRA Art. 13(4): Machine-readable SBOM format");
let refs = v.derive_standard_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 standard_refs_extracts_explicit_pren_id() {
let v = make_violation(
"CRA Annex I / prEN 40000-1-3 [PRE-7-RQ-07]: Unique component identifier",
);
let refs = v.derive_standard_refs();
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07"),
"expected explicit 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 standard_refs_extracts_annex_i_part_iii() {
let v = make_violation("CRA Annex I, Part III: Supply chain transparency");
let refs = v.derive_standard_refs();
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part III"),
"expected Annex I Part III; 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 standard_refs_recognises_csaf_in_art_13_7() {
let v = make_violation("CRA Art. 13(7): Coordinated vulnerability disclosure policy");
let refs = v.derive_standard_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 standard_refs_handles_nist_ssdf_practice() {
let v = make_violation("NIST SSDF PS.2: Build integrity — component hashes");
let refs = v.derive_standard_refs();
assert!(
refs.iter()
.any(|r| r.standard == StandardKind::NistSsdf && r.id == "PS.2"),
"expected NIST SSDF PS.2; got {refs:?}"
);
}
#[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_6() {
use crate::model::CraSidecarMetadata;
let sbom = NormalizedSbom::default();
let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
let art_13_6_warning = bare.violations.iter().find(|v| {
v.requirement.contains("Art. 13(6)") && v.severity == ViolationSeverity::Warning
});
assert!(
art_13_6_warning.is_some(),
"Without sidecar, Art. 13(6) 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_6_info = withsc.violations.iter().find(|v| {
v.requirement.contains("Art. 13(6)") && v.severity == ViolationSeverity::Info
});
assert!(
art_13_6_info.is_some(),
"With sidecar, Art. 13(6) should be downgraded to Info"
);
assert!(
!withsc
.violations
.iter()
.any(|v| v.requirement.contains("Art. 13(6)")
&& v.severity == ViolationSeverity::Warning),
"With sidecar, no Warning-level Art. 13(6) violation should remain"
);
}
#[test]
fn sidecar_supplies_product_name_downgrades_art_13_12() {
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(12)") && v.severity == ViolationSeverity::Info
});
assert!(
downgraded.is_some(),
"Sidecar product_name should downgrade Art. 13(12) to Info"
);
}
#[test]
fn sidecar_supplies_manufacturer_downgrades_art_13_15() {
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(15)") && v.severity == ViolationSeverity::Info
});
assert!(
downgraded.is_some(),
"Sidecar manufacturer_name should downgrade Art. 13(15) to Info"
);
}
#[test]
fn sidecar_supplies_cvd_url_downgrades_art_13_7() {
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("Art. 13(7)") && v.severity == ViolationSeverity::Info
});
assert!(
downgraded.is_some(),
"Sidecar CVD URL should downgrade Art. 13(7) 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)"
);
}
#[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.1")
&& v.severity == ViolationSeverity::Error),
"Empty SBOM should fail BSI §5.1"
);
}
#[test]
fn bsi_tr_03183_2_flags_missing_strong_hash() {
use crate::model::{Component, Hash, HashAlgorithm};
let mut sbom = NormalizedSbom::default();
let mut c = Component::new("lib".to_string(), "lib".to_string())
.with_purl("pkg:cargo/lib@1.0".to_string());
c.hashes.push(Hash::new(HashAlgorithm::Md5, "0".repeat(32)));
sbom.add_component(c);
let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
assert!(
result.violations.iter().any(|v| {
v.requirement.contains("BSI TR-03183-2 §5.4")
&& v.severity == ViolationSeverity::Error
}),
"Component without SHA-256+ hash should fail BSI §5.4"
);
}
#[test]
fn bsi_tr_03183_2_passes_for_complete_component() {
use crate::model::{
Component, Creator, CreatorType, DependencyEdge, DependencyType, Hash, HashAlgorithm,
LicenseExpression, Organization,
};
let mut sbom = NormalizedSbom::default();
sbom.document.creators.push(Creator {
creator_type: CreatorType::Tool,
name: "sbom-tools".to_string(),
email: None,
});
let mut a = Component::new("a".to_string(), "a".to_string())
.with_purl("pkg:cargo/a@1.0".to_string())
.with_version("1.0".to_string());
a.hashes
.push(Hash::new(HashAlgorithm::Sha256, "f".repeat(64)));
a.supplier = Some(Organization::new("SupplierA".to_string()));
a.licenses
.add_declared(LicenseExpression::new("MIT".to_string()));
let mut b = Component::new("b".to_string(), "b".to_string())
.with_purl("pkg:cargo/b@1.0".to_string())
.with_version("1.0".to_string());
b.hashes
.push(Hash::new(HashAlgorithm::Sha256, "0".repeat(64)));
b.supplier = Some(Organization::new("SupplierB".to_string()));
b.licenses
.add_declared(LicenseExpression::new("MIT".to_string()));
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(), 14);
assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiTr03183_2));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::CraOssSteward));
assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuccSubstantial));
}
#[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(15): Manufacturer identification")),
"When SBOM provides manufacturer, no Art. 13(15) violation should be emitted"
);
}
}