1use crate::model::{NormalizedSbom, SbomFormat};
11use serde::{Deserialize, Serialize};
12
13pub(crate) mod ai_shared;
17mod bsi;
18mod bsi_sbom_for_ai;
19mod cisa2026;
20mod context;
21mod cra;
22mod crypto;
23mod eo14028;
24mod eu_ai_act;
25mod eucc;
26mod fsct;
27mod generic;
28mod pci_dss;
29mod registry;
30mod selector;
31mod shared;
32mod ssdf;
33
34use context::{ComplianceContext, checker_for};
35pub use registry::{
36 CISA2026_SARIF_RULE_IDS, CNSA2_SARIF_RULE_IDS, COMPLIANCE_SARIF_RULE_IDS,
37 EO14028_SARIF_RULE_IDS, FDA_SARIF_RULE_IDS, FSCT_SARIF_RULE_IDS, NTIA_SARIF_RULE_IDS,
38 PCIDSS_SARIF_RULE_IDS, PQC_SARIF_RULE_IDS, RuleMeta, SSDF_SARIF_RULE_IDS, all_rule_ids,
39 rule_meta,
40};
41use registry::{REMEDIATION_GENERIC, lookup_static_rule_id};
42pub use selector::StandardSelector;
43use shared::{
44 has_known_supplier, has_known_value, is_valid_email_format, known_component_name, known_value,
45 manufacturer_scope_components, truncate_list,
46};
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum CraPhase {
51 Phase1,
55 Phase2,
59}
60
61impl CraPhase {
62 pub const fn name(self) -> &'static str {
63 match self {
64 Self::Phase1 => "Phase 1 (2026)",
65 Self::Phase2 => "Phase 2 (2027)",
66 }
67 }
68
69 pub const fn deadline(self) -> &'static str {
70 match self {
71 Self::Phase1 => "11 September 2026",
72 Self::Phase2 => "11 December 2027",
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[non_exhaustive]
80pub enum ComplianceLevel {
81 Minimum,
83 Standard,
85 NtiaMinimum,
87 CraPhase1,
89 CraPhase2,
91 FdaMedicalDevice,
93 NistSsdf,
95 Eo14028,
97 Cnsa2,
99 NistPqc,
101 BsiTr03183_2,
105 CraOssSteward,
110 EuccSubstantial,
116 EuAiAct,
124 BsiSbomForAi,
135 Cisa2026,
143 PciDss632,
151 Fsct,
159 Comprehensive,
161}
162
163impl ComplianceLevel {
164 #[must_use]
166 pub const fn name(&self) -> &'static str {
167 match self {
168 Self::Minimum => "Minimum",
169 Self::Standard => "Standard",
170 Self::NtiaMinimum => "NTIA Minimum Elements",
171 Self::CraPhase1 => "EU CRA Phase 1 (2026)",
172 Self::CraPhase2 => "EU CRA Phase 2 (2027)",
173 Self::FdaMedicalDevice => "FDA Medical Device",
174 Self::NistSsdf => "NIST SSDF (SP 800-218)",
175 Self::Eo14028 => "EO 14028 Section 4",
176 Self::Cnsa2 => "CNSA 2.0",
177 Self::NistPqc => "NIST PQC Readiness",
178 Self::BsiTr03183_2 => "BSI TR-03183-2",
179 Self::CraOssSteward => "CRA OSS Steward (Art. 24)",
180 Self::EuccSubstantial => "EUCC Substantial (Reg. 2024/482)",
181 Self::EuAiAct => "EU AI Act Annex IV Readiness",
182 Self::BsiSbomForAi => "BSI/G7 SBOM-for-AI Minimum Elements Readiness",
183 Self::Cisa2026 => "CISA 2026 Minimum Elements",
184 Self::PciDss632 => "PCI DSS v4.0.1 Req. 6.3.2",
185 Self::Fsct => "CISA Framing Software Component Transparency (3rd ed.)",
186 Self::Comprehensive => "Comprehensive",
187 }
188 }
189
190 #[must_use]
192 pub const fn short_name(&self) -> &'static str {
193 match self {
194 Self::Minimum => "Min",
195 Self::Standard => "Std",
196 Self::NtiaMinimum => "NTIA",
197 Self::CraPhase1 => "CRA-1",
198 Self::CraPhase2 => "CRA-2",
199 Self::FdaMedicalDevice => "FDA",
200 Self::NistSsdf => "SSDF",
201 Self::Eo14028 => "EO14028",
202 Self::Cnsa2 => "CNSA2",
203 Self::NistPqc => "PQC",
204 Self::BsiTr03183_2 => "BSI",
205 Self::CraOssSteward => "OSS",
206 Self::EuccSubstantial => "EUCC",
207 Self::EuAiAct => "AI-Act",
208 Self::BsiSbomForAi => "BSI-AI",
209 Self::Cisa2026 => "CISA26",
210 Self::PciDss632 => "PCI",
211 Self::Fsct => "FSCT",
212 Self::Comprehensive => "Full",
213 }
214 }
215
216 #[must_use]
218 pub const fn description(&self) -> &'static str {
219 match self {
220 Self::Minimum => "Basic component identification only",
221 Self::Standard => "Recommended fields for general use",
222 Self::NtiaMinimum => "NTIA minimum elements for software transparency",
223 Self::CraPhase1 => {
224 "CRA reporting obligations — product ID, SBOM format, manufacturer (Art. 14 applies from 11 Sep 2026)"
225 }
226 Self::CraPhase2 => {
227 "Full CRA compliance — adds vulnerability metadata, lifecycle, disclosure (regulation fully applies from 11 Dec 2027)"
228 }
229 Self::FdaMedicalDevice => "FDA premarket submission requirements for medical devices",
230 Self::NistSsdf => {
231 "Secure Software Development Framework — provenance, build integrity, VCS references"
232 }
233 Self::Eo14028 => {
234 "Executive Order 14028 — machine-readable SBOM, auto-generation, supply chain security"
235 }
236 Self::Cnsa2 => {
237 "CNSA 2.0 — AES-256, SHA-384+, ML-KEM-1024, ML-DSA-87, quantum security level 5"
238 }
239 Self::NistPqc => {
240 "NIST PQC — quantum-vulnerable algorithm detection, FIPS 203/204/205, SP 800-131A"
241 }
242 Self::BsiTr03183_2 => {
243 "BSI TR-03183-2 v2.1.0 — German national SBOM guideline (free, ENISA-cited): CycloneDX 1.6+/SPDX 3.0.1+ formats, required creator/timestamp, per-component version/licences/SHA-512 hash"
244 }
245 Self::CraOssSteward => {
246 "CRA Article 24 — Open-source software steward (lighter than full manufacturer obligations): SBOM + CVD policy + vuln-handling required, no DoC/module/manufacturer-email enforcement"
247 }
248 Self::EuccSubstantial => {
249 "EUCC Substantial (Reg. (EU) 2024/482) — reference-only check for Common-Criteria Protection Profile, Target of Evaluation, ITSEF, and certificate valid-until date"
250 }
251 Self::EuAiAct => {
252 "EU AI Act (Reg. (EU) 2024/1689) Annex IV technical-documentation READINESS — model description, training-data characteristics, validation/testing metrics, limitations (readiness only, not a legal-conformity guarantee; N/A for non-AI SBOMs)"
253 }
254 Self::BsiSbomForAi => {
255 "BSI/G7 SBOM-for-AI Minimum Elements (joint G7 final, May 2026) READINESS — scores an AI-BOM element-by-element across the Metadata, System-Level, Models, Datasets, Infrastructure, and Security clusters (readiness only, not a legal-conformity guarantee; N/A for non-AI SBOMs)"
256 }
257 Self::Cisa2026 => {
258 "2026 Minimum Elements for an SBOM (CISA et al., July 2026) — successor to NTIA 2021: author (person/org), signature, format name+version, generation context, timestamp, tool name+version, SBOM version, and per-component producer/name/version/identifiers/hash/license/dependencies. Frequency, distribution, and update accommodation are organizational practices with no in-document evidence and carry no rules."
259 }
260 Self::PciDss632 => {
261 "PCI DSS v4.0.1 Req. 6.3.2 software-inventory profile (required in assessments since 31 Mar 2025) — inventory completeness, per-component name/version/supplier/identifier, freshness, and vulnerability-management usability; 6.3.1/11.3.1.1 risk-ranking checks where vulnerability data is embedded. Assessor-side testing procedures (interviews, software comparison) are out of document reach."
262 }
263 Self::Fsct => {
264 "CISA Framing Software Component Transparency, 3rd ed. (2024) — baseline attributes (author, timestamp, primary component, name, version, supplier, identifiers, hashes, relationships, licenses, copyright) across the Minimum Expected (Error) / Recommended Practice (Warning) / Aspirational Goal (Info) maturity tiers"
265 }
266 Self::Comprehensive => "All recommended fields and best practices",
267 }
268 }
269
270 #[must_use]
272 pub const fn all() -> &'static [Self] {
273 &[
274 Self::Minimum,
275 Self::Standard,
276 Self::NtiaMinimum,
277 Self::CraPhase1,
278 Self::CraPhase2,
279 Self::FdaMedicalDevice,
280 Self::NistSsdf,
281 Self::Eo14028,
282 Self::Cnsa2,
283 Self::NistPqc,
284 Self::BsiTr03183_2,
285 Self::CraOssSteward,
286 Self::EuccSubstantial,
287 Self::EuAiAct,
288 Self::BsiSbomForAi,
289 Self::Cisa2026,
290 Self::PciDss632,
291 Self::Fsct,
292 Self::Comprehensive,
293 ]
294 }
295
296 #[must_use]
300 pub const fn is_cra(&self) -> bool {
301 matches!(
302 self,
303 Self::CraPhase1 | Self::CraPhase2 | Self::CraOssSteward
304 )
305 }
306
307 #[must_use]
309 pub const fn cra_phase(&self) -> Option<CraPhase> {
310 match self {
311 Self::CraPhase1 => Some(CraPhase::Phase1),
312 Self::CraPhase2 => Some(CraPhase::Phase2),
313 _ => None,
314 }
315 }
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
326#[non_exhaustive]
327pub enum StandardKind {
328 CraArticle,
330 CraAnnex,
332 Pren40000_1_3,
334 BsiTr03183_2,
336 NistSsdf,
338 Eo14028,
340 FdaPremarket,
342 NtiaMinimum,
344 Csaf2,
346 Cnsa2,
348 NistPqc,
350 EuAiAct,
352 BsiSbomForAi,
354 Eucc,
357 CisaMinimum2026,
360 PciDss4,
363 CisaFsct,
365 Other,
367}
368
369impl StandardKind {
370 #[must_use]
372 pub const fn label(self) -> &'static str {
373 match self {
374 Self::CraArticle => "CRA Article",
375 Self::CraAnnex => "CRA Annex",
376 Self::Pren40000_1_3 => "prEN 40000-1-3",
377 Self::BsiTr03183_2 => "BSI TR-03183-2",
378 Self::NistSsdf => "NIST SSDF",
379 Self::Eo14028 => "EO 14028",
380 Self::FdaPremarket => "FDA",
381 Self::NtiaMinimum => "NTIA",
382 Self::Csaf2 => "CSAF v2.0",
383 Self::Cnsa2 => "CNSA 2.0",
384 Self::NistPqc => "NIST PQC",
385 Self::EuAiAct => "EU AI Act",
386 Self::BsiSbomForAi => "BSI/G7 AI-SBOM",
387 Self::Eucc => "EUCC",
388 Self::CisaMinimum2026 => "CISA 2026",
389 Self::PciDss4 => "PCI DSS v4",
390 Self::CisaFsct => "CISA FSCT 3e",
391 Self::Other => "Other",
392 }
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
403pub struct StandardRef {
404 pub standard: StandardKind,
406 pub id: String,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub help_uri: Option<String>,
411}
412
413impl StandardRef {
414 #[must_use]
418 pub fn new(standard: StandardKind, id: impl Into<String>) -> Self {
419 let id = id.into();
420 let help_uri = standard.canonical_help_uri(&id);
421 Self {
422 standard,
423 id,
424 help_uri,
425 }
426 }
427
428 #[must_use]
429 pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
430 self.help_uri = Some(uri.into());
431 self
432 }
433}
434
435impl StandardKind {
436 #[must_use]
446 pub fn canonical_help_uri(self, _id: &str) -> Option<String> {
447 let url = match self {
448 Self::CraArticle | Self::CraAnnex => {
450 "https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng"
451 }
452 Self::Pren40000_1_3 => return None,
454 Self::BsiTr03183_2 => "https://bsi.bund.de/dok/TR-03183-en",
457 Self::NistSsdf => "https://doi.org/10.6028/NIST.SP.800-218",
459 Self::Eo14028 => "https://www.federalregister.gov/d/2021-10460",
461 Self::FdaPremarket => "https://www.fda.gov/media/119933/download",
466 Self::NtiaMinimum => {
469 "https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom"
470 }
471 Self::Csaf2 => "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html",
473 Self::Cnsa2 => {
475 "https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF"
476 }
477 Self::NistPqc => "https://csrc.nist.gov/projects/post-quantum-cryptography",
479 Self::EuAiAct => "https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng",
481 Self::BsiSbomForAi => {
484 "https://www.cisa.gov/resources-tools/resources/software-bill-materials-ai-minimum-elements"
485 }
486 Self::Eucc => "https://eur-lex.europa.eu/eli/reg_impl/2024/482/oj/eng",
489 Self::CisaMinimum2026 => {
492 "https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom"
493 }
494 Self::PciDss4 => "https://www.pcisecuritystandards.org/document_library/",
497 Self::CisaFsct => {
500 "https://www.cisa.gov/resources-tools/resources/framing-software-component-transparency-2024"
501 }
502 Self::Other => return None,
503 };
504 Some(url.to_string())
505 }
506}
507
508#[derive(Debug, Clone)]
510pub struct Violation {
511 pub severity: ViolationSeverity,
513 pub category: ViolationCategory,
515 pub message: String,
517 pub element: Option<String>,
523 pub component_id: Option<String>,
533 pub counts: Option<ViolationCounts>,
542 pub requirement: String,
544 pub rule_id: &'static str,
556 pub standard_refs: Vec<StandardRef>,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
569pub struct ViolationCounts {
570 pub affected: usize,
574 pub total: usize,
576}
577
578fn default_rule_id() -> &'static str {
581 "SBOM-CRA-GENERAL"
582}
583
584#[derive(Deserialize)]
590struct ViolationPayload {
591 severity: ViolationSeverity,
592 category: ViolationCategory,
593 message: String,
594 element: Option<String>,
595 #[serde(default)]
596 component_id: Option<String>,
597 #[serde(default)]
598 counts: Option<ViolationCounts>,
599 requirement: String,
600 #[serde(default)]
601 rule_id: Option<String>,
602 #[serde(default)]
603 standard_refs: Vec<StandardRef>,
604}
605
606impl<'de> Deserialize<'de> for Violation {
607 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
608 where
609 D: serde::Deserializer<'de>,
610 {
611 let payload = ViolationPayload::deserialize(deserializer)?;
612 Ok(Self {
613 severity: payload.severity,
614 category: payload.category,
615 message: payload.message,
616 element: payload.element,
617 component_id: payload.component_id,
618 counts: payload.counts,
619 requirement: payload.requirement,
620 rule_id: payload
625 .rule_id
626 .as_deref()
627 .and_then(lookup_static_rule_id)
628 .unwrap_or_else(default_rule_id),
629 standard_refs: payload.standard_refs,
630 })
631 }
632}
633
634impl Serialize for Violation {
635 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
642 where
643 S: serde::Serializer,
644 {
645 use serde::ser::SerializeStruct;
646 let has_refs = !self.standard_refs.is_empty();
647 let has_component_id = self.component_id.is_some();
648 let has_counts = self.counts.is_some();
649 let mut state = serializer.serialize_struct(
650 "Violation",
651 7 + usize::from(has_refs) + usize::from(has_component_id) + usize::from(has_counts),
652 )?;
653 state.serialize_field("severity", &self.severity)?;
654 state.serialize_field("category", &self.category)?;
655 state.serialize_field("message", &self.message)?;
656 state.serialize_field("element", &self.element)?;
657 if has_component_id {
658 state.serialize_field("component_id", &self.component_id)?;
659 } else {
660 state.skip_field("component_id")?;
661 }
662 if has_counts {
663 state.serialize_field("counts", &self.counts)?;
664 } else {
665 state.skip_field("counts")?;
666 }
667 state.serialize_field("requirement", &self.requirement)?;
668 state.serialize_field("rule_id", self.rule_id)?;
669 state.serialize_field("sarif_rule_id", self.sarif_rule_id())?;
672 if has_refs {
673 state.serialize_field("standard_refs", &self.standard_refs)?;
674 } else {
675 state.skip_field("standard_refs")?;
676 }
677 state.end()
678 }
679}
680
681impl Violation {
682 #[must_use]
695 pub fn registry_standard_refs(&self) -> Vec<StandardRef> {
696 rule_meta(self.rule_id)
697 .map(|m| {
698 m.refs
699 .iter()
700 .map(|(kind, id)| StandardRef::new(*kind, *id))
701 .collect()
702 })
703 .unwrap_or_default()
704 }
705
706 #[must_use]
709 pub fn remediation_guidance(&self) -> &'static str {
710 rule_meta(self.rule_id).map_or(REMEDIATION_GENERIC, |m| m.remediation)
711 }
712
713 #[must_use]
723 pub fn sarif_rule_id(&self) -> &'static str {
724 rule_meta(self.rule_id).map_or("SBOM-CRA-GENERAL", |m| m.sarif_id)
725 }
726}
727
728#[must_use]
734pub const fn generic_rule_id_for_level(level: ComplianceLevel) -> &'static str {
735 match level {
736 ComplianceLevel::Minimum | ComplianceLevel::Standard | ComplianceLevel::Comprehensive => {
737 "SBOM-QUALITY-GENERAL"
738 }
739 ComplianceLevel::NtiaMinimum => "SBOM-NTIA-GENERAL",
740 ComplianceLevel::CraPhase1
741 | ComplianceLevel::CraPhase2
742 | ComplianceLevel::CraOssSteward => "SBOM-CRA-GENERAL",
743 ComplianceLevel::FdaMedicalDevice => "SBOM-FDA-GENERAL",
744 ComplianceLevel::NistSsdf => "SBOM-SSDF-GENERAL",
745 ComplianceLevel::Eo14028 => "SBOM-EO14028-GENERAL",
746 ComplianceLevel::Cnsa2 => "SBOM-CNSA2-GENERAL",
747 ComplianceLevel::NistPqc => "SBOM-PQC-GENERAL",
748 ComplianceLevel::BsiTr03183_2 => "SBOM-BSI-TR-03183-2-GENERAL",
749 ComplianceLevel::EuccSubstantial => "SBOM-EUCC-GENERAL",
750 ComplianceLevel::EuAiAct => "SBOM-AIACT-GENERAL",
751 ComplianceLevel::BsiSbomForAi => "SBOM-BSIAI-GENERAL",
752 ComplianceLevel::Cisa2026 => "SBOM-CISA2026-GENERAL",
753 ComplianceLevel::PciDss632 => "SBOM-PCI-GENERAL",
754 ComplianceLevel::Fsct => "SBOM-FSCT-GENERAL",
755 }
756}
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
760pub enum ViolationSeverity {
761 Error,
763 Warning,
765 Info,
767}
768
769impl ViolationSeverity {
770 #[must_use]
774 pub const fn name(self) -> &'static str {
775 match self {
776 Self::Error => "Error",
777 Self::Warning => "Warning",
778 Self::Info => "Info",
779 }
780 }
781}
782
783#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
785pub enum ViolationCategory {
786 DocumentMetadata,
788 ComponentIdentification,
790 DependencyInfo,
792 LicenseInfo,
794 SupplierInfo,
796 IntegrityInfo,
798 SecurityInfo,
800 FormatSpecific,
802 CryptographyInfo,
804}
805
806impl ViolationCategory {
807 #[must_use]
808 pub const fn name(&self) -> &'static str {
809 match self {
810 Self::DocumentMetadata => "Document Metadata",
811 Self::ComponentIdentification => "Component Identification",
812 Self::DependencyInfo => "Dependency Information",
813 Self::LicenseInfo => "License Information",
814 Self::SupplierInfo => "Supplier Information",
815 Self::IntegrityInfo => "Integrity Information",
816 Self::SecurityInfo => "Security Information",
817 Self::FormatSpecific => "Format-Specific",
818 Self::CryptographyInfo => "Cryptography",
819 }
820 }
821
822 #[must_use]
824 pub const fn short_name(&self) -> &'static str {
825 match self {
826 Self::DocumentMetadata => "Doc Meta",
827 Self::ComponentIdentification => "Comp IDs",
828 Self::DependencyInfo => "Deps",
829 Self::LicenseInfo => "License",
830 Self::SupplierInfo => "Supplier",
831 Self::IntegrityInfo => "Integrity",
832 Self::SecurityInfo => "Security",
833 Self::FormatSpecific => "Format",
834 Self::CryptographyInfo => "Crypto",
835 }
836 }
837
838 #[must_use]
840 pub const fn all() -> &'static [Self] {
841 &[
842 Self::SupplierInfo,
843 Self::ComponentIdentification,
844 Self::DocumentMetadata,
845 Self::IntegrityInfo,
846 Self::LicenseInfo,
847 Self::DependencyInfo,
848 Self::SecurityInfo,
849 Self::FormatSpecific,
850 Self::CryptographyInfo,
851 ]
852 }
853}
854
855#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
863#[serde(rename_all = "snake_case", tag = "status", content = "reason")]
864pub enum Applicability {
865 #[default]
867 Applicable,
868 NotApplicable(String),
870}
871
872const NOT_APPLICABLE_RULES: &[&str] = &["SBOM-AIACT-NA", "SBOM-BSIAI-NA"];
875
876#[derive(Debug, Clone, Serialize, Deserialize)]
878pub struct ComplianceResult {
879 pub is_compliant: bool,
881 pub level: ComplianceLevel,
883 pub violations: Vec<Violation>,
885 pub error_count: usize,
887 pub warning_count: usize,
889 pub info_count: usize,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub conformity_summary: Option<ConformityAssessmentSummary>,
896 #[serde(default)]
900 pub applicability: Applicability,
901}
902
903#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct ConformityAssessmentSummary {
909 pub product_class: crate::model::CraProductClass,
911 pub route: crate::model::ConformityRoute,
913 pub evidence: Vec<ConformityEvidence>,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize)]
921pub struct ConformityEvidence {
922 pub label: String,
924 pub detail: String,
926 pub satisfied: bool,
928}
929
930impl ComplianceResult {
931 #[must_use]
933 pub fn new(level: ComplianceLevel, violations: Vec<Violation>) -> Self {
934 let error_count = violations
935 .iter()
936 .filter(|v| v.severity == ViolationSeverity::Error)
937 .count();
938 let warning_count = violations
939 .iter()
940 .filter(|v| v.severity == ViolationSeverity::Warning)
941 .count();
942 let info_count = violations
943 .iter()
944 .filter(|v| v.severity == ViolationSeverity::Info)
945 .count();
946
947 let applicability = violations
948 .iter()
949 .find(|v| NOT_APPLICABLE_RULES.contains(&v.rule_id))
950 .map_or(Applicability::Applicable, |v| {
951 Applicability::NotApplicable(v.message.clone())
952 });
953
954 Self {
955 is_compliant: error_count == 0,
956 level,
957 violations,
958 conformity_summary: None,
959 applicability,
960 error_count,
961 warning_count,
962 info_count,
963 }
964 }
965
966 #[must_use]
968 pub fn is_applicable(&self) -> bool {
969 self.applicability == Applicability::Applicable
970 }
971
972 #[must_use]
980 pub fn score(&self) -> Option<u8> {
981 if !self.is_applicable() {
982 return None;
983 }
984 let actionable = self.error_count + self.warning_count;
985 #[allow(clippy::cast_possible_truncation)]
986 Some((100 / (actionable + 1)) as u8)
987 }
988
989 #[must_use]
991 pub fn violations_by_severity(&self, severity: ViolationSeverity) -> Vec<&Violation> {
992 self.violations
993 .iter()
994 .filter(|v| v.severity == severity)
995 .collect()
996 }
997
998 #[must_use]
1000 pub fn violations_by_category(&self, category: ViolationCategory) -> Vec<&Violation> {
1001 self.violations
1002 .iter()
1003 .filter(|v| v.category == category)
1004 .collect()
1005 }
1006}
1007
1008#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1017pub enum ClassCheck {
1018 VendorHashCoverage,
1020 EolComponents,
1022 Cycles,
1024 DocReference,
1026 EuccReference,
1028 Psirt,
1030 ModuleAttestation,
1033}
1034
1035#[derive(Debug, Clone)]
1037pub struct ComplianceChecker {
1038 level: ComplianceLevel,
1040 sidecar: Option<crate::model::CraSidecarMetadata>,
1045 product_class: Option<crate::model::CraProductClass>,
1049 as_of: Option<chrono::DateTime<chrono::Utc>>,
1053}
1054
1055impl ComplianceChecker {
1056 #[must_use]
1058 pub const fn new(level: ComplianceLevel) -> Self {
1059 Self {
1060 level,
1061 sidecar: None,
1062 product_class: None,
1063 as_of: None,
1064 }
1065 }
1066
1067 #[must_use]
1072 pub const fn with_as_of(mut self, as_of: chrono::DateTime<chrono::Utc>) -> Self {
1073 self.as_of = Some(as_of);
1074 self
1075 }
1076
1077 pub(crate) fn now(&self) -> chrono::DateTime<chrono::Utc> {
1079 self.as_of.unwrap_or_else(chrono::Utc::now)
1080 }
1081
1082 #[must_use]
1089 pub fn with_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
1090 self.sidecar = Some(sidecar);
1091 self
1092 }
1093
1094 #[must_use]
1099 pub const fn with_product_class(mut self, class: crate::model::CraProductClass) -> Self {
1100 self.product_class = Some(class);
1101 self
1102 }
1103
1104 #[must_use]
1109 pub fn effective_product_class(&self) -> crate::model::CraProductClass {
1110 self.sidecar
1111 .as_ref()
1112 .and_then(|s| s.product_class)
1113 .or(self.product_class)
1114 .unwrap_or(crate::model::CraProductClass::Default)
1115 }
1116
1117 #[must_use]
1120 pub fn effective_route(&self) -> crate::model::ConformityRoute {
1121 self.sidecar
1122 .as_ref()
1123 .and_then(|s| s.conformity_assessment_route)
1124 .unwrap_or_else(|| self.effective_product_class().default_route())
1125 }
1126
1127 #[must_use]
1131 pub fn class_severity(&self, check: ClassCheck) -> Option<ViolationSeverity> {
1132 use crate::model::CraProductClass as C;
1133 let class = self.effective_product_class();
1134 match (check, class) {
1135 (ClassCheck::VendorHashCoverage, C::Default | C::ImportantClass1) => {
1139 Some(ViolationSeverity::Warning)
1140 }
1141 (ClassCheck::VendorHashCoverage, C::ImportantClass2 | C::Critical) => {
1142 Some(ViolationSeverity::Error)
1143 }
1144
1145 (ClassCheck::EolComponents, C::Default | C::ImportantClass1) => {
1146 Some(ViolationSeverity::Warning)
1147 }
1148 (ClassCheck::EolComponents, C::ImportantClass2 | C::Critical) => {
1149 Some(ViolationSeverity::Error)
1150 }
1151
1152 (ClassCheck::Cycles, C::Default | C::ImportantClass1) => {
1153 Some(ViolationSeverity::Warning)
1154 }
1155 (ClassCheck::Cycles, C::ImportantClass2 | C::Critical) => {
1156 Some(ViolationSeverity::Error)
1157 }
1158
1159 (ClassCheck::DocReference, C::Default) => Some(ViolationSeverity::Info),
1160 (ClassCheck::DocReference, C::ImportantClass1) => Some(ViolationSeverity::Warning),
1161 (ClassCheck::DocReference, C::ImportantClass2 | C::Critical) => {
1162 Some(ViolationSeverity::Error)
1163 }
1164
1165 (ClassCheck::EuccReference, C::Default | C::ImportantClass1) => None,
1166 (ClassCheck::EuccReference, C::ImportantClass2) => Some(ViolationSeverity::Info),
1167 (ClassCheck::EuccReference, C::Critical) => Some(ViolationSeverity::Error),
1168
1169 (ClassCheck::Psirt, C::Default | C::ImportantClass1) => {
1170 Some(ViolationSeverity::Warning)
1171 }
1172 (ClassCheck::Psirt, C::ImportantClass2 | C::Critical) => Some(ViolationSeverity::Error),
1173
1174 (ClassCheck::ModuleAttestation, C::Default) => None,
1175 (ClassCheck::ModuleAttestation, C::ImportantClass1) => Some(ViolationSeverity::Warning),
1176 (ClassCheck::ModuleAttestation, C::ImportantClass2 | C::Critical) => {
1177 Some(ViolationSeverity::Error)
1178 }
1179 }
1180 }
1181
1182 #[must_use]
1186 pub fn vendor_hash_threshold(&self) -> f64 {
1187 use crate::model::CraProductClass as C;
1188 match self.effective_product_class() {
1189 C::Default => 0.50,
1190 C::ImportantClass1 | C::ImportantClass2 => 0.80,
1191 C::Critical => 1.00,
1192 }
1193 }
1194
1195 #[must_use]
1201 pub fn has_explicit_product_class(&self) -> bool {
1202 self.product_class.is_some()
1203 || self
1204 .sidecar
1205 .as_ref()
1206 .and_then(|s| s.product_class)
1207 .is_some()
1208 }
1209
1210 #[must_use]
1218 pub fn check(&self, sbom: &NormalizedSbom) -> ComplianceResult {
1219 let ctx = ComplianceContext::new(self, sbom);
1220 let checker = checker_for(self.level);
1221 debug_assert_eq!(
1222 checker.level(),
1223 self.level,
1224 "dispatched checker must match the configured level"
1225 );
1226 let mut violations = checker.check(&ctx);
1227
1228 for v in &mut violations {
1230 if v.standard_refs.is_empty() {
1231 v.standard_refs = v.registry_standard_refs();
1232 }
1233 }
1234
1235 let mut result = ComplianceResult::new(self.level, violations);
1236 if self.level.is_cra() && self.has_explicit_product_class() {
1239 result.conformity_summary = Some(self.build_conformity_summary(sbom));
1240 }
1241 result
1242 }
1243}
1244
1245impl Default for ComplianceChecker {
1246 fn default() -> Self {
1247 Self::new(ComplianceLevel::Standard)
1248 }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::*;
1254
1255 #[test]
1258 fn ntia_gates_on_missing_timestamp() {
1259 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1260 let comp = |sbom: &mut NormalizedSbom| {
1261 let c = Component::new("lib".to_string(), "lib@1".to_string())
1262 .with_version("1.0".to_string())
1263 .with_purl("pkg:cargo/lib@1.0".to_string());
1264 sbom.add_component(c);
1265 };
1266
1267 let mut no_ts = NormalizedSbom::new(DocumentMetadata::default());
1269 no_ts.document.created = chrono::DateTime::UNIX_EPOCH;
1270 comp(&mut no_ts);
1271 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&no_ts);
1272 assert!(
1273 r.violations
1274 .iter()
1275 .any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP"
1276 && v.severity == ViolationSeverity::Error),
1277 "missing timestamp must fail NTIA"
1278 );
1279
1280 let mut with_ts = NormalizedSbom::new(DocumentMetadata::default()); comp(&mut with_ts);
1283 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&with_ts);
1284 assert!(
1285 !r.violations
1286 .iter()
1287 .any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP")
1288 );
1289 }
1290
1291 #[test]
1294 fn eo14028_gates_on_missing_supplier() {
1295 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1296 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1297 sbom.add_component(
1299 Component::new("lib".to_string(), "lib@1".to_string())
1300 .with_version("1.0".to_string())
1301 .with_purl("pkg:cargo/lib@1.0".to_string()),
1302 );
1303 let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1304 assert!(
1305 r.violations
1306 .iter()
1307 .any(|v| v.rule_id == "SBOM-EO14028-SUPPLIER"
1308 && v.severity == ViolationSeverity::Error),
1309 "missing supplier must be a gating Error under EO 14028"
1310 );
1311 }
1312
1313 #[test]
1317 fn bsi_gates_on_nameless_component() {
1318 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1319 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1320 let mut c =
1321 Component::new(String::new(), "ref-1".to_string()).with_version("1.0".to_string());
1322 c.identifiers.purl = Some("pkg:cargo/x@1.0".to_string());
1323 sbom.add_component(c);
1324 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
1325 assert!(
1326 r.violations
1327 .iter()
1328 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-3"),
1329 "a nameless component must fail BSI §5.2.2"
1330 );
1331 }
1332
1333 #[test]
1336 fn cra_art24_honors_document_level_disclosure() {
1337 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1338 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1339 sbom.document.vulnerability_disclosure_url =
1340 Some("https://example.org/security".to_string());
1341 sbom.add_component(
1342 Component::new("lib".to_string(), "lib@1".to_string())
1343 .with_version("1.0".to_string())
1344 .with_purl("pkg:cargo/lib@1.0".to_string()),
1345 );
1346 let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
1347 assert!(
1348 !r.violations.iter().any(|v| v.rule_id == "SBOM-CRA-ART-24"),
1349 "a document-level disclosure URL must satisfy the Art.24 vuln-handling gate"
1350 );
1351 }
1352
1353 #[test]
1356 fn ntia_gates_on_missing_identifier() {
1357 use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
1358 let mut without_id = NormalizedSbom::new(DocumentMetadata::default());
1359 let mut c =
1360 Component::new("lib".to_string(), "lib@1".to_string()).with_version("1.0".to_string());
1361 c.supplier = Some(Organization::new("LibCorp".to_string()));
1362 without_id.add_component(c);
1363 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&without_id);
1364 assert!(
1365 r.violations
1366 .iter()
1367 .any(|v| v.rule_id == "SBOM-NTIA-IDENTIFIER"
1368 && v.severity == ViolationSeverity::Error),
1369 "missing unique identifier must fail NTIA"
1370 );
1371
1372 let mut with_id = NormalizedSbom::new(DocumentMetadata::default());
1373 let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1374 .with_version("1.0".to_string())
1375 .with_purl("pkg:cargo/lib@1.0".to_string());
1376 c.supplier = Some(Organization::new("LibCorp".to_string()));
1377 with_id.add_component(c);
1378 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&with_id);
1379 assert!(
1380 !r.violations
1381 .iter()
1382 .any(|v| v.rule_id == "SBOM-NTIA-IDENTIFIER"),
1383 "PURL satisfies the NTIA identifier element"
1384 );
1385 }
1386
1387 #[test]
1390 fn placeholder_values_do_not_satisfy_required_elements() {
1391 use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
1392 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1393 let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1394 .with_version("NOASSERTION".to_string())
1395 .with_purl("pkg:cargo/lib@1.0".to_string());
1396 c.supplier = Some(Organization::new("NOASSERTION".to_string()));
1397 sbom.add_component(c);
1398 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1399 assert!(
1400 r.violations
1401 .iter()
1402 .any(|v| v.rule_id == "SBOM-NTIA-VERSION"),
1403 "NOASSERTION version must not satisfy the NTIA version element"
1404 );
1405 assert!(
1406 r.violations
1407 .iter()
1408 .any(|v| v.rule_id == "SBOM-NTIA-SUPPLIER"),
1409 "NOASSERTION supplier must not satisfy the NTIA supplier element"
1410 );
1411 }
1412
1413 #[test]
1416 fn fda_gates_on_missing_timestamp() {
1417 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1418 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1419 sbom.document.created = chrono::DateTime::UNIX_EPOCH;
1420 sbom.add_component(
1421 Component::new("lib".to_string(), "lib@1".to_string())
1422 .with_version("1.0".to_string())
1423 .with_purl("pkg:cargo/lib@1.0".to_string()),
1424 );
1425 let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
1426 assert!(
1427 r.violations
1428 .iter()
1429 .any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP"
1430 && v.severity == ViolationSeverity::Error),
1431 "missing timestamp must fail FDA (NTIA baseline)"
1432 );
1433 }
1434
1435 #[test]
1438 fn fda_warns_without_support_lifecycle() {
1439 use crate::model::{Component, DocumentMetadata, NormalizedSbom, Property};
1440 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1441 sbom.add_component(
1442 Component::new("lib".to_string(), "lib@1".to_string())
1443 .with_version("1.0".to_string())
1444 .with_purl("pkg:cargo/lib@1.0".to_string()),
1445 );
1446 let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
1447 assert!(
1448 r.violations
1449 .iter()
1450 .any(|v| v.requirement.contains("Level of support")),
1451 "missing support-lifecycle info must warn under FDA"
1452 );
1453
1454 let mut with_eol = NormalizedSbom::new(DocumentMetadata::default());
1455 let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1456 .with_version("1.0".to_string())
1457 .with_purl("pkg:cargo/lib@1.0".to_string());
1458 c.extensions.properties.push(Property {
1459 name: "end-of-support".to_string(),
1460 value: "2030-01-01".to_string(),
1461 });
1462 with_eol.add_component(c);
1463 let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&with_eol);
1464 assert!(
1465 !r.violations
1466 .iter()
1467 .any(|v| v.requirement.contains("Level of support")),
1468 "component end-of-support property satisfies the FDA support check"
1469 );
1470 }
1471
1472 #[test]
1475 fn eo14028_gates_on_missing_timestamp() {
1476 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1477 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1478 sbom.document.created = chrono::DateTime::UNIX_EPOCH;
1479 sbom.add_component(
1480 Component::new("lib".to_string(), "lib@1".to_string())
1481 .with_version("1.0".to_string())
1482 .with_purl("pkg:cargo/lib@1.0".to_string()),
1483 );
1484 let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1485 assert!(
1486 r.violations
1487 .iter()
1488 .any(|v| v.rule_id == "SBOM-EO14028-TIMESTAMP"
1489 && v.severity == ViolationSeverity::Error),
1490 "missing timestamp must fail EO 14028"
1491 );
1492
1493 let mut with_ts = NormalizedSbom::new(DocumentMetadata::default());
1494 with_ts.add_component(
1495 Component::new("lib".to_string(), "lib@1".to_string())
1496 .with_version("1.0".to_string())
1497 .with_purl("pkg:cargo/lib@1.0".to_string()),
1498 );
1499 let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&with_ts);
1500 assert!(
1501 !r.violations
1502 .iter()
1503 .any(|v| v.rule_id == "SBOM-EO14028-TIMESTAMP")
1504 );
1505 }
1506
1507 #[test]
1510 fn eo14028_accepts_spdx_2_2() {
1511 use crate::model::{DocumentMetadata, NormalizedSbom, SbomFormat};
1512 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1513 sbom.document.format = SbomFormat::Spdx;
1514 sbom.document.spec_version = "2.2".to_string();
1515 let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1516 assert!(
1517 !r.violations
1518 .iter()
1519 .any(|v| v.rule_id == "SBOM-EO14028-FORMAT"),
1520 "SPDX 2.2 is machine-readable under EO 14028"
1521 );
1522
1523 sbom.document.spec_version = "2.1".to_string();
1524 let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1525 assert!(
1526 r.violations
1527 .iter()
1528 .any(|v| v.rule_id == "SBOM-EO14028-FORMAT"),
1529 "SPDX 2.1 still fails the machine-readable gate"
1530 );
1531 }
1532
1533 #[test]
1536 fn eo14028_and_ssdf_accept_swhid_identifiers() {
1537 use crate::model::{Component, DocumentMetadata, NormalizedSbom, SwhidObject};
1538 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1539 let mut c =
1540 Component::new("lib".to_string(), "lib@1".to_string()).with_version("1.0".to_string());
1541 c.identifiers.swhid.push(
1542 SwhidObject::parse("swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2").unwrap(),
1543 );
1544 sbom.add_component(c);
1545 let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1546 assert!(
1547 !r.violations
1548 .iter()
1549 .any(|v| v.rule_id == "SBOM-EO14028-IDENTIFIER"),
1550 "SWHID satisfies the EO 14028 identifier element"
1551 );
1552 let r = ComplianceChecker::new(ComplianceLevel::NistSsdf).check(&sbom);
1553 assert!(
1554 !r.violations.iter().any(|v| v.rule_id == "SBOM-SSDF-RV1"),
1555 "SWHID satisfies the SSDF RV.1 identifier check"
1556 );
1557 }
1558
1559 #[test]
1562 fn dependency_graph_orphans_warn() {
1563 use crate::model::{
1564 CompletenessDeclaration, Component, DependencyEdge, DependencyType, DocumentMetadata,
1565 NormalizedSbom,
1566 };
1567 let build = || {
1568 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1569 let mut ids = Vec::new();
1570 for i in 0..5 {
1571 let c = Component::new(format!("lib{i}"), format!("lib{i}@1"))
1572 .with_version("1.0".to_string())
1573 .with_purl(format!("pkg:cargo/lib{i}@1.0"));
1574 ids.push(c.canonical_id.clone());
1575 sbom.add_component(c);
1576 }
1577 sbom.edges.push(DependencyEdge::new(
1579 ids[0].clone(),
1580 ids[1].clone(),
1581 DependencyType::DependsOn,
1582 ));
1583 sbom
1584 };
1585
1586 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&build());
1587 assert!(
1588 r.violations.iter().any(|v| v
1589 .message
1590 .contains("participate in no dependency relationship")),
1591 "3/5 orphaned components must produce a dependency-coverage warning"
1592 );
1593
1594 let mut declared = build();
1595 declared.document.completeness_declaration = CompletenessDeclaration::Incomplete;
1596 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&declared);
1597 assert!(
1598 !r.violations.iter().any(|v| v
1599 .message
1600 .contains("participate in no dependency relationship")),
1601 "declared-incomplete SBOMs are exempt from the orphan warning"
1602 );
1603 }
1604
1605 #[test]
1608 fn primary_component_must_participate_in_graph() {
1609 use crate::model::{
1610 Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom,
1611 };
1612 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1613 let app = Component::new("app".to_string(), "app".to_string())
1614 .with_version("1.0".to_string())
1615 .with_purl("pkg:cargo/app@1.0".to_string());
1616 let lib_a = Component::new("liba".to_string(), "liba".to_string())
1617 .with_version("1.0".to_string())
1618 .with_purl("pkg:cargo/liba@1.0".to_string());
1619 let lib_b = Component::new("libb".to_string(), "libb".to_string())
1620 .with_version("1.0".to_string())
1621 .with_purl("pkg:cargo/libb@1.0".to_string());
1622 let app_id = app.canonical_id.clone();
1623 let a_id = lib_a.canonical_id.clone();
1624 let b_id = lib_b.canonical_id.clone();
1625 sbom.primary_component_id = Some(app_id.clone());
1626 sbom.add_component(app);
1627 sbom.add_component(lib_a);
1628 sbom.add_component(lib_b);
1629 sbom.edges.push(DependencyEdge::new(
1631 a_id.clone(),
1632 b_id,
1633 DependencyType::DependsOn,
1634 ));
1635 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1636 assert!(
1637 r.violations
1638 .iter()
1639 .any(|v| v.message.contains("Primary component")
1640 && v.message.contains("no dependency relationship")),
1641 "disconnected primary component must warn"
1642 );
1643
1644 sbom.edges
1646 .push(DependencyEdge::new(app_id, a_id, DependencyType::DependsOn));
1647 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1648 assert!(
1649 !r.violations
1650 .iter()
1651 .any(|v| v.message.contains("Primary component")
1652 && v.message.contains("no dependency relationship"))
1653 );
1654 }
1655
1656 #[test]
1660 fn oss_steward_enforces_component_completeness() {
1661 use crate::model::{Component, DocumentMetadata, ExternalRefType, NormalizedSbom};
1662 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1663 sbom.document.vulnerability_disclosure_url =
1665 Some("https://example.org/security".to_string());
1666 for n in ["a", "b", "c"] {
1667 let mut c = Component::new(n.to_string(), n.to_string());
1668 c.external_refs.push(crate::model::ExternalReference {
1669 ref_type: ExternalRefType::Advisories,
1670 url: "https://example.org/advisories".to_string(),
1671 comment: None,
1672 hashes: Vec::new(),
1673 });
1674 sbom.add_component(c);
1675 }
1676 let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
1677 assert!(
1678 r.violations
1679 .iter()
1680 .any(|v| v.rule_id == "SBOM-CRA-COMPONENT-VERSION"
1681 && v.severity == ViolationSeverity::Error),
1682 "steward components without versions must error"
1683 );
1684 assert!(
1685 r.violations
1686 .iter()
1687 .any(|v| v.rule_id == "SBOM-CRA-ANNEX-I-IDENTIFIER"
1688 && v.severity == ViolationSeverity::Error),
1689 "steward components without identifiers must error"
1690 );
1691 assert!(
1692 r.violations
1693 .iter()
1694 .any(|v| v.rule_id == "SBOM-CRA-ANNEX-I-DEPENDENCY"
1695 && v.severity == ViolationSeverity::Error),
1696 "steward SBOM without dependency edges must error"
1697 );
1698 assert!(!r.is_compliant, "incomplete steward SBOM must not pass");
1699 }
1700
1701 #[test]
1705 fn third_party_refs_do_not_satisfy_manufacturer_obligations() {
1706 use crate::model::{
1707 Component, DependencyEdge, DependencyType, DocumentMetadata, ExternalRefType,
1708 ExternalReference, NormalizedSbom,
1709 };
1710 let build = |primary_has_contact: bool| {
1711 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1712 let mut app = Component::new("app".to_string(), "app".to_string())
1713 .with_version("1.0".to_string())
1714 .with_purl("pkg:cargo/app@1.0".to_string());
1715 if primary_has_contact {
1716 app.external_refs.push(ExternalReference {
1717 ref_type: ExternalRefType::SecurityContact,
1718 url: "https://acme.example/security".to_string(),
1719 comment: None,
1720 hashes: Vec::new(),
1721 });
1722 app.external_refs.push(ExternalReference {
1723 ref_type: ExternalRefType::Advisories,
1724 url: "https://acme.example/advisories".to_string(),
1725 comment: None,
1726 hashes: Vec::new(),
1727 });
1728 }
1729 let mut lodash = Component::new("lodash".to_string(), "lodash".to_string())
1731 .with_version("4.17.21".to_string())
1732 .with_purl("pkg:npm/lodash@4.17.21".to_string());
1733 lodash.external_refs.push(ExternalReference {
1734 ref_type: ExternalRefType::Advisories,
1735 url: "https://github.com/lodash/lodash/security/advisories".to_string(),
1736 comment: None,
1737 hashes: Vec::new(),
1738 });
1739 lodash.external_refs.push(ExternalReference {
1740 ref_type: ExternalRefType::Support,
1741 url: "https://lodash.com/docs".to_string(),
1742 comment: None,
1743 hashes: Vec::new(),
1744 });
1745 let app_id = app.canonical_id.clone();
1746 let lodash_id = lodash.canonical_id.clone();
1747 sbom.primary_component_id = Some(app_id.clone());
1748 sbom.add_component(app);
1749 sbom.add_component(lodash);
1750 sbom.edges.push(DependencyEdge::new(
1751 app_id,
1752 lodash_id,
1753 DependencyType::DependsOn,
1754 ));
1755 sbom
1756 };
1757
1758 let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(false));
1759 assert!(
1760 r.violations
1761 .iter()
1762 .any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
1763 "dep-level advisories/support refs must not satisfy Art. 13(17)"
1764 );
1765 assert!(
1766 r.violations
1767 .iter()
1768 .any(|v| v.rule_id == "SBOM-CRA-CVD-POLICY"),
1769 "dep-level advisories ref must not satisfy Annex I Part II (5)"
1770 );
1771
1772 let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(true));
1773 assert!(
1774 !r.violations
1775 .iter()
1776 .any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
1777 "primary-component security contact satisfies Art. 13(17)"
1778 );
1779 assert!(
1780 !r.violations
1781 .iter()
1782 .any(|v| v.rule_id == "SBOM-CRA-CVD-POLICY"),
1783 "primary-component advisories ref satisfies Annex I Part II (5)"
1784 );
1785 }
1786
1787 #[test]
1791 fn explicit_product_class_never_weakens_vendor_hash_gate() {
1792 use crate::model::CraProductClass;
1793 let mut sbom = NormalizedSbom::default();
1794 for n in ["a", "b", "c", "d"] {
1795 let c = vendor_component(n, true);
1796 sbom.components.insert(c.canonical_id.clone(), c);
1797 }
1798 for n in ["e", "f", "g", "h", "i", "j"] {
1799 let c = vendor_component(n, false);
1800 sbom.components.insert(c.canonical_id.clone(), c);
1801 }
1802 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1803 .with_product_class(CraProductClass::ImportantClass1)
1804 .check(&sbom);
1805 let v = result.violations.iter().find(|v| {
1806 v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Error
1807 });
1808 assert!(
1809 v.is_some(),
1810 "40% coverage must stay an Error under CraPhase2 even with an explicit class"
1811 );
1812 }
1813
1814 #[test]
1817 fn eucc_sidecar_fields_satisfy_critical_class_check() {
1818 use crate::model::{Component, CraProductClass, CraSidecarMetadata, DocumentMetadata};
1819 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1820 sbom.add_component(
1821 Component::new("fw".to_string(), "fw".to_string())
1822 .with_version("1.0".to_string())
1823 .with_purl("pkg:generic/fw@1.0".to_string()),
1824 );
1825
1826 let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1827 .with_product_class(CraProductClass::Critical)
1828 .check(&sbom);
1829 assert!(
1830 bare.violations
1831 .iter()
1832 .any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV"),
1833 "Critical class without EUCC evidence must flag Annex IV"
1834 );
1835
1836 let sidecar = CraSidecarMetadata {
1837 eucc_protection_profile_id: Some("PP-CC-MFR-2024-01".to_string()),
1838 eucc_target_of_evaluation: Some("TOE-fw-1.0".to_string()),
1839 ..Default::default()
1840 };
1841 let with_sidecar = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1842 .with_product_class(CraProductClass::Critical)
1843 .with_sidecar(sidecar)
1844 .check(&sbom);
1845 assert!(
1846 !with_sidecar
1847 .violations
1848 .iter()
1849 .any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV"),
1850 "sidecar EUCC evidence fields must satisfy the Critical-class check"
1851 );
1852 }
1853
1854 #[test]
1857 fn genuine_none_named_package_passes_name_gate() {
1858 use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
1859 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1860 let mut c = Component::new("none".to_string(), "none@1".to_string())
1861 .with_version("1.0.0".to_string())
1862 .with_purl("pkg:npm/none@1.0.0".to_string());
1863 c.supplier = Some(Organization::new("Acme".to_string()));
1864 sbom.add_component(c);
1865 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1866 assert!(
1867 !r.violations.iter().any(|v| v.rule_id == "SBOM-NTIA-NAME"),
1868 "npm package genuinely named 'none' must not fail the name gate"
1869 );
1870
1871 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1872 sbom.add_component(
1873 Component::new("NOASSERTION".to_string(), "x@1".to_string())
1874 .with_version("1.0".to_string())
1875 .with_purl("pkg:npm/realname@1.0".to_string()),
1876 );
1877 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1878 assert!(
1879 r.violations.iter().any(|v| v.rule_id == "SBOM-NTIA-NAME"),
1880 "NOASSERTION never satisfies the name gate"
1881 );
1882 }
1883
1884 #[test]
1887 fn fda_support_not_satisfied_by_incidental_eol_substring() {
1888 use crate::model::{Component, DocumentMetadata, NormalizedSbom, Property};
1889 let build = |name: &str, value: &str| {
1890 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1891 let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1892 .with_version("1.0".to_string())
1893 .with_purl("pkg:cargo/lib@1.0".to_string());
1894 c.extensions.properties.push(Property {
1895 name: name.to_string(),
1896 value: value.to_string(),
1897 });
1898 sbom.add_component(c);
1899 ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom)
1900 };
1901 assert!(
1902 build("geolocation", "enabled")
1903 .violations
1904 .iter()
1905 .any(|v| v.requirement.contains("Level of support")),
1906 "'geolocation' must not satisfy the support-lifecycle element"
1907 );
1908 assert!(
1909 build("acme:eol", "2030-01-01")
1910 .violations
1911 .iter()
1912 .all(|v| !v.requirement.contains("Level of support")),
1913 "a real eol property with a value satisfies the element"
1914 );
1915 assert!(
1916 build("end-of-support", "NOASSERTION")
1917 .violations
1918 .iter()
1919 .any(|v| v.requirement.contains("Level of support")),
1920 "a placeholder value must not satisfy the element"
1921 );
1922 }
1923
1924 #[test]
1928 fn primary_with_declared_empty_deps_does_not_warn() {
1929 use crate::model::{
1930 Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom, Property,
1931 };
1932 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1933 let mut app = Component::new("app".to_string(), "app".to_string())
1934 .with_version("1.0".to_string())
1935 .with_purl("pkg:cargo/app@1.0".to_string());
1936 app.extensions.properties.push(Property {
1937 name: crate::parsers::DECLARED_NO_DEPENDENCIES_PROPERTY.to_string(),
1938 value: "true".to_string(),
1939 });
1940 let lib_a = Component::new("liba".to_string(), "liba".to_string())
1941 .with_version("1.0".to_string())
1942 .with_purl("pkg:cargo/liba@1.0".to_string());
1943 let lib_b = Component::new("libb".to_string(), "libb".to_string())
1944 .with_version("1.0".to_string())
1945 .with_purl("pkg:cargo/libb@1.0".to_string());
1946 let app_id = app.canonical_id.clone();
1947 let a_id = lib_a.canonical_id.clone();
1948 let b_id = lib_b.canonical_id.clone();
1949 sbom.primary_component_id = Some(app_id);
1950 sbom.add_component(app);
1951 sbom.add_component(lib_a);
1952 sbom.add_component(lib_b);
1953 sbom.edges
1954 .push(DependencyEdge::new(a_id, b_id, DependencyType::DependsOn));
1955 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1956 assert!(
1957 !r.violations
1958 .iter()
1959 .any(|v| v.message.contains("Primary component")),
1960 "declared-no-dependencies primary must not warn"
1961 );
1962 }
1963
1964 #[test]
1967 fn fda_warns_on_minority_orphans() {
1968 use crate::model::{
1969 Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom,
1970 };
1971 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1972 let mut ids = Vec::new();
1973 for i in 0..4 {
1974 let c = Component::new(format!("lib{i}"), format!("lib{i}@1"))
1975 .with_version("1.0".to_string())
1976 .with_purl(format!("pkg:cargo/lib{i}@1.0"));
1977 ids.push(c.canonical_id.clone());
1978 sbom.add_component(c);
1979 }
1980 sbom.edges.push(DependencyEdge::new(
1982 ids[0].clone(),
1983 ids[1].clone(),
1984 DependencyType::DependsOn,
1985 ));
1986 sbom.edges.push(DependencyEdge::new(
1987 ids[1].clone(),
1988 ids[2].clone(),
1989 DependencyType::DependsOn,
1990 ));
1991 let fda = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
1992 assert!(
1993 fda.violations.iter().any(|v| v
1994 .message
1995 .contains("participate in no dependency relationship")),
1996 "FDA warns on any orphaned component"
1997 );
1998 let ntia = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1999 assert!(
2000 !ntia.violations.iter().any(|v| v
2001 .message
2002 .contains("participate in no dependency relationship")),
2003 "NTIA only warns when orphans form a majority"
2004 );
2005 }
2006
2007 #[test]
2010 fn fda_findings_carry_fda_rule_ids() {
2011 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
2012 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2013 sbom.document.creators.clear();
2014 for i in 0..2 {
2015 sbom.add_component(
2016 Component::new(format!("lib{i}"), format!("lib{i}@1"))
2017 .with_version("1.0".to_string())
2018 .with_purl(format!("pkg:cargo/lib{i}@1.0")),
2019 );
2020 }
2021 let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
2022 assert!(
2023 r.violations.iter().any(|v| v.rule_id == "SBOM-FDA-CREATOR"),
2024 "creators-empty must carry SBOM-FDA-CREATOR under FDA"
2025 );
2026 assert!(
2027 r.violations
2028 .iter()
2029 .any(|v| v.rule_id == "SBOM-FDA-DEPENDENCY"),
2030 "dependency findings must carry SBOM-FDA-DEPENDENCY under FDA"
2031 );
2032 assert!(
2033 r.violations
2034 .iter()
2035 .any(|v| v.rule_id == "SBOM-FDA-NAMESPACE"),
2036 "serial-number finding must carry SBOM-FDA-NAMESPACE under FDA"
2037 );
2038 assert!(
2039 !r.violations
2040 .iter()
2041 .any(|v| v.rule_id == "SBOM-NTIA-DEPENDENCY"),
2042 "FDA runs must not emit NTIA dependency rule identity"
2043 );
2044 }
2045
2046 #[test]
2049 fn eucc_sidecar_empty_or_expired_evidence_does_not_satisfy() {
2050 use crate::model::{Component, CraProductClass, CraSidecarMetadata, DocumentMetadata};
2051 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2052 sbom.add_component(
2053 Component::new("fw".to_string(), "fw".to_string())
2054 .with_version("1.0".to_string())
2055 .with_purl("pkg:generic/fw@1.0".to_string()),
2056 );
2057 let check = |sidecar: CraSidecarMetadata| {
2058 ComplianceChecker::new(ComplianceLevel::CraPhase2)
2059 .with_product_class(CraProductClass::Critical)
2060 .with_sidecar(sidecar)
2061 .check(&sbom)
2062 .violations
2063 .iter()
2064 .any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV")
2065 };
2066 assert!(
2067 check(CraSidecarMetadata {
2068 eucc_protection_profile_id: Some(" ".to_string()),
2069 ..Default::default()
2070 }),
2071 "an empty-string EUCC field must not satisfy the Annex IV gate"
2072 );
2073 assert!(
2074 check(CraSidecarMetadata {
2075 eucc_valid_until: Some(chrono::Utc::now() - chrono::Duration::days(365)),
2076 ..Default::default()
2077 }),
2078 "an expired EUCC validity date must not satisfy the Annex IV gate"
2079 );
2080 assert!(
2081 !check(CraSidecarMetadata {
2082 eucc_valid_until: Some(chrono::Utc::now() + chrono::Duration::days(365)),
2083 ..Default::default()
2084 }),
2085 "a live EUCC validity date satisfies the Annex IV gate"
2086 );
2087 }
2088
2089 #[test]
2092 fn steward_supplier_refs_cite_art_24() {
2093 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
2094 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2095 sbom.document.vulnerability_disclosure_url =
2096 Some("https://example.org/security".to_string());
2097 sbom.add_component(
2098 Component::new("lib".to_string(), "lib@1".to_string())
2099 .with_version("1.0".to_string())
2100 .with_purl("pkg:cargo/lib@1.0".to_string()),
2101 );
2102 let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
2103 let v = r
2104 .violations
2105 .iter()
2106 .find(|v| v.rule_id == "SBOM-CRA-ART-24-SUPPLIER")
2107 .expect("steward supplier warning fires");
2108 assert!(
2109 v.standard_refs
2110 .iter()
2111 .all(|sr| sr.id != "Art. 13(15)" && sr.id != "Art. 13(16)"),
2112 "steward supplier refs must not cite Art. 13(15)/13(16)"
2113 );
2114 assert!(
2115 v.standard_refs.iter().any(|sr| sr.id == "Art. 24"),
2116 "steward supplier refs must cite Art. 24"
2117 );
2118 }
2119
2120 #[test]
2123 fn manufacturer_scope_handles_cycles_and_sibling_roots() {
2124 use crate::model::{
2125 Component, DependencyEdge, DependencyType, DocumentMetadata, ExternalRefType,
2126 ExternalReference, NormalizedSbom,
2127 };
2128 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2130 let mut a = Component::new("liba".to_string(), "liba".to_string())
2131 .with_version("1.0".to_string())
2132 .with_purl("pkg:cargo/liba@1.0".to_string());
2133 a.external_refs.push(ExternalReference {
2134 ref_type: ExternalRefType::SecurityContact,
2135 url: "https://acme.example/security".to_string(),
2136 comment: None,
2137 hashes: Vec::new(),
2138 });
2139 let b = Component::new("libb".to_string(), "libb".to_string())
2140 .with_version("1.0".to_string())
2141 .with_purl("pkg:cargo/libb@1.0".to_string());
2142 let a_id = a.canonical_id.clone();
2143 let b_id = b.canonical_id.clone();
2144 sbom.add_component(a);
2145 sbom.add_component(b);
2146 sbom.edges.push(DependencyEdge::new(
2147 a_id.clone(),
2148 b_id.clone(),
2149 DependencyType::DependsOn,
2150 ));
2151 sbom.edges
2152 .push(DependencyEdge::new(b_id, a_id, DependencyType::DependsOn));
2153 let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
2154 assert!(
2155 !r.violations
2156 .iter()
2157 .any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
2158 "evidence in a fully-cyclic graph must still count (fallback to all components)"
2159 );
2160 }
2161
2162 #[test]
2165 fn cra_dependency_cycles_scale_with_product_class() {
2166 use crate::model::{
2167 Component, CraProductClass, DependencyEdge, DependencyType, DocumentMetadata,
2168 NormalizedSbom,
2169 };
2170 let build = |cyclic: bool| {
2171 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2172 let a = Component::new("liba".to_string(), "liba".to_string())
2173 .with_version("1.0".to_string())
2174 .with_purl("pkg:cargo/liba@1.0".to_string());
2175 let b = Component::new("libb".to_string(), "libb".to_string())
2176 .with_version("1.0".to_string())
2177 .with_purl("pkg:cargo/libb@1.0".to_string());
2178 let a_id = a.canonical_id.clone();
2179 let b_id = b.canonical_id.clone();
2180 sbom.add_component(a);
2181 sbom.add_component(b);
2182 sbom.edges.push(DependencyEdge::new(
2183 a_id.clone(),
2184 b_id.clone(),
2185 DependencyType::DependsOn,
2186 ));
2187 if cyclic {
2188 sbom.edges
2189 .push(DependencyEdge::new(b_id, a_id, DependencyType::DependsOn));
2190 }
2191 sbom
2192 };
2193
2194 let cycles = |r: &ComplianceResult| {
2195 r.violations
2196 .iter()
2197 .find(|v| v.rule_id == "SBOM-CRA-CYCLES")
2198 .map(|v| v.severity)
2199 };
2200
2201 let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(true));
2202 assert_eq!(
2203 cycles(&r),
2204 Some(ViolationSeverity::Warning),
2205 "cycles warn at the default class"
2206 );
2207 let r = ComplianceChecker::new(ComplianceLevel::CraPhase2)
2208 .with_product_class(CraProductClass::Critical)
2209 .check(&build(true));
2210 assert_eq!(
2211 cycles(&r),
2212 Some(ViolationSeverity::Error),
2213 "cycles error at Critical class"
2214 );
2215 let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(false));
2216 assert_eq!(cycles(&r), None, "acyclic graphs are silent");
2217 }
2218
2219 #[test]
2222 fn not_applicable_result_has_no_score() {
2223 use crate::model::{Component, DocumentMetadata, NormalizedSbom};
2224 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2225 sbom.add_component(
2226 Component::new("lib".to_string(), "lib@1".to_string())
2227 .with_version("1.0".to_string())
2228 .with_purl("pkg:cargo/lib@1.0".to_string()),
2229 );
2230 let r = ComplianceChecker::new(ComplianceLevel::EuAiAct).check(&sbom);
2231 assert!(!r.is_applicable(), "non-AI SBOM must be NotApplicable");
2232 assert!(
2233 matches!(r.applicability, Applicability::NotApplicable(_)),
2234 "applicability must carry the reason"
2235 );
2236 assert_eq!(r.score(), None, "unevaluated SBOMs have no score");
2237 assert!(r.is_compliant, "the N/A is_compliant contract is preserved");
2238
2239 let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
2241 assert!(r.is_applicable());
2242 assert!(r.score().is_some());
2243 }
2244
2245 #[test]
2249 fn score_is_neutral_to_info_findings() {
2250 let violation = |severity| Violation {
2251 severity,
2252 category: ViolationCategory::DocumentMetadata,
2253 message: "x".to_string(),
2254 element: None,
2255 requirement: "x".to_string(),
2256 rule_id: "SBOM-CRA-GENERAL",
2257 component_id: None,
2258 counts: None,
2259 standard_refs: Vec::new(),
2260 };
2261 let errors_only = ComplianceResult::new(
2262 ComplianceLevel::NtiaMinimum,
2263 (0..5)
2264 .map(|_| violation(ViolationSeverity::Error))
2265 .collect(),
2266 );
2267 let with_infos = ComplianceResult::new(
2268 ComplianceLevel::NtiaMinimum,
2269 (0..5)
2270 .map(|_| violation(ViolationSeverity::Error))
2271 .chain((0..20).map(|_| violation(ViolationSeverity::Info)))
2272 .collect(),
2273 );
2274 assert_eq!(errors_only.score(), with_infos.score());
2275 assert_eq!(errors_only.score(), Some(16));
2276 let clean = ComplianceResult::new(ComplianceLevel::NtiaMinimum, Vec::new());
2277 assert_eq!(clean.score(), Some(100));
2278 }
2279
2280 #[test]
2283 fn applicability_defaults_on_old_payloads() {
2284 let r = ComplianceResult::new(ComplianceLevel::NtiaMinimum, Vec::new());
2285 let mut json: serde_json::Value = serde_json::to_value(&r).unwrap();
2286 json.as_object_mut().unwrap().remove("applicability");
2287 let back: ComplianceResult = serde_json::from_value(json).unwrap();
2288 assert_eq!(back.applicability, Applicability::Applicable);
2289 }
2290
2291 #[test]
2295 fn as_of_clock_pins_deadline_checks() {
2296 use crate::model::{Component, CraSidecarMetadata, DocumentMetadata, NormalizedSbom};
2297 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2298 sbom.add_component(
2299 Component::new("lib".to_string(), "lib@1".to_string())
2300 .with_version("1.0".to_string())
2301 .with_purl("pkg:cargo/lib@1.0".to_string()),
2302 );
2303 let ts = |s: &str| {
2304 chrono::DateTime::parse_from_rfc3339(s)
2305 .unwrap()
2306 .with_timezone(&chrono::Utc)
2307 };
2308
2309 let art14_severity = |as_of: &str| {
2311 ComplianceChecker::new(ComplianceLevel::CraPhase2)
2312 .with_as_of(ts(as_of))
2313 .check(&sbom)
2314 .violations
2315 .iter()
2316 .find(|v| v.requirement.contains("Art. 14(2)(a)"))
2317 .map(|v| v.severity)
2318 };
2319 assert_eq!(
2320 art14_severity("2026-01-01T00:00:00Z"),
2321 Some(ViolationSeverity::Info),
2322 "pre-deadline Art. 14 findings are informational"
2323 );
2324 assert_eq!(
2325 art14_severity("2027-01-01T00:00:00Z"),
2326 Some(ViolationSeverity::Warning),
2327 "post-deadline Art. 14 findings escalate"
2328 );
2329
2330 let sidecar = CraSidecarMetadata {
2332 eucc_protection_profile_id: Some("PP-1".to_string()),
2333 eucc_target_of_evaluation: Some("TOE-1".to_string()),
2334 eucc_itsef_identifier: Some("ITSEF-1".to_string()),
2335 eucc_valid_until: Some(ts("2027-06-01T00:00:00Z")),
2336 ..Default::default()
2337 };
2338 let eucc_expired = |as_of: &str| {
2339 ComplianceChecker::new(ComplianceLevel::EuccSubstantial)
2340 .with_sidecar(sidecar.clone())
2341 .with_as_of(ts(as_of))
2342 .check(&sbom)
2343 .violations
2344 .iter()
2345 .any(|v| {
2346 v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-EUCC-VALIDITY"
2347 })
2348 };
2349 assert!(
2350 !eucc_expired("2027-01-01T00:00:00Z"),
2351 "certificate valid at the pinned instant"
2352 );
2353 assert!(
2354 eucc_expired("2028-01-01T00:00:00Z"),
2355 "certificate expired at the pinned instant"
2356 );
2357 }
2358
2359 #[test]
2360 fn test_compliance_level_names() {
2361 assert_eq!(ComplianceLevel::Minimum.name(), "Minimum");
2362 assert_eq!(ComplianceLevel::NtiaMinimum.name(), "NTIA Minimum Elements");
2363 assert_eq!(ComplianceLevel::CraPhase1.name(), "EU CRA Phase 1 (2026)");
2364 assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2027)");
2365 assert_eq!(ComplianceLevel::NistSsdf.name(), "NIST SSDF (SP 800-218)");
2366 assert_eq!(ComplianceLevel::Eo14028.name(), "EO 14028 Section 4");
2367 }
2368
2369 #[test]
2370 fn test_nist_ssdf_empty_sbom() {
2371 let sbom = NormalizedSbom::default();
2372 let checker = ComplianceChecker::new(ComplianceLevel::NistSsdf);
2373 let result = checker.check(&sbom);
2374 assert!(
2376 result
2377 .violations
2378 .iter()
2379 .any(|v| v.requirement.contains("PS.1"))
2380 );
2381 }
2382
2383 #[test]
2384 fn test_eo14028_empty_sbom() {
2385 let sbom = NormalizedSbom::default();
2386 let checker = ComplianceChecker::new(ComplianceLevel::Eo14028);
2387 let result = checker.check(&sbom);
2388 assert!(
2389 result
2390 .violations
2391 .iter()
2392 .any(|v| v.requirement.contains("EO 14028"))
2393 );
2394 }
2395
2396 #[test]
2397 fn test_compliance_result_counts() {
2398 let violations = vec![
2399 Violation {
2400 severity: ViolationSeverity::Error,
2401 category: ViolationCategory::ComponentIdentification,
2402 message: "Error 1".to_string(),
2403 element: None,
2404 requirement: "Test".to_string(),
2405 rule_id: "SBOM-CRA-GENERAL",
2406 component_id: None,
2407 counts: None,
2408 standard_refs: Vec::new(),
2409 },
2410 Violation {
2411 severity: ViolationSeverity::Warning,
2412 category: ViolationCategory::LicenseInfo,
2413 message: "Warning 1".to_string(),
2414 element: None,
2415 requirement: "Test".to_string(),
2416 rule_id: "SBOM-CRA-GENERAL",
2417 component_id: None,
2418 counts: None,
2419 standard_refs: Vec::new(),
2420 },
2421 Violation {
2422 severity: ViolationSeverity::Info,
2423 category: ViolationCategory::FormatSpecific,
2424 message: "Info 1".to_string(),
2425 element: None,
2426 requirement: "Test".to_string(),
2427 rule_id: "SBOM-CRA-GENERAL",
2428 component_id: None,
2429 counts: None,
2430 standard_refs: Vec::new(),
2431 },
2432 ];
2433
2434 let result = ComplianceResult::new(ComplianceLevel::Standard, violations);
2435 assert!(!result.is_compliant);
2436 assert_eq!(result.error_count, 1);
2437 assert_eq!(result.warning_count, 1);
2438 assert_eq!(result.info_count, 1);
2439 }
2440
2441 fn make_crypto_sbom(algos: &[(&str, &str, Option<&str>, Option<u8>)]) -> NormalizedSbom {
2442 use crate::model::{
2443 AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
2444 };
2445 let mut sbom = NormalizedSbom::default();
2446 for (name, family, param, ql) in algos {
2447 let mut c = crate::model::Component::new(name.to_string(), format!("{name}@1.0"));
2448 c.component_type = ComponentType::Cryptographic;
2449 let mut algo = AlgorithmProperties::new(CryptoPrimitive::Ae)
2450 .with_algorithm_family(family.to_string());
2451 if let Some(p) = param {
2452 algo = algo.with_parameter_set_identifier(p.to_string());
2453 }
2454 if let Some(level) = ql {
2455 algo = algo.with_nist_quantum_security_level(*level);
2456 }
2457 c.crypto_properties = Some(
2458 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(algo),
2459 );
2460 sbom.add_component(c);
2461 }
2462 sbom
2463 }
2464
2465 #[test]
2466 fn test_cnsa2_aes128_violation() {
2467 let sbom = make_crypto_sbom(&[("AES-128-GCM", "AES", Some("128"), Some(1))]);
2468 let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
2469 let result = checker.check(&sbom);
2470 assert!(
2471 result
2472 .violations
2473 .iter()
2474 .any(|v| v.severity == ViolationSeverity::Error && v.message.contains("AES-128")),
2475 "CNSA 2.0 should flag AES-128"
2476 );
2477 }
2478
2479 #[test]
2480 fn test_cnsa2_mlkem1024_passes() {
2481 let sbom = make_crypto_sbom(&[("ML-KEM-1024", "ML-KEM", Some("1024"), Some(5))]);
2482 let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
2483 let result = checker.check(&sbom);
2484 let algo_errors: Vec<_> = result
2485 .violations
2486 .iter()
2487 .filter(|v| {
2488 v.severity == ViolationSeverity::Error
2489 && v.element.as_deref() == Some("ML-KEM-1024")
2490 })
2491 .collect();
2492 assert!(algo_errors.is_empty(), "ML-KEM-1024 should pass CNSA 2.0");
2493 }
2494
2495 #[test]
2496 fn test_pqc_quantum_vulnerable() {
2497 let sbom = make_crypto_sbom(&[("RSA-2048", "RSA", None, Some(0))]);
2498 let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
2499 let result = checker.check(&sbom);
2500 assert!(
2501 result
2502 .violations
2503 .iter()
2504 .any(|v| v.severity == ViolationSeverity::Error
2505 && v.message.contains("quantum-vulnerable")),
2506 "PQC should flag RSA-2048 as quantum-vulnerable"
2507 );
2508 }
2509
2510 #[test]
2514 fn crypto_standards_fail_on_empty_inventory() {
2515 let mut sbom = NormalizedSbom::default();
2516 sbom.add_component(crate::model::Component::new(
2517 "lodash".to_string(),
2518 "lodash@4.17.21".to_string(),
2519 ));
2520 for level in [ComplianceLevel::NistPqc, ComplianceLevel::Cnsa2] {
2521 let result = ComplianceChecker::new(level).check(&sbom);
2522 assert!(
2523 !result.is_compliant,
2524 "{level:?} must not report compliant with no crypto inventory"
2525 );
2526 assert!(
2527 result
2528 .violations
2529 .iter()
2530 .any(|v| v.severity == ViolationSeverity::Error
2531 && v.message.contains("No cryptographic inventory")),
2532 "{level:?} must emit an inventory-absent error"
2533 );
2534 }
2535 }
2536
2537 #[test]
2541 fn pqc_flags_classical_crypto_with_unset_quantum_level() {
2542 for family in ["RSA", "ECDSA", "ECDH", "DH", "DSA"] {
2543 let sbom = make_crypto_sbom(&[("classical", family, None, None)]);
2544 let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2545 assert!(
2546 !result.is_compliant,
2547 "{family} with unset quantum level must fail PQC"
2548 );
2549 assert!(
2550 result
2551 .violations
2552 .iter()
2553 .any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-001"),
2554 "{family} must raise the quantum-vulnerable error"
2555 );
2556 }
2557 }
2558
2559 #[test]
2563 fn cnsa2_flags_weak_sha2_in_either_encoding() {
2564 for (family, param) in [
2565 ("SHA-256", None),
2566 ("SHA-224", None),
2567 ("SHA-2", Some("256")),
2568 ("SHA-2", Some("224")),
2569 ] {
2570 let sbom = make_crypto_sbom(&[("hash", family, param, None)]);
2571 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2572 assert!(
2573 result
2574 .violations
2575 .iter()
2576 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
2577 "{family}/{param:?} must fail CNSA 2.0 hash gate"
2578 );
2579 }
2580 let ok = make_crypto_sbom(&[("hash", "SHA-384", None, None)]);
2582 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
2583 assert!(
2584 !result
2585 .violations
2586 .iter()
2587 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
2588 "SHA-384 must not trip the hash gate"
2589 );
2590 }
2591
2592 #[test]
2593 fn test_pqc_approved_algorithm_info() {
2594 let sbom = make_crypto_sbom(&[("ML-DSA-65", "ML-DSA", Some("65"), Some(3))]);
2595 let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
2596 let result = checker.check(&sbom);
2597 assert!(
2598 result
2599 .violations
2600 .iter()
2601 .any(|v| v.severity == ViolationSeverity::Info && v.message.contains("approved")),
2602 "PQC should report ML-DSA-65 as approved"
2603 );
2604 }
2605
2606 #[test]
2611 fn cnsa2_allowlist_rejects_non_cnsa_algorithms() {
2612 for (family, param, expected_rule) in [
2613 ("SHA-1", None, "SBOM-CNSA2-ALG-005"),
2614 ("DES", None, "SBOM-CNSA2-ALG-005"),
2615 ("ChaCha20", None, "SBOM-CNSA2-ALG-008"),
2616 ("Ed25519", None, "SBOM-CNSA2-ALG-006"),
2617 ("ECIES", None, "SBOM-CNSA2-ALG-006"),
2618 ("EC", None, "SBOM-CNSA2-ALG-006"),
2619 ("Kyber", Some("768"), "SBOM-CNSA2-ALG-003"),
2620 ("ML-KEM-768", None, "SBOM-CNSA2-ALG-003"),
2621 ("ML-KEM", None, "SBOM-CNSA2-ALG-003"), ("ML-DSA", Some("65"), "SBOM-CNSA2-ALG-004"),
2623 ("SLH-DSA", None, "SBOM-CNSA2-ALG-008"),
2624 ("SHA-3", Some("256"), "SBOM-CNSA2-ALG-008"),
2625 ("AES-128", None, "SBOM-CNSA2-ALG-001"), ] {
2627 let sbom = make_crypto_sbom(&[("asset", family, param, None)]);
2628 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2629 assert!(
2630 result.violations.iter().any(|v| {
2631 v.severity == ViolationSeverity::Error && v.rule_id == expected_rule
2632 }),
2633 "{family}/{param:?} must fail CNSA 2.0 with {expected_rule}; got {:?}",
2634 result
2635 .violations
2636 .iter()
2637 .map(|v| (v.rule_id, v.message.clone()))
2638 .collect::<Vec<_>>()
2639 );
2640 assert!(!result.is_compliant, "{family} must not be CNSA compliant");
2641 }
2642 }
2643
2644 #[test]
2647 fn cnsa2_allowlist_accepts_full_cnsa_suite() {
2648 let sbom = make_crypto_sbom(&[
2649 ("AES-256-GCM", "AES", Some("256"), Some(1)),
2650 ("SHA-384", "SHA-2", Some("384"), Some(2)),
2651 ("ML-KEM-1024", "ML-KEM", Some("1024"), Some(5)),
2652 ("ML-DSA-87", "ML-DSA", Some("87"), Some(5)),
2653 ("LMS", "LMS", None, Some(5)),
2654 ]);
2655 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2656 let errors: Vec<_> = result
2657 .violations
2658 .iter()
2659 .filter(|v| v.severity == ViolationSeverity::Error)
2660 .collect();
2661 assert!(
2662 errors.is_empty(),
2663 "full CNSA 2.0 suite must have zero errors, got {:?}",
2664 errors
2665 .iter()
2666 .map(|v| (v.rule_id, v.message.clone()))
2667 .collect::<Vec<_>>()
2668 );
2669 assert!(result.is_compliant);
2670 }
2671
2672 #[test]
2675 fn cnsa2_unknown_algorithm_warns() {
2676 let sbom = make_crypto_sbom(&[("mystery", "proprietary-frobnicator", None, None)]);
2677 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2678 assert!(
2679 result.violations.iter().any(|v| {
2680 v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-CNSA2-ALG-UNKNOWN"
2681 }),
2682 "unknown algorithm must produce the cannot-verify warning"
2683 );
2684 assert!(
2685 !result
2686 .violations
2687 .iter()
2688 .any(|v| v.severity == ViolationSeverity::Error
2689 && v.rule_id.starts_with("SBOM-CNSA2-ALG")),
2690 "unknown algorithm must not produce an algorithm Error"
2691 );
2692 }
2693
2694 #[test]
2698 fn cnsa2_classifies_without_algorithm_family() {
2699 use crate::model::{
2700 AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
2701 };
2702 let mut sbom = NormalizedSbom::default();
2703 let mut rsa = crate::model::Component::new("RSA-2048".into(), "algo-1".into());
2705 rsa.component_type = ComponentType::Cryptographic;
2706 rsa.crypto_properties = Some(
2707 CryptoProperties::new(CryptoAssetType::Algorithm)
2708 .with_oid("1.2.840.113549.1.1.1".into())
2709 .with_algorithm_properties(AlgorithmProperties::new(CryptoPrimitive::Pke)),
2710 );
2711 sbom.add_component(rsa);
2712 let mut aes = crate::model::Component::new("AES-128-CBC".into(), "algo-2".into());
2714 aes.component_type = ComponentType::Cryptographic;
2715 aes.crypto_properties = Some(
2716 CryptoProperties::new(CryptoAssetType::Algorithm)
2717 .with_algorithm_properties(AlgorithmProperties::new(CryptoPrimitive::BlockCipher)),
2718 );
2719 sbom.add_component(aes);
2720
2721 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2722 assert!(
2723 result
2724 .violations
2725 .iter()
2726 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-006" && v.message.contains("RSA")),
2727 "RSA must be flagged via OID without algorithmFamily"
2728 );
2729 assert!(
2730 result
2731 .violations
2732 .iter()
2733 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-001" && v.message.contains("AES-128")),
2734 "AES-128 must be flagged via name without algorithmFamily/OID"
2735 );
2736
2737 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2738 assert!(
2739 pqc.violations
2740 .iter()
2741 .any(|v| v.rule_id == "SBOM-PQC-001" && v.message.contains("RSA")),
2742 "PQC must flag OID-only RSA as quantum-vulnerable"
2743 );
2744 }
2745
2746 #[test]
2749 fn pqc_flags_broken_spelling_variants() {
2750 for family in ["SHA1", "TDES", "ARC4"] {
2751 let sbom = make_crypto_sbom(&[("legacy", family, None, None)]);
2752 let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2753 assert!(
2754 result
2755 .violations
2756 .iter()
2757 .any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-005"),
2758 "{family} must fail SP 800-131A broken-algorithm detection"
2759 );
2760 }
2761 let ok = make_crypto_sbom(&[("hash", "SHA-384", None, None)]);
2763 let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&ok);
2764 assert!(
2765 !result
2766 .violations
2767 .iter()
2768 .any(|v| v.rule_id == "SBOM-PQC-005"),
2769 "SHA-384 must not be reported broken"
2770 );
2771 }
2772
2773 fn make_protocol_sbom(version: &str, suite_name: &str, suite_algos: &[&str]) -> NormalizedSbom {
2774 use crate::model::{
2775 CipherSuite, ComponentType, CryptoAssetType, CryptoProperties, ProtocolProperties,
2776 ProtocolType,
2777 };
2778 let mut sbom = NormalizedSbom::default();
2779 let mut c = crate::model::Component::new("tls-endpoint".into(), "proto-1".into());
2780 c.component_type = ComponentType::Cryptographic;
2781 c.crypto_properties = Some(
2782 CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
2783 ProtocolProperties::new(ProtocolType::Tls)
2784 .with_version(version.to_string())
2785 .with_cipher_suites(vec![CipherSuite {
2786 name: Some(suite_name.to_string()),
2787 algorithms: suite_algos.iter().map(ToString::to_string).collect(),
2788 identifiers: Vec::new(),
2789 }]),
2790 ),
2791 );
2792 sbom.add_component(c);
2793 sbom
2794 }
2795
2796 #[test]
2800 fn protocol_tls10_rc4_fails_both_standards() {
2801 let sbom = make_protocol_sbom("1.0", "TLS_RSA_WITH_RC4_128_SHA", &[]);
2802
2803 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2804 assert!(!cnsa.is_compliant, "TLS 1.0 must fail CNSA 2.0");
2805 assert!(
2806 cnsa.violations
2807 .iter()
2808 .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
2809 "TLS 1.0 must trip the CNSA TLS-1.3 version gate"
2810 );
2811 assert!(
2812 cnsa.violations.iter().any(|v| {
2813 v.rule_id == "SBOM-CNSA2-PROTO-002"
2814 && v.message.contains("RC4")
2815 && v.message.contains("RSA")
2816 }),
2817 "cipher-suite scan must flag RC4 and RSA; got {:?}",
2818 cnsa.violations
2819 .iter()
2820 .map(|v| v.message.clone())
2821 .collect::<Vec<_>>()
2822 );
2823
2824 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2825 assert!(!pqc.is_compliant, "TLS 1.0 must fail PQC readiness");
2826 assert!(
2827 pqc.violations
2828 .iter()
2829 .any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
2830 "TLS 1.0 must trip the PQC minimum-version gate"
2831 );
2832 assert!(
2833 pqc.violations
2834 .iter()
2835 .any(|v| v.rule_id == "SBOM-PQC-PROTO-002" && v.message.contains("broken")),
2836 "cipher-suite scan must flag broken algorithms under PQC"
2837 );
2838 }
2839
2840 #[test]
2843 fn protocol_tls13_cnsa_suite_passes() {
2844 let sbom = make_protocol_sbom("1.3", "TLS_AES_256_GCM_SHA384", &[]);
2845
2846 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2847 assert!(
2848 !cnsa
2849 .violations
2850 .iter()
2851 .any(|v| v.rule_id.starts_with("SBOM-CNSA2-PROTO")),
2852 "TLS 1.3 + CNSA suite must raise no CNSA protocol violations; got {:?}",
2853 cnsa.violations
2854 .iter()
2855 .map(|v| v.message.clone())
2856 .collect::<Vec<_>>()
2857 );
2858 assert!(cnsa.is_compliant);
2859
2860 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2861 assert!(
2862 !pqc.violations
2863 .iter()
2864 .any(|v| v.rule_id.starts_with("SBOM-PQC-PROTO")),
2865 "TLS 1.3 + CNSA suite must raise no PQC protocol violations"
2866 );
2867 }
2868
2869 #[test]
2872 fn protocol_tls12_fails_cnsa_only() {
2873 let sbom = make_protocol_sbom("1.2", "TLS_AES_256_GCM_SHA384", &[]);
2874 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2875 assert!(
2876 cnsa.violations
2877 .iter()
2878 .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
2879 "TLS 1.2 must fail the CNSA 2.0 version gate"
2880 );
2881 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2882 assert!(
2883 !pqc.violations
2884 .iter()
2885 .any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
2886 "TLS 1.2 must pass the PQC minimum-version gate"
2887 );
2888 }
2889
2890 #[test]
2893 fn protocol_resolves_cipher_suite_algorithm_refs() {
2894 use crate::model::{
2895 AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
2896 };
2897 let mut sbom = make_protocol_sbom("1.3", "OPAQUE_SUITE_1", &["suite-algo-7"]);
2898 let mut rsa = crate::model::Component::new("legacy-kx".into(), "suite-algo-7".into());
2899 rsa.component_type = ComponentType::Cryptographic;
2900 rsa.crypto_properties = Some(
2901 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
2902 AlgorithmProperties::new(CryptoPrimitive::Pke).with_algorithm_family("RSA".into()),
2903 ),
2904 );
2905 sbom.add_component(rsa);
2906
2907 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2908 assert!(
2909 cnsa.violations
2910 .iter()
2911 .any(|v| { v.rule_id == "SBOM-CNSA2-PROTO-002" && v.message.contains("RSA") }),
2912 "resolved suite algorithm ref must be classified; got {:?}",
2913 cnsa.violations
2914 .iter()
2915 .map(|v| v.message.clone())
2916 .collect::<Vec<_>>()
2917 );
2918 }
2919
2920 fn make_cert_sbom(sig_ref: &str, algo_family: Option<&str>) -> NormalizedSbom {
2921 use crate::model::{
2922 AlgorithmProperties, CertificateProperties, ComponentType, CryptoAssetType,
2923 CryptoPrimitive, CryptoProperties,
2924 };
2925 let mut sbom = NormalizedSbom::default();
2926 if let Some(family) = algo_family {
2927 let mut algo = crate::model::Component::new("sig-algorithm".into(), sig_ref.into());
2928 algo.component_type = ComponentType::Cryptographic;
2929 algo.crypto_properties = Some(
2930 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
2931 AlgorithmProperties::new(CryptoPrimitive::Signature)
2932 .with_algorithm_family(family.to_string()),
2933 ),
2934 );
2935 sbom.add_component(algo);
2936 }
2937 let mut cert = crate::model::Component::new("server-cert".into(), "cert-1".into());
2938 cert.component_type = ComponentType::Cryptographic;
2939 cert.crypto_properties = Some(
2940 CryptoProperties::new(CryptoAssetType::Certificate).with_certificate_properties(
2941 CertificateProperties::new().with_signature_algorithm_ref(sig_ref.to_string()),
2942 ),
2943 );
2944 sbom.add_component(cert);
2945 sbom
2946 }
2947
2948 #[test]
2952 fn cnsa2_cert_resolves_opaque_signature_ref() {
2953 let sbom = make_cert_sbom("sig-algo-42", Some("RSA"));
2954 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2955 assert!(
2956 result
2957 .violations
2958 .iter()
2959 .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
2960 "opaque ref resolving to RSA must fire CERT-001; got {:?}",
2961 result
2962 .violations
2963 .iter()
2964 .map(|v| (v.rule_id, v.message.clone()))
2965 .collect::<Vec<_>>()
2966 );
2967
2968 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2969 assert!(
2970 pqc.violations
2971 .iter()
2972 .any(|v| v.rule_id == "SBOM-PQC-CERT-001"),
2973 "opaque ref resolving to RSA must fire the PQC certificate rule"
2974 );
2975 }
2976
2977 #[test]
2981 fn cnsa2_cert_approved_and_fallback_cases() {
2982 let ok = make_cert_sbom("sig-algo-42", Some("ML-DSA-87"));
2983 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
2984 assert!(
2985 !result
2986 .violations
2987 .iter()
2988 .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
2989 "ML-DSA-87-signed certificate must pass CERT-001"
2990 );
2991
2992 let dangling = make_cert_sbom("crypto/algorithm/ecdsa-p256", None);
2994 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&dangling);
2995 assert!(
2996 result
2997 .violations
2998 .iter()
2999 .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
3000 "unresolvable ECDSA-named ref must still fire CERT-001 via token fallback"
3001 );
3002 }
3003
3004 #[test]
3007 fn cnsa2_key_material_alone_does_not_satisfy_inventory_gate() {
3008 use crate::model::{
3009 ComponentType, CryptoAssetType, CryptoMaterialType, CryptoProperties,
3010 RelatedCryptoMaterialProperties,
3011 };
3012 let mut sbom = NormalizedSbom::default();
3013 let mut key = crate::model::Component::new("some-key".into(), "key-1".into());
3014 key.component_type = ComponentType::Cryptographic;
3015 key.crypto_properties = Some(
3016 CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
3017 .with_related_crypto_material_properties(
3018 RelatedCryptoMaterialProperties::new(CryptoMaterialType::PublicKey)
3019 .with_size(2048),
3020 ),
3021 );
3022 sbom.add_component(key);
3023
3024 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3025 assert!(
3026 result
3027 .violations
3028 .iter()
3029 .any(|v| v.rule_id == "SBOM-CNSA2-000"),
3030 "unevaluable key material must not satisfy the CNSA2-000 inventory gate"
3031 );
3032 assert!(!result.is_compliant);
3033 }
3034
3035 #[test]
3039 fn compound_family_spellings_fail_both_standards() {
3040 let sbom = make_crypto_sbom(&[("legacy-cipher", "DES-CBC", None, None)]);
3041 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3042 assert!(!cnsa.is_compliant, "DES-CBC must not be CNSA 2.0 compliant");
3043 assert!(
3044 cnsa.violations
3045 .iter()
3046 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-005" && v.message.contains("DES")),
3047 "DES-CBC must fire the broken-algorithm rule; got {:?}",
3048 cnsa.violations
3049 .iter()
3050 .map(|v| (v.rule_id, v.message.clone()))
3051 .collect::<Vec<_>>()
3052 );
3053 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3054 assert!(
3055 pqc.violations.iter().any(|v| v.rule_id == "SBOM-PQC-005"),
3056 "DES-CBC must fire SP 800-131A under PQC"
3057 );
3058
3059 let sbom = make_crypto_sbom(&[("aes-cbc", "AES-128-CBC", None, None)]);
3060 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3061 assert!(
3062 cnsa.violations
3063 .iter()
3064 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-001" && v.message.contains("AES-128")),
3065 "AES-128-CBC must fire the AES-256-only rule"
3066 );
3067
3068 let ok = make_crypto_sbom(&[("aead", "AES-256-GCM", None, None)]);
3070 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
3071 assert!(
3072 !cnsa
3073 .violations
3074 .iter()
3075 .any(|v| v.severity == ViolationSeverity::Error),
3076 "AES-256-GCM must pass the CNSA 2.0 allowlist; got {:?}",
3077 cnsa.violations
3078 .iter()
3079 .map(|v| (v.rule_id, v.message.clone()))
3080 .collect::<Vec<_>>()
3081 );
3082 }
3083
3084 #[test]
3088 fn cnsa2_flags_truncated_sha2_variants() {
3089 for family in ["SHA-512/256", "SHA-512/224"] {
3090 let sbom = make_crypto_sbom(&[("hash", family, None, None)]);
3091 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3092 assert!(
3093 result
3094 .violations
3095 .iter()
3096 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
3097 "{family} must fail the CNSA 2.0 hash gate; got {:?}",
3098 result
3099 .violations
3100 .iter()
3101 .map(|v| (v.rule_id, v.message.clone()))
3102 .collect::<Vec<_>>()
3103 );
3104 }
3105 let ok = make_crypto_sbom(&[("hash", "SHA-512", None, None)]);
3107 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
3108 assert!(
3109 !result
3110 .violations
3111 .iter()
3112 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
3113 "SHA-512 must not trip the hash gate"
3114 );
3115 }
3116
3117 #[test]
3122 fn name_fallback_reports_most_severe_token() {
3123 use crate::model::{
3124 AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
3125 };
3126 for name in ["sha384-rsa-signature", "rsa-sha384-signature"] {
3127 let mut sbom = NormalizedSbom::default();
3128 let mut c = crate::model::Component::new(name.into(), "algo-1".into());
3129 c.component_type = ComponentType::Cryptographic;
3130 c.crypto_properties = Some(
3131 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
3132 AlgorithmProperties::new(CryptoPrimitive::Signature),
3133 ),
3134 );
3135 sbom.add_component(c);
3136
3137 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3138 assert!(
3139 cnsa.violations
3140 .iter()
3141 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-006" && v.message.contains("RSA")),
3142 "'{name}' must be flagged quantum-vulnerable under CNSA 2.0; got {:?}",
3143 cnsa.violations
3144 .iter()
3145 .map(|v| (v.rule_id, v.message.clone()))
3146 .collect::<Vec<_>>()
3147 );
3148 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3149 assert!(
3150 pqc.violations
3151 .iter()
3152 .any(|v| v.rule_id == "SBOM-PQC-001" && v.message.contains("RSA")),
3153 "'{name}' must be flagged quantum-vulnerable under PQC"
3154 );
3155 }
3156 }
3157
3158 #[test]
3163 fn cert_unknown_signature_ref_warns_both_standards() {
3164 let sbom = make_cert_sbom("sig-algo-42", None);
3165
3166 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3167 assert!(
3168 cnsa.violations.iter().any(|v| {
3169 v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-CNSA2-CERT-UNKNOWN"
3170 }),
3171 "opaque dangling sig ref must warn under CNSA 2.0; got {:?}",
3172 cnsa.violations
3173 .iter()
3174 .map(|v| (v.rule_id, v.message.clone()))
3175 .collect::<Vec<_>>()
3176 );
3177 assert!(
3178 !cnsa
3179 .violations
3180 .iter()
3181 .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
3182 "an unverifiable ref is a Warning, not a CERT-001 Error"
3183 );
3184
3185 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3186 assert!(
3187 pqc.violations.iter().any(|v| {
3188 v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-PQC-CERT-UNKNOWN"
3189 }),
3190 "opaque dangling sig ref must warn under PQC"
3191 );
3192
3193 let ok = make_cert_sbom("sig-algo-42", Some("ML-DSA-87"));
3196 for level in [ComplianceLevel::Cnsa2, ComplianceLevel::NistPqc] {
3197 let result = ComplianceChecker::new(level).check(&ok);
3198 assert!(
3199 !result
3200 .violations
3201 .iter()
3202 .any(|v| v.rule_id.ends_with("CERT-UNKNOWN")),
3203 "{level:?}: resolvable approved sig ref must not warn"
3204 );
3205 }
3206 }
3207
3208 #[test]
3212 fn protocol_without_evidence_warns_both_standards() {
3213 use crate::model::{
3214 ComponentType, CryptoAssetType, CryptoProperties, ProtocolProperties, ProtocolType,
3215 };
3216 let mut sbom = NormalizedSbom::default();
3217 let mut c = crate::model::Component::new("ssh-endpoint".into(), "proto-1".into());
3218 c.component_type = ComponentType::Cryptographic;
3219 c.crypto_properties = Some(
3220 CryptoProperties::new(CryptoAssetType::Protocol)
3221 .with_protocol_properties(ProtocolProperties::new(ProtocolType::Ssh)),
3222 );
3223 sbom.add_component(c);
3224
3225 for (level, rule) in [
3226 (ComplianceLevel::Cnsa2, "SBOM-CNSA2-PROTO-UNKNOWN"),
3227 (ComplianceLevel::NistPqc, "SBOM-PQC-PROTO-UNKNOWN"),
3228 ] {
3229 let result = ComplianceChecker::new(level).check(&sbom);
3230 assert!(
3231 result
3232 .violations
3233 .iter()
3234 .any(|v| v.severity == ViolationSeverity::Warning && v.rule_id == rule),
3235 "{level:?}: evidence-free protocol must warn with {rule}; got {:?}",
3236 result
3237 .violations
3238 .iter()
3239 .map(|v| (v.rule_id, v.message.clone()))
3240 .collect::<Vec<_>>()
3241 );
3242 }
3243
3244 let ok = make_protocol_sbom("1.3", "TLS_AES_256_GCM_SHA384", &[]);
3247 for level in [ComplianceLevel::Cnsa2, ComplianceLevel::NistPqc] {
3248 let result = ComplianceChecker::new(level).check(&ok);
3249 assert!(
3250 !result
3251 .violations
3252 .iter()
3253 .any(|v| v.rule_id.ends_with("PROTO-UNKNOWN")),
3254 "{level:?}: evaluable protocol must not warn"
3255 );
3256 }
3257 }
3258
3259 #[test]
3264 fn ikev2_opaque_transform_refs_warn_both_standards() {
3265 use crate::model::{
3266 ComponentType, CryptoAssetType, CryptoProperties, Ikev2TransformTypes,
3267 ProtocolProperties, ProtocolType,
3268 };
3269 let mut sbom = NormalizedSbom::default();
3270 let mut c = crate::model::Component::new("ipsec-tunnel".into(), "proto-1".into());
3271 c.component_type = ComponentType::Cryptographic;
3272 c.crypto_properties = Some(
3273 CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
3274 ProtocolProperties::new(ProtocolType::Ikev2).with_ikev2_transform_types(
3275 Ikev2TransformTypes {
3276 encr: vec!["transform-encr-7".into()],
3277 prf: vec!["transform-prf-3".into()],
3278 integ: vec!["transform-integ-2".into()],
3279 ke: vec!["transform-ke-9".into()],
3280 },
3281 ),
3282 ),
3283 );
3284 sbom.add_component(c);
3285
3286 for (level, rule) in [
3287 (ComplianceLevel::Cnsa2, "SBOM-CNSA2-PROTO-UNKNOWN"),
3288 (ComplianceLevel::NistPqc, "SBOM-PQC-PROTO-UNKNOWN"),
3289 ] {
3290 let result = ComplianceChecker::new(level).check(&sbom);
3291 assert!(
3292 result.violations.iter().any(|v| {
3293 v.severity == ViolationSeverity::Warning
3294 && v.rule_id == rule
3295 && v.message.contains("transform-encr-7")
3296 }),
3297 "{level:?}: opaque IKEv2 transforms must warn with {rule}; got {:?}",
3298 result
3299 .violations
3300 .iter()
3301 .map(|v| (v.rule_id, v.message.clone()))
3302 .collect::<Vec<_>>()
3303 );
3304 }
3305 }
3306
3307 #[test]
3312 fn tls_version_spellings_parse_tolerantly() {
3313 for version in ["TLSv1.3", "tls1.3", "1.3.0", " 1.3", "v1.3"] {
3314 let sbom = make_protocol_sbom(version, "TLS_AES_256_GCM_SHA384", &[]);
3315 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3316 assert!(
3317 !cnsa
3318 .violations
3319 .iter()
3320 .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
3321 "'{version}' must count as TLS 1.3; got {:?}",
3322 cnsa.violations
3323 .iter()
3324 .map(|v| v.message.clone())
3325 .collect::<Vec<_>>()
3326 );
3327 }
3328 let sbom = make_protocol_sbom("TLSv1.0", "TLS_AES_256_GCM_SHA384", &[]);
3330 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3331 assert!(
3332 cnsa.violations
3333 .iter()
3334 .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
3335 "TLSv1.0 must fail the CNSA 2.0 version gate"
3336 );
3337 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3338 assert!(
3339 pqc.violations
3340 .iter()
3341 .any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
3342 "TLSv1.0 must fail the PQC minimum-version gate (previously silent)"
3343 );
3344 let sbom = make_protocol_sbom("quantum-safe", "TLS_AES_256_GCM_SHA384", &[]);
3347 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3348 assert!(
3349 cnsa.violations
3350 .iter()
3351 .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
3352 "an unparseable version cannot affirm TLS 1.3"
3353 );
3354 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3355 assert!(
3356 pqc.violations
3357 .iter()
3358 .any(|v| v.severity == ViolationSeverity::Warning
3359 && v.rule_id == "SBOM-PQC-PROTO-UNKNOWN"
3360 && v.message.contains("quantum-safe")),
3361 "an unparseable version must be a cannot-verify Warning under PQC"
3362 );
3363 }
3364
3365 #[test]
3371 fn protocol_ref_resolution_keeps_declared_security_level() {
3372 use crate::model::{
3373 AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
3374 ProtocolProperties, ProtocolType,
3375 };
3376 let make = |classical_level: u32| {
3377 let mut sbom = NormalizedSbom::default();
3378 let mut aes = crate::model::Component::new("aes-gcm-cipher".into(), "algo-aes".into());
3379 aes.component_type = ComponentType::Cryptographic;
3380 aes.crypto_properties = Some(
3381 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
3382 AlgorithmProperties::new(CryptoPrimitive::Ae)
3383 .with_algorithm_family("AES".into())
3384 .with_classical_security_level(classical_level)
3385 .with_nist_quantum_security_level(5),
3386 ),
3387 );
3388 sbom.add_component(aes);
3389 let mut proto = crate::model::Component::new("tls-endpoint".into(), "proto-1".into());
3390 proto.component_type = ComponentType::Cryptographic;
3391 proto.crypto_properties = Some(
3392 CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
3393 ProtocolProperties::new(ProtocolType::Tls)
3394 .with_version("1.3".into())
3395 .with_crypto_ref_array(vec!["algo-aes".into()]),
3396 ),
3397 );
3398 sbom.add_component(proto);
3399 sbom
3400 };
3401
3402 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&make(256));
3405 assert!(
3406 !result
3407 .violations
3408 .iter()
3409 .any(|v| v.severity == ViolationSeverity::Error),
3410 "AES with classicalSecurityLevel=256 must pass via ref too; got {:?}",
3411 result
3412 .violations
3413 .iter()
3414 .map(|v| (v.rule_id, v.message.clone()))
3415 .collect::<Vec<_>>()
3416 );
3417
3418 let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&make(128));
3421 assert!(
3422 result
3423 .violations
3424 .iter()
3425 .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-002" && v.message.contains("AES")),
3426 "AES-128 referenced from a protocol must still fire PROTO-002"
3427 );
3428 }
3429
3430 #[test]
3434 fn national_algorithms_fail_both_standards() {
3435 for family in ["SM2", "GOST R 34.10", "brainpoolP256r1"] {
3436 let sbom = make_crypto_sbom(&[("national-sig", family, None, None)]);
3437 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3438 assert!(
3439 pqc.violations
3440 .iter()
3441 .any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-001"),
3442 "{family} must fire the quantum-vulnerable rule; got {:?}",
3443 pqc.violations
3444 .iter()
3445 .map(|v| (v.rule_id, v.message.clone()))
3446 .collect::<Vec<_>>()
3447 );
3448 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3449 assert!(
3450 cnsa.violations
3451 .iter()
3452 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-006"),
3453 "{family} must fail the CNSA 2.0 allowlist as quantum-vulnerable"
3454 );
3455 }
3456 for family in ["SM4", "Streebog"] {
3459 let sbom = make_crypto_sbom(&[("national-prim", family, None, None)]);
3460 let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3461 assert!(
3462 cnsa.violations
3463 .iter()
3464 .any(|v| v.rule_id == "SBOM-CNSA2-ALG-008"),
3465 "{family} must be recognized as not CNSA 2.0-approved"
3466 );
3467 let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3468 assert!(
3469 !pqc.violations
3470 .iter()
3471 .any(|v| v.rule_id == "SBOM-PQC-001" || v.rule_id == "SBOM-PQC-005"),
3472 "{family} must not be reported broken or quantum-vulnerable"
3473 );
3474 }
3475 }
3476
3477 fn refs_for(rule_id: &'static str) -> Vec<StandardRef> {
3478 let v = Violation {
3479 severity: ViolationSeverity::Warning,
3480 category: ViolationCategory::DocumentMetadata,
3481 message: String::new(),
3482 element: None,
3483 requirement: String::new(),
3484 rule_id,
3485 component_id: None,
3486 counts: None,
3487 standard_refs: Vec::new(),
3488 };
3489 v.registry_standard_refs()
3490 }
3491
3492 #[test]
3493 fn registry_refs_for_machine_readable_include_annex_and_pren() {
3494 let refs = refs_for("SBOM-CRA-MACHINE-READABLE");
3495 assert!(
3496 refs.iter()
3497 .any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II (1)"),
3498 "expected CRA Annex I Part II (1); got {refs:?}"
3499 );
3500 assert!(
3501 refs.iter()
3502 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-04"),
3503 "expected prEN PRE-7-RQ-04; got {refs:?}"
3504 );
3505 }
3506
3507 #[test]
3508 fn registry_refs_for_annex_i_identifier_include_pren_07() {
3509 let refs = refs_for("SBOM-CRA-ANNEX-I-IDENTIFIER");
3510 assert!(
3511 refs.iter()
3512 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07"),
3513 "expected PRE-7-RQ-07; got {refs:?}"
3514 );
3515 let pren_count = refs
3516 .iter()
3517 .filter(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07")
3518 .count();
3519 assert_eq!(pren_count, 1, "PRE-7-RQ-07 should appear exactly once");
3520 }
3521
3522 #[test]
3523 fn registry_refs_for_supply_chain_include_annex_and_pren() {
3524 let refs = refs_for("SBOM-CRA-ANNEX-I-SUPPLY-CHAIN");
3525 assert!(
3526 refs.iter()
3527 .any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II"),
3528 "expected Annex I Part II; got {refs:?}"
3529 );
3530 assert!(
3531 refs.iter()
3532 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-01"),
3533 "expected PRE-7-RQ-01; got {refs:?}"
3534 );
3535 assert!(
3536 refs.iter()
3537 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-03"),
3538 "expected PRE-7-RQ-03; got {refs:?}"
3539 );
3540 }
3541
3542 #[test]
3543 fn registry_refs_for_cvd_policy_include_annex_and_pren_rls() {
3544 let refs = refs_for("SBOM-CRA-CVD-POLICY");
3545 assert!(
3546 refs.iter()
3547 .any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II (5)"),
3548 "expected CRA Annex I Part II (5); got {refs:?}"
3549 );
3550 assert!(
3551 refs.iter()
3552 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "RLS-2-RQ-03-RE"),
3553 "expected RLS-2-RQ-03-RE; got {refs:?}"
3554 );
3555 }
3556
3557 #[test]
3558 fn registry_refs_for_ssdf_ps2() {
3559 let refs = refs_for("SBOM-SSDF-PS2");
3560 assert!(
3561 refs.iter()
3562 .any(|r| r.standard == StandardKind::NistSsdf && r.id == "PS.2"),
3563 "expected NIST SSDF PS.2; got {refs:?}"
3564 );
3565 }
3566
3567 #[test]
3571 fn every_emitted_violation_has_a_registered_rule_id() {
3572 let sbom = NormalizedSbom::default();
3573 for level in ComplianceLevel::all() {
3574 let result = ComplianceChecker::new(*level).check(&sbom);
3575 for v in &result.violations {
3576 assert!(
3577 rule_meta(v.rule_id).is_some(),
3578 "level {level:?}: violation {:?} has unregistered rule_id {:?}",
3579 v.requirement,
3580 v.rule_id
3581 );
3582 }
3583 }
3584 }
3585
3586 #[test]
3587 fn check_populates_standard_refs_for_cra_violations() {
3588 let sbom = NormalizedSbom::default();
3589 let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
3590 let result = checker.check(&sbom);
3591 let cra_violations: Vec<_> = result
3592 .violations
3593 .iter()
3594 .filter(|v| v.requirement.to_lowercase().contains("cra"))
3595 .collect();
3596 assert!(
3597 !cra_violations.is_empty(),
3598 "empty SBOM should produce some CRA violations"
3599 );
3600 for v in &cra_violations {
3601 assert!(
3602 !v.standard_refs.is_empty(),
3603 "CRA violation {:?} should have standard_refs populated",
3604 v.requirement
3605 );
3606 }
3607 }
3608
3609 #[test]
3610 fn sidecar_supplies_security_contact_downgrades_art_13_17() {
3611 use crate::model::CraSidecarMetadata;
3612 let sbom = NormalizedSbom::default();
3613
3614 let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3616 let art_13_17_warning = bare.violations.iter().find(|v| {
3617 v.requirement.contains("Art. 13(17)") && v.severity == ViolationSeverity::Warning
3618 });
3619 assert!(
3620 art_13_17_warning.is_some(),
3621 "Without sidecar, Art. 13(17) should be a Warning"
3622 );
3623
3624 let sidecar = CraSidecarMetadata {
3626 security_contact: Some("security@example.com".to_string()),
3627 ..Default::default()
3628 };
3629 let withsc = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3630 .with_sidecar(sidecar)
3631 .check(&sbom);
3632 let art_13_17_info = withsc.violations.iter().find(|v| {
3633 v.requirement.contains("Art. 13(17)") && v.severity == ViolationSeverity::Info
3634 });
3635 assert!(
3636 art_13_17_info.is_some(),
3637 "With sidecar, Art. 13(17) should be downgraded to Info"
3638 );
3639 assert!(
3640 !withsc
3641 .violations
3642 .iter()
3643 .any(|v| v.requirement.contains("Art. 13(17)")
3644 && v.severity == ViolationSeverity::Warning),
3645 "With sidecar, no Warning-level Art. 13(17) violation should remain"
3646 );
3647 }
3648
3649 #[test]
3650 fn sidecar_supplies_product_name_downgrades_art_13_15() {
3651 use crate::model::CraSidecarMetadata;
3652 let sbom = NormalizedSbom::default(); let sidecar = CraSidecarMetadata {
3655 product_name: Some("Demo Product".to_string()),
3656 ..Default::default()
3657 };
3658 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3659 .with_sidecar(sidecar)
3660 .check(&sbom);
3661 let downgraded = result.violations.iter().find(|v| {
3662 v.requirement.contains("Art. 13(15)") && v.severity == ViolationSeverity::Info
3663 });
3664 assert!(
3665 downgraded.is_some(),
3666 "Sidecar product_name should downgrade Art. 13(15) to Info"
3667 );
3668 }
3669
3670 #[test]
3671 fn sidecar_supplies_manufacturer_downgrades_art_13_16() {
3672 use crate::model::CraSidecarMetadata;
3673 let sbom = NormalizedSbom::default();
3674 let sidecar = CraSidecarMetadata {
3675 manufacturer_name: Some("Demo Corp".to_string()),
3676 ..Default::default()
3677 };
3678 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3679 .with_sidecar(sidecar)
3680 .check(&sbom);
3681 let downgraded = result.violations.iter().find(|v| {
3682 v.requirement.contains("Art. 13(16)") && v.severity == ViolationSeverity::Info
3683 });
3684 assert!(
3685 downgraded.is_some(),
3686 "Sidecar manufacturer_name should downgrade Art. 13(16) to Info"
3687 );
3688 }
3689
3690 #[test]
3691 fn sidecar_supplies_cvd_url_downgrades_cvd_policy() {
3692 use crate::model::CraSidecarMetadata;
3693 let sbom = NormalizedSbom::default();
3694 let sidecar = CraSidecarMetadata {
3695 vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
3696 ..Default::default()
3697 };
3698 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3699 .with_sidecar(sidecar)
3700 .check(&sbom);
3701 let downgraded = result.violations.iter().find(|v| {
3702 v.requirement.contains("Annex I Part II (5)") && v.severity == ViolationSeverity::Info
3703 });
3704 assert!(
3705 downgraded.is_some(),
3706 "Sidecar CVD URL should downgrade the Annex I Part II (5) CVD-policy finding to Info"
3707 );
3708 }
3709
3710 fn vendor_component(name: &str, with_hash: bool) -> crate::model::Component {
3711 use crate::model::{Component, Hash, HashAlgorithm, Organization};
3712 let mut c = Component::new(name.to_string(), name.to_string())
3713 .with_purl(format!("pkg:cargo/{name}@1.0.0"));
3714 c.supplier = Some(Organization::new("VendorCorp".to_string()));
3715 if with_hash {
3716 c.hashes.push(Hash::new(
3717 HashAlgorithm::Sha256,
3718 "0000000000000000000000000000000000000000000000000000000000000000".to_string(),
3719 ));
3720 }
3721 c
3722 }
3723
3724 fn hw_component(
3725 name: &str,
3726 kind: crate::model::ComponentType,
3727 with_purl: bool,
3728 with_supplier: bool,
3729 version: Option<&str>,
3730 ) -> crate::model::Component {
3731 use crate::model::{Component, Organization};
3732 let mut c = Component::new(name.to_string(), name.to_string());
3733 c.component_type = kind;
3734 if with_purl {
3735 c = c.with_purl(format!("pkg:generic/{name}"));
3736 }
3737 if with_supplier {
3738 c.supplier = Some(Organization::new("HardwareCorp".to_string()));
3739 }
3740 if let Some(v) = version {
3741 c = c.with_version(v.to_string());
3742 }
3743 c
3744 }
3745
3746 #[test]
3747 fn hardware_check_skipped_for_software_only_sbom() {
3748 let mut sbom = NormalizedSbom::default();
3749 let c = vendor_component("software", true);
3750 sbom.components.insert(c.canonical_id.clone(), c);
3751 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3752 assert!(
3753 !result
3754 .violations
3755 .iter()
3756 .any(|v| v.requirement.contains("PRE-8-RQ-02")),
3757 "Software-only SBOM should produce no PRE-8-RQ-02 violations"
3758 );
3759 }
3760
3761 #[test]
3762 fn hardware_check_passes_for_complete_firmware() {
3763 use crate::model::ComponentType;
3764 let mut sbom = NormalizedSbom::default();
3765 let c = hw_component(
3766 "router-fw",
3767 ComponentType::Firmware,
3768 true,
3769 true,
3770 Some("1.2.3"),
3771 );
3772 sbom.components.insert(c.canonical_id.clone(), c);
3773 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3774 assert!(
3775 !result
3776 .violations
3777 .iter()
3778 .any(|v| v.requirement.contains("PRE-8-RQ-02")),
3779 "Complete firmware component should pass [PRE-8-RQ-02]"
3780 );
3781 }
3782
3783 #[test]
3784 fn hardware_check_flags_firmware_without_version() {
3785 use crate::model::ComponentType;
3786 let mut sbom = NormalizedSbom::default();
3787 let c = hw_component("router-fw", ComponentType::Firmware, true, true, None);
3788 sbom.components.insert(c.canonical_id.clone(), c);
3789 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3790 assert!(
3791 result.violations.iter().any(|v| {
3792 v.requirement.contains("Firmware version") && v.severity == ViolationSeverity::Error
3793 }),
3794 "Firmware without version should produce an Error"
3795 );
3796 }
3797
3798 #[test]
3799 fn hardware_check_flags_missing_producer() {
3800 use crate::model::ComponentType;
3801 let mut sbom = NormalizedSbom::default();
3802 let c = hw_component("router", ComponentType::Device, true, false, Some("1.0"));
3803 sbom.components.insert(c.canonical_id.clone(), c);
3804 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3805 assert!(
3806 result.violations.iter().any(|v| {
3807 v.requirement.contains("Hardware producer")
3808 && v.severity == ViolationSeverity::Error
3809 }),
3810 "Hardware without producer should produce an Error"
3811 );
3812 }
3813
3814 #[test]
3815 fn hardware_check_flags_synthetic_identifier() {
3816 use crate::model::{Component, ComponentType, Organization};
3817 let mut sbom = NormalizedSbom::default();
3818 let mut c = Component::new("router".to_string(), "router".to_string())
3819 .with_version("1.0".to_string());
3820 c.component_type = ComponentType::Device;
3821 c.supplier = Some(Organization::new("HardwareCorp".to_string()));
3822 sbom.components.insert(c.canonical_id.clone(), c);
3824 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3825 assert!(
3826 result.violations.iter().any(|v| {
3827 v.requirement.contains("Hardware identifier")
3828 && v.severity == ViolationSeverity::Error
3829 }),
3830 "Hardware with synthetic ID should produce an Error"
3831 );
3832 }
3833
3834 #[test]
3835 fn hardware_check_device_with_firmware_dep_passes() {
3836 use crate::model::{ComponentType, DependencyEdge, DependencyType};
3837 let mut sbom = NormalizedSbom::default();
3838 let device = hw_component("router", ComponentType::Device, true, true, None);
3839 let firmware = hw_component(
3840 "router-fw",
3841 ComponentType::Firmware,
3842 true,
3843 true,
3844 Some("1.2.3"),
3845 );
3846 let device_id = device.canonical_id.clone();
3847 let firmware_id = firmware.canonical_id.clone();
3848 sbom.components.insert(device_id.clone(), device);
3849 sbom.components.insert(firmware_id.clone(), firmware);
3850 sbom.edges.push(DependencyEdge::new(
3851 device_id,
3852 firmware_id,
3853 DependencyType::DependsOn,
3854 ));
3855 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3856 assert!(
3857 !result
3858 .violations
3859 .iter()
3860 .any(|v| { v.requirement.contains("Device firmware association") }),
3861 "Device with firmware dependency should not trigger version warning"
3862 );
3863 }
3864
3865 #[test]
3866 fn vendor_hash_coverage_full() {
3867 use crate::quality::HashQualityMetrics;
3868 let mut sbom = NormalizedSbom::default();
3869 for n in ["a", "b", "c", "d", "e"] {
3870 let c = vendor_component(n, true);
3871 sbom.components.insert(c.canonical_id.clone(), c);
3872 }
3873 let m = HashQualityMetrics::from_sbom(&sbom);
3874 assert_eq!(m.vendor_components_total, 5);
3875 assert_eq!(m.vendor_components_with_hash, 5);
3876 assert_eq!(m.vendor_hash_coverage(), Some(1.0));
3877 }
3878
3879 #[test]
3880 fn vendor_hash_coverage_partial_triggers_warning() {
3881 let mut sbom = NormalizedSbom::default();
3882 for n in ["a", "b", "c", "d", "e", "f", "g"] {
3884 let c = vendor_component(n, true);
3885 sbom.components.insert(c.canonical_id.clone(), c);
3886 }
3887 for n in ["h", "i", "j"] {
3888 let c = vendor_component(n, false);
3889 sbom.components.insert(c.canonical_id.clone(), c);
3890 }
3891 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3892 let v = result.violations.iter().find(|v| {
3893 v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Warning
3894 });
3895 assert!(
3896 v.is_some(),
3897 "70% vendor-hash coverage should produce a Warning under CraPhase2"
3898 );
3899 }
3900
3901 #[test]
3902 fn vendor_hash_coverage_below_50_triggers_error() {
3903 let mut sbom = NormalizedSbom::default();
3904 for n in ["a", "b", "c", "d"] {
3906 let c = vendor_component(n, true);
3907 sbom.components.insert(c.canonical_id.clone(), c);
3908 }
3909 for n in ["e", "f", "g", "h", "i", "j"] {
3910 let c = vendor_component(n, false);
3911 sbom.components.insert(c.canonical_id.clone(), c);
3912 }
3913 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3914 let v = result.violations.iter().find(|v| {
3915 v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Error
3916 });
3917 assert!(
3918 v.is_some(),
3919 "40% vendor-hash coverage should produce an Error under CraPhase2"
3920 );
3921 }
3922
3923 #[test]
3924 fn vendor_hash_coverage_no_vendor_components_no_violation() {
3925 let mut sbom = NormalizedSbom::default();
3927 use crate::model::Component;
3928 for n in ["a", "b", "c"] {
3929 let c = Component::new(n.to_string(), n.to_string());
3930 sbom.components.insert(c.canonical_id.clone(), c);
3931 }
3932 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3933 assert!(
3934 !result
3935 .violations
3936 .iter()
3937 .any(|v| v.requirement.contains("PRE-7-RQ-07-RE")),
3938 "No vendor components → no [PRE-7-RQ-07-RE] violation"
3939 );
3940 }
3941
3942 #[test]
3947 fn art_13_2_warns_when_no_risk_assessment_referenced() {
3948 let sbom = NormalizedSbom::default();
3949 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3950 let v = result.violations.iter().find(|v| {
3951 v.requirement.contains("Art. 13(2)") && v.severity == ViolationSeverity::Warning
3952 });
3953 assert!(v.is_some(), "Empty SBOM should produce Art. 13(2) Warning");
3954 }
3955
3956 #[test]
3957 fn art_13_2_silenced_by_sidecar_risk_assessment_url() {
3958 use crate::model::CraSidecarMetadata;
3959 let sbom = NormalizedSbom::default();
3960 let sidecar = CraSidecarMetadata {
3961 risk_assessment_url: Some("https://example.com/ra.pdf".to_string()),
3962 ..Default::default()
3963 };
3964 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3965 .with_sidecar(sidecar)
3966 .check(&sbom);
3967 assert!(
3968 !result
3969 .violations
3970 .iter()
3971 .any(|v| v.requirement.contains("Art. 13(2)")),
3972 "Sidecar risk_assessment_url should suppress Art. 13(2) violation"
3973 );
3974 }
3975
3976 #[test]
3977 fn article_14_pre_deadline_emits_info_only() {
3978 let sbom = NormalizedSbom::default();
3983 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3984 let art14_count = result
3985 .violations
3986 .iter()
3987 .filter(|v| v.requirement.contains("Art. 14"))
3988 .count();
3989 assert!(
3990 art14_count >= 4,
3991 "Art. 14 readiness should produce ≥4 violations (PSIRT, 14(1), 14(2), 14(7)); got {art14_count}"
3992 );
3993 }
3994
3995 #[test]
3999 fn article_14_pre_deadline_mocked_clock_emits_4_infos() {
4000 let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
4001 let mut violations = Vec::new();
4002 let now = chrono::DateTime::parse_from_rfc3339("2026-04-26T00:00:00Z")
4003 .unwrap()
4004 .with_timezone(&chrono::Utc);
4005 checker.check_article_14_readiness_at(now, &mut violations);
4006
4007 let infos = violations
4008 .iter()
4009 .filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
4010 .count();
4011 let warnings = violations
4012 .iter()
4013 .filter(|v| {
4014 v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
4015 })
4016 .count();
4017 assert_eq!(
4018 infos, 4,
4019 "Pre-deadline expects 4 Info-level Art. 14 findings; got {infos} (full list: {violations:?})"
4020 );
4021 assert_eq!(
4022 warnings, 0,
4023 "Pre-deadline expects 0 Warning-level Art. 14 findings"
4024 );
4025 }
4026
4027 #[test]
4031 fn article_14_post_deadline_mocked_clock_emits_3_warnings_1_info() {
4032 let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
4033 let mut violations = Vec::new();
4034 let now = chrono::DateTime::parse_from_rfc3339("2026-12-01T00:00:00Z")
4035 .unwrap()
4036 .with_timezone(&chrono::Utc);
4037 checker.check_article_14_readiness_at(now, &mut violations);
4038
4039 let infos = violations
4040 .iter()
4041 .filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
4042 .count();
4043 let warnings = violations
4044 .iter()
4045 .filter(|v| {
4046 v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
4047 })
4048 .count();
4049 assert_eq!(
4050 warnings, 3,
4051 "Post-deadline expects 3 Warning-level Art. 14 findings (PSIRT/14(1)/14(2)); got {warnings} (full: {violations:?})"
4052 );
4053 assert_eq!(
4054 infos, 1,
4055 "Post-deadline expects 1 Info-level Art. 14 finding (Art. 14(7) ENISA platform stays Info regardless of date)"
4056 );
4057 }
4058
4059 #[test]
4060 fn article_14_sidecar_suppresses_psirt_warning() {
4061 use crate::model::CraSidecarMetadata;
4062 let sbom = NormalizedSbom::default();
4063 let sidecar = CraSidecarMetadata {
4064 psirt_url: Some("https://example.com/psirt".to_string()),
4065 early_warning_contact: Some("psirt@example.com".to_string()),
4066 incident_report_contact: Some("ir@example.com".to_string()),
4067 ..Default::default()
4068 };
4069 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
4070 .with_sidecar(sidecar)
4071 .check(&sbom);
4072 let art_14_psirt = result
4074 .violations
4075 .iter()
4076 .any(|v| v.requirement.contains("Art. 14: PSIRT"));
4077 let art_14_1 = result
4078 .violations
4079 .iter()
4080 .any(|v| v.requirement.contains("Art. 14(1)"));
4081 let art_14_2 = result
4082 .violations
4083 .iter()
4084 .any(|v| v.requirement.contains("Art. 14(2)"));
4085 assert!(
4086 !art_14_psirt,
4087 "Sidecar psirt_url should suppress PSIRT check"
4088 );
4089 assert!(
4090 !art_14_1,
4091 "Sidecar early_warning_contact should suppress 14(1)"
4092 );
4093 assert!(
4094 !art_14_2,
4095 "Sidecar incident_report_contact should suppress 14(2)"
4096 );
4097 }
4098
4099 #[test]
4100 fn direct_dep_missing_supplier_is_error_under_cra_phase2() {
4101 use crate::model::{Component, DependencyEdge, DependencyType};
4102 let mut sbom = NormalizedSbom::default();
4103 let app = Component::new("app".to_string(), "app".to_string())
4105 .with_purl("pkg:cargo/app@1.0".to_string());
4106 let lib = Component::new("lib".to_string(), "lib".to_string())
4107 .with_purl("pkg:cargo/lib@1.0".to_string());
4108 let app_id = app.canonical_id.clone();
4109 let lib_id = lib.canonical_id.clone();
4110 sbom.primary_component_id = Some(app_id.clone());
4111 sbom.components.insert(app_id.clone(), app);
4112 sbom.components.insert(lib_id.clone(), lib);
4113 sbom.edges.push(DependencyEdge::new(
4114 app_id,
4115 lib_id,
4116 DependencyType::DependsOn,
4117 ));
4118 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
4119 let v = result.violations.iter().find(|v| {
4120 v.requirement.contains("Direct dependency supplier")
4121 && v.severity == ViolationSeverity::Error
4122 });
4123 assert!(
4124 v.is_some(),
4125 "Direct dep without supplier should produce an Error under CraPhase2"
4126 );
4127 }
4128
4129 #[test]
4130 fn transitive_dep_missing_supplier_is_softer_than_direct() {
4131 use crate::model::{Component, DependencyEdge, DependencyType, Organization};
4132 let mut sbom = NormalizedSbom::default();
4133 let mut app = Component::new("app".to_string(), "app".to_string())
4135 .with_purl("pkg:cargo/app@1.0".to_string());
4136 app.supplier = Some(Organization::new("AppCorp".to_string()));
4137 let mut lib = Component::new("lib".to_string(), "lib".to_string())
4138 .with_purl("pkg:cargo/lib@1.0".to_string());
4139 lib.supplier = Some(Organization::new("LibCorp".to_string()));
4140 let deep = Component::new("deep".to_string(), "deep".to_string())
4141 .with_purl("pkg:cargo/deep@1.0".to_string());
4142 let app_id = app.canonical_id.clone();
4143 let lib_id = lib.canonical_id.clone();
4144 let deep_id = deep.canonical_id.clone();
4145 sbom.primary_component_id = Some(app_id.clone());
4146 sbom.components.insert(app_id.clone(), app);
4147 sbom.components.insert(lib_id.clone(), lib);
4148 sbom.components.insert(deep_id.clone(), deep);
4149 sbom.edges.push(DependencyEdge::new(
4150 app_id,
4151 lib_id.clone(),
4152 DependencyType::DependsOn,
4153 ));
4154 sbom.edges.push(DependencyEdge::new(
4155 lib_id,
4156 deep_id,
4157 DependencyType::DependsOn,
4158 ));
4159 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
4160 let direct_err = result.violations.iter().any(|v| {
4161 v.requirement.contains("Direct dependency supplier")
4162 && v.severity == ViolationSeverity::Error
4163 });
4164 let transitive = result
4165 .violations
4166 .iter()
4167 .find(|v| v.requirement.contains("Transitive dependency supplier"));
4168 assert!(
4169 !direct_err,
4170 "No direct deps lack a supplier; should not error"
4171 );
4172 assert!(transitive.is_some(), "Transitive dep should be reported");
4173 assert_ne!(
4174 transitive.unwrap().severity,
4175 ViolationSeverity::Error,
4176 "Transitive supplier missing should never be Error (it's recommended, not mandatory)"
4177 );
4178 }
4179
4180 fn bsi_ok_component(name: &str) -> crate::model::Component {
4183 use crate::model::{Component, Hash, HashAlgorithm, LicenseExpression, Organization};
4184 let mut c = Component::new(name.to_string(), name.to_string())
4185 .with_purl(format!("pkg:cargo/{name}@1.0"))
4186 .with_version("1.0".to_string());
4187 c.hashes
4188 .push(Hash::new(HashAlgorithm::Sha512, "f".repeat(128)));
4189 c.supplier = Some(Organization::new(format!("{name}-vendor")));
4190 c.licenses
4191 .add_declared(LicenseExpression::new("MIT".to_string()));
4192 c
4193 }
4194
4195 #[test]
4196 fn bsi_tr_03183_2_empty_sbom_emits_errors() {
4197 let sbom = NormalizedSbom::default();
4198 let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4199 assert!(
4200 result
4201 .violations
4202 .iter()
4203 .any(|v| v.requirement.contains("BSI TR-03183-2 §5.2.1")
4204 && v.severity == ViolationSeverity::Error),
4205 "Empty SBOM should fail BSI §5.2.1 (creator missing)"
4206 );
4207 }
4208
4209 #[test]
4214 fn bsi_tr_03183_2_requires_sha512_hash() {
4215 use crate::model::{Hash, HashAlgorithm};
4216 let check = |alg: Option<HashAlgorithm>, hexlen: usize| {
4217 let mut sbom = NormalizedSbom::default();
4218 let mut c = bsi_ok_component("lib");
4219 c.hashes.clear();
4220 if let Some(alg) = alg {
4221 c.hashes.push(Hash::new(alg, "0".repeat(hexlen)));
4222 }
4223 sbom.add_component(c);
4224 ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom)
4225 };
4226
4227 let md5 = check(Some(HashAlgorithm::Md5), 32);
4228 assert!(
4229 md5.violations
4230 .iter()
4231 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
4232 && v.severity == ViolationSeverity::Error),
4233 "MD5-only component must fail the §5.2.2 SHA-512 requirement"
4234 );
4235
4236 let sha256 = check(Some(HashAlgorithm::Sha256), 64);
4237 assert!(
4238 sha256
4239 .violations
4240 .iter()
4241 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
4242 && v.severity == ViolationSeverity::Error),
4243 "SHA-256-only component must now FAIL the hash rule (§5.2.2 names SHA-512)"
4244 );
4245
4246 let sha512 = check(Some(HashAlgorithm::Sha512), 128);
4247 assert!(
4248 !sha512
4249 .violations
4250 .iter()
4251 .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-4")),
4252 "SHA-512 component must satisfy the hash rule"
4253 );
4254
4255 let none = check(None, 0);
4256 assert!(
4257 none.violations
4258 .iter()
4259 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4-MISSING"
4260 && v.severity == ViolationSeverity::Warning),
4261 "hash-less component must warn (§3.2.1 legitimate-omission escape)"
4262 );
4263 assert!(
4264 !none
4265 .violations
4266 .iter()
4267 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"),
4268 "hash-less component must not also fire the wrong-algorithm Error"
4269 );
4270 }
4271
4272 #[test]
4276 fn bsi_tr_03183_2_format_gate() {
4277 use crate::model::SbomFormat;
4278 let check = |format: SbomFormat, version: &str| {
4279 let mut sbom = NormalizedSbom::default();
4280 sbom.document.format = format;
4281 sbom.document.spec_version = version.to_string();
4282 sbom.add_component(bsi_ok_component("lib"));
4283 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4284 r.violations.iter().any(|v| {
4285 v.rule_id == "SBOM-BSI-TR-03183-2-4" && v.severity == ViolationSeverity::Error
4286 })
4287 };
4288
4289 assert!(
4290 check(SbomFormat::CycloneDx, "1.5"),
4291 "CycloneDX 1.5 must fail the §4 format gate"
4292 );
4293 assert!(
4294 !check(SbomFormat::CycloneDx, "1.6"),
4295 "CycloneDX 1.6 must pass the §4 format gate"
4296 );
4297 assert!(
4298 !check(SbomFormat::CycloneDx, "1.7"),
4299 "CycloneDX 1.7 must pass the §4 format gate"
4300 );
4301 assert!(
4302 check(SbomFormat::Spdx, "2.3"),
4303 "SPDX 2.3 must fail the §4 format gate"
4304 );
4305 assert!(
4306 check(SbomFormat::Spdx, "3.0"),
4307 "SPDX 3.0 is below the 3.0.1 minimum and must fail the §4 gate"
4308 );
4309 assert!(
4310 !check(SbomFormat::Spdx, "3.0.1"),
4311 "SPDX 3.0.1 must pass the §4 format gate"
4312 );
4313 assert!(
4314 !check(SbomFormat::CycloneDx, ""),
4315 "a document with no spec_version must skip the §4 gate"
4316 );
4317 }
4318
4319 #[test]
4323 fn bsi_tr_03183_2_does_not_require_tool_creator() {
4324 use crate::model::{Creator, CreatorType};
4325 let mut sbom = NormalizedSbom::default();
4326 sbom.document.creators.push(Creator {
4327 creator_type: CreatorType::Person,
4328 name: "Jane Doe".to_string(),
4329 email: Some("jane@example.org".to_string()),
4330 });
4331 sbom.add_component(bsi_ok_component("lib"));
4332 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4333 assert!(
4334 !r.violations.iter().any(|v| v.message.contains("tool")),
4335 "no violation may demand a generation tool (not mandated in any TR tier)"
4336 );
4337 assert!(
4338 !r.violations
4339 .iter()
4340 .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
4341 "a Person creator with an email satisfies §5.2.1 entirely"
4342 );
4343 }
4344
4345 #[test]
4348 fn bsi_tr_03183_2_creator_contact_granularity() {
4349 use crate::model::{Creator, CreatorType};
4350 let base = || {
4351 let mut sbom = NormalizedSbom::default();
4352 sbom.add_component(bsi_ok_component("lib"));
4353 sbom
4354 };
4355
4356 let empty = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&base());
4357 assert!(
4358 empty
4359 .violations
4360 .iter()
4361 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"
4362 && v.severity == ViolationSeverity::Error),
4363 "no creator at all must be a §5.2.1 Error"
4364 );
4365
4366 let mut contactless = base();
4367 contactless.document.creators.push(Creator {
4368 creator_type: CreatorType::Organization,
4369 name: "Acme".to_string(),
4370 email: None,
4371 });
4372 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&contactless);
4373 assert!(
4374 r.violations
4375 .iter()
4376 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1-CONTACT"
4377 && v.severity == ViolationSeverity::Warning),
4378 "a creator without email/URL must be a §5.2.1 Warning"
4379 );
4380 assert!(
4381 !r.violations
4382 .iter()
4383 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"),
4384 "a contactless creator is present — the absence Error must not fire"
4385 );
4386
4387 let mut with_url = base();
4388 with_url.document.creators.push(Creator {
4389 creator_type: CreatorType::Organization,
4390 name: "https://acme.example".to_string(),
4391 email: None,
4392 });
4393 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&with_url);
4394 assert!(
4395 !r.violations
4396 .iter()
4397 .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
4398 "a creator URL satisfies §5.2.1 (URL fallback when no email exists)"
4399 );
4400 }
4401
4402 #[test]
4408 fn bsi_tr_03183_2_tools_only_creators_fail_creator_gate() {
4409 use crate::model::{Creator, CreatorType};
4410 let mut sbom = NormalizedSbom::default();
4411 sbom.document.creators.push(Creator {
4412 creator_type: CreatorType::Tool,
4413 name: "syft 1.18.0".to_string(),
4414 email: None,
4415 });
4416 sbom.add_component(bsi_ok_component("lib"));
4417 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4418 assert!(
4419 r.violations
4420 .iter()
4421 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"
4422 && v.severity == ViolationSeverity::Error),
4423 "a tools-only creators list must fail the §5.2.1 creator gate"
4424 );
4425
4426 sbom.document.creators.push(Creator {
4429 creator_type: CreatorType::Organization,
4430 name: "Demo Corp".to_string(),
4431 email: Some("sbom@demo.example".to_string()),
4432 });
4433 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4434 assert!(
4435 !r.violations
4436 .iter()
4437 .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
4438 "an Organization creator with email satisfies §5.2.1 entirely"
4439 );
4440 }
4441
4442 #[test]
4446 fn bsi_tr_03183_2_contact_warning_ignores_tool_contacts() {
4447 use crate::model::{Creator, CreatorType};
4448 let mut sbom = NormalizedSbom::default();
4449 sbom.document.creators.push(Creator {
4450 creator_type: CreatorType::Tool,
4451 name: "sbom-gen (https://sbom-gen.example)".to_string(),
4452 email: None,
4453 });
4454 sbom.document.creators.push(Creator {
4455 creator_type: CreatorType::Organization,
4456 name: "Acme".to_string(),
4457 email: None,
4458 });
4459 sbom.add_component(bsi_ok_component("lib"));
4460 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4461 assert!(
4462 r.violations
4463 .iter()
4464 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1-CONTACT"
4465 && v.severity == ViolationSeverity::Warning),
4466 "a tool's URL must not satisfy the creator contact requirement"
4467 );
4468 assert!(
4469 !r.violations
4470 .iter()
4471 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"),
4472 "the Organization creator is present — only the contact Warning may fire"
4473 );
4474 }
4475
4476 #[test]
4481 fn bsi_tr_03183_2_sha512_counts_only_authored_hashes() {
4482 use crate::model::{Hash, HashAlgorithm};
4483 let check = |hashes: Vec<Hash>| {
4484 let mut sbom = NormalizedSbom::default();
4485 let mut c = bsi_ok_component("lib");
4486 c.hashes = hashes;
4487 sbom.add_component(c);
4488 ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom)
4489 };
4490
4491 let r = check(vec![
4494 Hash::new(HashAlgorithm::Sha256, "0".repeat(64)),
4495 Hash::enriched(HashAlgorithm::Sha512, "0".repeat(128)),
4496 ]);
4497 assert!(
4498 r.violations
4499 .iter()
4500 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
4501 && v.severity == ViolationSeverity::Error),
4502 "an enriched SHA-512 must not mask a missing authored SHA-512"
4503 );
4504
4505 let r = check(vec![Hash::new(HashAlgorithm::Sha512, "f".repeat(128))]);
4507 assert!(
4508 !r.violations
4509 .iter()
4510 .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-4")),
4511 "an authored SHA-512 satisfies §5.2.2"
4512 );
4513
4514 let r = check(vec![Hash::enriched(HashAlgorithm::Sha512, "0".repeat(128))]);
4517 assert!(
4518 r.violations
4519 .iter()
4520 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4-MISSING"
4521 && v.severity == ViolationSeverity::Warning),
4522 "enriched-only hashes must count as no authored hash at all"
4523 );
4524 assert!(
4525 !r.violations
4526 .iter()
4527 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"),
4528 "enriched-only hashes must not fire the wrong-algorithm Error"
4529 );
4530 }
4531
4532 #[test]
4534 fn bsi_tr_03183_2_gates_on_missing_version() {
4535 let mut sbom = NormalizedSbom::default();
4536 let mut c = bsi_ok_component("lib");
4537 c.version = None;
4538 sbom.add_component(c);
4539 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4540 assert!(
4541 r.violations
4542 .iter()
4543 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-VERSION"
4544 && v.severity == ViolationSeverity::Error),
4545 "a version-less component must fail BSI §5.2.2"
4546 );
4547
4548 let mut ok = NormalizedSbom::default();
4549 ok.add_component(bsi_ok_component("lib"));
4550 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&ok);
4551 assert!(
4552 !r.violations
4553 .iter()
4554 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-VERSION"),
4555 "a versioned component must not fire the version rule"
4556 );
4557 }
4558
4559 #[test]
4562 fn bsi_tr_03183_2_licence_rules() {
4563 use crate::model::LicenseExpression;
4564 let mut unlicensed = NormalizedSbom::default();
4565 let mut c = bsi_ok_component("lib");
4566 c.licenses.declared.clear();
4567 c.licenses.concluded = None;
4568 unlicensed.add_component(c);
4569 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&unlicensed);
4570 assert!(
4571 r.violations
4572 .iter()
4573 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE"
4574 && v.severity == ViolationSeverity::Error),
4575 "a component without distribution licences must fail §5.2.2"
4576 );
4577
4578 let mut non_spdx = NormalizedSbom::default();
4579 let mut c = bsi_ok_component("lib");
4580 c.licenses.declared.clear();
4581 c.licenses.add_declared(LicenseExpression::new(
4582 "Standard Commercial Terms, see LICENSE.txt".to_string(),
4583 ));
4584 non_spdx.add_component(c);
4585 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&non_spdx);
4586 assert!(
4587 r.violations
4588 .iter()
4589 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE-SPDX"
4590 && v.severity == ViolationSeverity::Warning),
4591 "a non-SPDX licence expression must warn under §6.1"
4592 );
4593 assert!(
4594 !r.violations
4595 .iter()
4596 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE"),
4597 "a declared (non-SPDX) licence still counts as present"
4598 );
4599
4600 let mut spdx_ok = NormalizedSbom::default();
4601 spdx_ok.add_component(bsi_ok_component("lib"));
4602 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&spdx_ok);
4603 assert!(
4604 !r.violations
4605 .iter()
4606 .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-LICENSE")),
4607 "an SPDX-named licence satisfies both §5.2.2 and §6.1"
4608 );
4609 }
4610
4611 #[test]
4614 fn bsi_tr_03183_2_warns_on_missing_component_creator() {
4615 let mut sbom = NormalizedSbom::default();
4616 let mut c = bsi_ok_component("lib");
4617 c.supplier = None;
4618 c.author = None;
4619 sbom.add_component(c);
4620 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4621 assert!(
4622 r.violations
4623 .iter()
4624 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-CREATOR"
4625 && v.severity == ViolationSeverity::Warning),
4626 "a component without supplier/author must warn under §5.2.2"
4627 );
4628
4629 let mut ok = NormalizedSbom::default();
4630 ok.add_component(bsi_ok_component("lib"));
4631 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&ok);
4632 assert!(
4633 !r.violations
4634 .iter()
4635 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-CREATOR"),
4636 "a supplied component must not fire the creator rule"
4637 );
4638 }
4639
4640 #[test]
4644 fn bsi_tr_03183_2_identifier_is_warning_not_error() {
4645 let mut sbom = NormalizedSbom::default();
4646 let mut c = bsi_ok_component("lib");
4647 c.identifiers.purl = None;
4648 c.canonical_id = c.identifiers.canonical_id();
4649 sbom.add_component(c);
4650 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4651 let id_violations: Vec<_> = r
4652 .violations
4653 .iter()
4654 .filter(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-2-4")
4655 .collect();
4656 assert!(
4657 !id_violations.is_empty(),
4658 "a purl-less component must fire the §5.2.4 identifier rule"
4659 );
4660 assert!(
4661 id_violations
4662 .iter()
4663 .all(|v| v.severity == ViolationSeverity::Warning),
4664 "the §5.2.4 identifier rule is additional-tier: Warning, not Error"
4665 );
4666 }
4667
4668 #[test]
4671 fn bsi_tr_03183_2_warns_on_undeclared_completeness() {
4672 use crate::model::CompletenessDeclaration;
4673 let mut sbom = NormalizedSbom::default();
4674 sbom.add_component(bsi_ok_component("lib"));
4675 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4677 assert!(
4678 r.violations
4679 .iter()
4680 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-5-COMPLETENESS"
4681 && v.severity == ViolationSeverity::Warning),
4682 "an SBOM without a completeness declaration must warn under §5.2.2"
4683 );
4684
4685 sbom.document.completeness_declaration = CompletenessDeclaration::Complete;
4686 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4687 assert!(
4688 !r.violations
4689 .iter()
4690 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-5-COMPLETENESS"),
4691 "a declared completeness must not warn"
4692 );
4693 }
4694
4695 #[test]
4698 fn bsi_tr_03183_2_warns_on_embedded_vulnerabilities() {
4699 use crate::model::{VulnerabilityRef, VulnerabilitySource};
4700 let mut sbom = NormalizedSbom::default();
4701 let mut c = bsi_ok_component("lib");
4702 c.vulnerabilities.push(VulnerabilityRef::new(
4703 "CVE-2026-0001".to_string(),
4704 VulnerabilitySource::Cve,
4705 ));
4706 sbom.add_component(c);
4707 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4708 assert!(
4709 r.violations
4710 .iter()
4711 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-3-1"
4712 && v.severity == ViolationSeverity::Warning),
4713 "embedded vulnerability information must warn under §3.1"
4714 );
4715
4716 let mut clean = NormalizedSbom::default();
4717 clean.add_component(bsi_ok_component("lib"));
4718 let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&clean);
4719 assert!(
4720 !r.violations
4721 .iter()
4722 .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-3-1"),
4723 "a vulnerability-free SBOM must not fire §3.1"
4724 );
4725 }
4726
4727 #[test]
4728 fn bsi_tr_03183_2_passes_for_complete_component() {
4729 use crate::model::{Creator, CreatorType, DependencyEdge, DependencyType};
4730 let mut sbom = NormalizedSbom::default();
4731 sbom.document.creators.push(Creator {
4732 creator_type: CreatorType::Person,
4733 name: "Release Engineering".to_string(),
4734 email: Some("sbom@example.org".to_string()),
4735 });
4736 let a = bsi_ok_component("a");
4737 let b = bsi_ok_component("b");
4738 let a_id = a.canonical_id.clone();
4739 let b_id = b.canonical_id.clone();
4740 sbom.components.insert(a_id.clone(), a);
4741 sbom.components.insert(b_id.clone(), b);
4742 sbom.edges
4743 .push(DependencyEdge::new(a_id, b_id, DependencyType::DependsOn));
4744
4745 let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4746 let errors: Vec<_> = result
4747 .violations
4748 .iter()
4749 .filter(|v| v.severity == ViolationSeverity::Error)
4750 .collect();
4751 assert!(
4752 errors.is_empty(),
4753 "Complete BSI-compliant SBOM should produce no Errors; got: {errors:?}"
4754 );
4755 }
4756
4757 #[test]
4758 fn bsi_tr_03183_2_in_compliance_level_all() {
4759 assert_eq!(ComplianceLevel::all().len(), 19);
4760 assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiTr03183_2));
4761 assert!(ComplianceLevel::all().contains(&ComplianceLevel::CraOssSteward));
4762 assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuccSubstantial));
4763 assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuAiAct));
4764 assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiSbomForAi));
4765 assert!(ComplianceLevel::all().contains(&ComplianceLevel::Cisa2026));
4766 assert!(ComplianceLevel::all().contains(&ComplianceLevel::PciDss632));
4767 assert!(ComplianceLevel::all().contains(&ComplianceLevel::Fsct));
4768 }
4769
4770 #[test]
4771 fn sidecar_does_not_override_present_sbom_field() {
4772 use crate::model::{CraSidecarMetadata, Creator, CreatorType};
4773 let mut sbom = NormalizedSbom::default();
4774 sbom.document.creators.push(Creator {
4775 creator_type: CreatorType::Organization,
4776 name: "SbomDeclaredCorp".to_string(),
4777 email: None,
4778 });
4779 let sidecar = CraSidecarMetadata {
4780 manufacturer_name: Some("SidecarCorp".to_string()),
4781 ..Default::default()
4782 };
4783 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
4784 .with_sidecar(sidecar)
4785 .check(&sbom);
4786 assert!(
4788 !result.violations.iter().any(|v| v
4789 .requirement
4790 .contains("Art. 13(16): Manufacturer identification")),
4791 "When SBOM provides manufacturer, no Art. 13(16) violation should be emitted"
4792 );
4793 }
4794}