1use crate::model::{NormalizedSbom, SbomFormat};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum CraPhase {
11 Phase1,
14 Phase2,
17}
18
19impl CraPhase {
20 pub const fn name(self) -> &'static str {
21 match self {
22 Self::Phase1 => "Phase 1 (2027)",
23 Self::Phase2 => "Phase 2 (2029)",
24 }
25 }
26
27 pub const fn deadline(self) -> &'static str {
28 match self {
29 Self::Phase1 => "11 December 2027",
30 Self::Phase2 => "11 December 2029",
31 }
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[non_exhaustive]
38pub enum ComplianceLevel {
39 Minimum,
41 Standard,
43 NtiaMinimum,
45 CraPhase1,
47 CraPhase2,
49 FdaMedicalDevice,
51 NistSsdf,
53 Eo14028,
55 Comprehensive,
57}
58
59impl ComplianceLevel {
60 #[must_use]
62 pub const fn name(&self) -> &'static str {
63 match self {
64 Self::Minimum => "Minimum",
65 Self::Standard => "Standard",
66 Self::NtiaMinimum => "NTIA Minimum Elements",
67 Self::CraPhase1 => "EU CRA Phase 1 (2027)",
68 Self::CraPhase2 => "EU CRA Phase 2 (2029)",
69 Self::FdaMedicalDevice => "FDA Medical Device",
70 Self::NistSsdf => "NIST SSDF (SP 800-218)",
71 Self::Eo14028 => "EO 14028 Section 4",
72 Self::Comprehensive => "Comprehensive",
73 }
74 }
75
76 #[must_use]
78 pub const fn short_name(&self) -> &'static str {
79 match self {
80 Self::Minimum => "Min",
81 Self::Standard => "Std",
82 Self::NtiaMinimum => "NTIA",
83 Self::CraPhase1 => "CRA-1",
84 Self::CraPhase2 => "CRA-2",
85 Self::FdaMedicalDevice => "FDA",
86 Self::NistSsdf => "SSDF",
87 Self::Eo14028 => "EO14028",
88 Self::Comprehensive => "Full",
89 }
90 }
91
92 #[must_use]
94 pub const fn description(&self) -> &'static str {
95 match self {
96 Self::Minimum => "Basic component identification only",
97 Self::Standard => "Recommended fields for general use",
98 Self::NtiaMinimum => "NTIA minimum elements for software transparency",
99 Self::CraPhase1 => {
100 "CRA reporting obligations — product ID, SBOM format, manufacturer (deadline: 11 Dec 2027)"
101 }
102 Self::CraPhase2 => {
103 "Full CRA compliance — adds vulnerability metadata, lifecycle, disclosure (deadline: 11 Dec 2029)"
104 }
105 Self::FdaMedicalDevice => "FDA premarket submission requirements for medical devices",
106 Self::NistSsdf => {
107 "Secure Software Development Framework — provenance, build integrity, VCS references"
108 }
109 Self::Eo14028 => {
110 "Executive Order 14028 — machine-readable SBOM, auto-generation, supply chain security"
111 }
112 Self::Comprehensive => "All recommended fields and best practices",
113 }
114 }
115
116 #[must_use]
118 pub const fn all() -> &'static [Self] {
119 &[
120 Self::Minimum,
121 Self::Standard,
122 Self::NtiaMinimum,
123 Self::CraPhase1,
124 Self::CraPhase2,
125 Self::FdaMedicalDevice,
126 Self::NistSsdf,
127 Self::Eo14028,
128 Self::Comprehensive,
129 ]
130 }
131
132 #[must_use]
134 pub const fn is_cra(&self) -> bool {
135 matches!(self, Self::CraPhase1 | Self::CraPhase2)
136 }
137
138 #[must_use]
140 pub const fn cra_phase(&self) -> Option<CraPhase> {
141 match self {
142 Self::CraPhase1 => Some(CraPhase::Phase1),
143 Self::CraPhase2 => Some(CraPhase::Phase2),
144 _ => None,
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Violation {
152 pub severity: ViolationSeverity,
154 pub category: ViolationCategory,
156 pub message: String,
158 pub element: Option<String>,
160 pub requirement: String,
162}
163
164impl Violation {
165 #[must_use]
167 pub fn remediation_guidance(&self) -> &'static str {
168 let req = self.requirement.to_lowercase();
169 if req.contains("art. 13(4)") {
170 "Ensure the SBOM is produced in CycloneDX 1.4+ (JSON or XML), SPDX 2.3+ (JSON or tag-value), or SPDX 3.0+ (JSON-LD). Older format versions may not be recognized as machine-readable under the CRA."
171 } else if req.contains("art. 13(6)") && req.contains("vulnerability metadata") {
172 "Add severity (e.g., CVSS score) and remediation details to each vulnerability entry. CycloneDX: use vulnerability.ratings[].score and vulnerability.analysis. SPDX: use annotation or externalRef."
173 } else if req.contains("art. 13(6)") {
174 "Add a security contact or vulnerability disclosure URL. CycloneDX: add a component externalReference with type 'security-contact' or set metadata.manufacturer.contact. SPDX: add an SECURITY external reference."
175 } else if req.contains("art. 13(7)") {
176 "Reference a coordinated vulnerability disclosure policy. CycloneDX: add an externalReference of type 'advisories' linking to your disclosure policy. SPDX: add an external document reference."
177 } else if req.contains("art. 13(8)") {
178 "Specify when security updates will no longer be provided. CycloneDX 1.5+: use component.releaseNotes or metadata properties. SPDX: use an annotation with end-of-support date."
179 } else if req.contains("art. 13(11)") {
180 "Include lifecycle or end-of-support metadata for components. CycloneDX: use component properties (e.g., cdx:lifecycle:status). SPDX: use annotations."
181 } else if req.contains("art. 13(12)") && req.contains("version") {
182 "Every component must have a version string. Use the actual release version (e.g., '1.2.3'), not a range or placeholder."
183 } else if req.contains("art. 13(12)") {
184 "The SBOM must identify the product by name. CycloneDX: set metadata.component.name. SPDX: set documentDescribes with the primary package name."
185 } else if req.contains("art. 13(15)") && req.contains("email") {
186 "Provide a valid contact email for the manufacturer. The email must contain an @ sign with valid local and domain parts."
187 } else if req.contains("art. 13(15)") {
188 "Identify the manufacturer/supplier. CycloneDX: set metadata.manufacturer or component.supplier. SPDX: set PackageSupplier."
189 } else if req.contains("annex vii") {
190 "Reference the EU Declaration of Conformity. CycloneDX: add an externalReference of type 'attestation' or 'certification'. SPDX: add an external document reference."
191 } else if req.contains("annex i") && req.contains("identifier") {
192 "Add a PURL, CPE, or SWID tag to each component for unique identification. PURLs are preferred (e.g., pkg:npm/lodash@4.17.21)."
193 } else if req.contains("annex i") && req.contains("dependency") {
194 "Add dependency relationships between components. CycloneDX: use the dependencies array. SPDX: use DEPENDS_ON relationships."
195 } else if req.contains("annex i") && req.contains("primary") {
196 "Identify the top-level product component. CycloneDX: set metadata.component. SPDX: use documentDescribes to point to the primary package."
197 } else if req.contains("annex i") && req.contains("hash") {
198 "Add cryptographic hashes (SHA-256 or stronger) to components for integrity verification."
199 } else if req.contains("annex i") && req.contains("traceability") {
200 "The primary product component needs a stable unique identifier (PURL or CPE) that persists across software updates for traceability."
201 } else if req.contains("art. 13(3)") {
202 "Regenerate the SBOM when components are added, removed, or updated. CRA Art. 13(3) requires timely updates reflecting the current state of the software."
203 } else if req.contains("art. 13(5)") {
204 "Ensure every component has license information. CycloneDX: use component.licenses[]. SPDX 2.x: use PackageLicenseDeclared / PackageLicenseConcluded. SPDX 3.0: use HAS_DECLARED_LICENSE / HAS_CONCLUDED_LICENSE relationships."
205 } else if req.contains("art. 13(9)") {
206 "Include vulnerability data or add a vulnerability-assertion external reference stating no known vulnerabilities. CycloneDX: use the vulnerabilities array. SPDX: use annotations or external references."
207 } else if req.contains("annex i") && req.contains("supply chain") {
208 "Populate the supplier field for all components, especially transitive dependencies. CycloneDX: use component.supplier. SPDX: use PackageSupplier."
209 } else if req.contains("annex iii") {
210 "Add document-level integrity metadata: a serial number (CycloneDX: serialNumber, SPDX: documentNamespace), or a digital signature/attestation with a cryptographic hash."
211 } else if req.contains("nist ssdf") || req.contains("sp 800-218") {
212 "Follow NIST SP 800-218 SSDF practices: include tool provenance, source VCS references, build metadata, and cryptographic hashes for all components."
213 } else if req.contains("eo 14028") {
214 "Follow EO 14028 Section 4(e) requirements: use a machine-readable format (CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+), auto-generate the SBOM, include unique identifiers, versions, hashes, dependencies, and supplier information."
215 } else {
216 "Review the requirement and update the SBOM accordingly. Consult the EU CRA regulation (EU 2024/2847) for detailed guidance."
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223pub enum ViolationSeverity {
224 Error,
226 Warning,
228 Info,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
234pub enum ViolationCategory {
235 DocumentMetadata,
237 ComponentIdentification,
239 DependencyInfo,
241 LicenseInfo,
243 SupplierInfo,
245 IntegrityInfo,
247 SecurityInfo,
249 FormatSpecific,
251}
252
253impl ViolationCategory {
254 #[must_use]
255 pub const fn name(&self) -> &'static str {
256 match self {
257 Self::DocumentMetadata => "Document Metadata",
258 Self::ComponentIdentification => "Component Identification",
259 Self::DependencyInfo => "Dependency Information",
260 Self::LicenseInfo => "License Information",
261 Self::SupplierInfo => "Supplier Information",
262 Self::IntegrityInfo => "Integrity Information",
263 Self::SecurityInfo => "Security Information",
264 Self::FormatSpecific => "Format-Specific",
265 }
266 }
267
268 #[must_use]
270 pub const fn short_name(&self) -> &'static str {
271 match self {
272 Self::DocumentMetadata => "Doc Meta",
273 Self::ComponentIdentification => "Comp IDs",
274 Self::DependencyInfo => "Deps",
275 Self::LicenseInfo => "License",
276 Self::SupplierInfo => "Supplier",
277 Self::IntegrityInfo => "Integrity",
278 Self::SecurityInfo => "Security",
279 Self::FormatSpecific => "Format",
280 }
281 }
282
283 #[must_use]
285 pub const fn all() -> &'static [Self] {
286 &[
287 Self::SupplierInfo,
288 Self::ComponentIdentification,
289 Self::DocumentMetadata,
290 Self::IntegrityInfo,
291 Self::LicenseInfo,
292 Self::DependencyInfo,
293 Self::SecurityInfo,
294 Self::FormatSpecific,
295 ]
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct ComplianceResult {
302 pub is_compliant: bool,
304 pub level: ComplianceLevel,
306 pub violations: Vec<Violation>,
308 pub error_count: usize,
310 pub warning_count: usize,
312 pub info_count: usize,
314}
315
316impl ComplianceResult {
317 #[must_use]
319 pub fn new(level: ComplianceLevel, violations: Vec<Violation>) -> Self {
320 let error_count = violations
321 .iter()
322 .filter(|v| v.severity == ViolationSeverity::Error)
323 .count();
324 let warning_count = violations
325 .iter()
326 .filter(|v| v.severity == ViolationSeverity::Warning)
327 .count();
328 let info_count = violations
329 .iter()
330 .filter(|v| v.severity == ViolationSeverity::Info)
331 .count();
332
333 Self {
334 is_compliant: error_count == 0,
335 level,
336 violations,
337 error_count,
338 warning_count,
339 info_count,
340 }
341 }
342
343 #[must_use]
345 pub fn violations_by_severity(&self, severity: ViolationSeverity) -> Vec<&Violation> {
346 self.violations
347 .iter()
348 .filter(|v| v.severity == severity)
349 .collect()
350 }
351
352 #[must_use]
354 pub fn violations_by_category(&self, category: ViolationCategory) -> Vec<&Violation> {
355 self.violations
356 .iter()
357 .filter(|v| v.category == category)
358 .collect()
359 }
360}
361
362#[derive(Debug, Clone)]
364pub struct ComplianceChecker {
365 level: ComplianceLevel,
367}
368
369impl ComplianceChecker {
370 #[must_use]
372 pub const fn new(level: ComplianceLevel) -> Self {
373 Self { level }
374 }
375
376 #[must_use]
378 pub fn check(&self, sbom: &NormalizedSbom) -> ComplianceResult {
379 let mut violations = Vec::new();
380
381 match self.level {
382 ComplianceLevel::NistSsdf => {
383 self.check_nist_ssdf(sbom, &mut violations);
384 }
385 ComplianceLevel::Eo14028 => {
386 self.check_eo14028(sbom, &mut violations);
387 }
388 _ => {
389 self.check_document_metadata(sbom, &mut violations);
391
392 self.check_components(sbom, &mut violations);
394
395 self.check_dependencies(sbom, &mut violations);
397
398 self.check_vulnerability_metadata(sbom, &mut violations);
400
401 self.check_format_specific(sbom, &mut violations);
403
404 if self.level.is_cra() {
406 self.check_cra_gaps(sbom, &mut violations);
407 }
408 }
409 }
410
411 ComplianceResult::new(self.level, violations)
412 }
413
414 fn check_document_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
415 use crate::model::{CreatorType, ExternalRefType};
416
417 if sbom.document.creators.is_empty() {
419 violations.push(Violation {
420 severity: match self.level {
421 ComplianceLevel::Minimum => ViolationSeverity::Warning,
422 _ => ViolationSeverity::Error,
423 },
424 category: ViolationCategory::DocumentMetadata,
425 message: "SBOM must have creator/tool information".to_string(),
426 element: None,
427 requirement: "Document creator identification".to_string(),
428 });
429 }
430
431 if self.level.is_cra() {
433 let has_org = sbom
434 .document
435 .creators
436 .iter()
437 .any(|c| c.creator_type == CreatorType::Organization);
438 if !has_org {
439 violations.push(Violation {
440 severity: ViolationSeverity::Warning,
441 category: ViolationCategory::DocumentMetadata,
442 message:
443 "[CRA Art. 13(15)] SBOM should identify the manufacturer (organization)"
444 .to_string(),
445 element: None,
446 requirement: "CRA Art. 13(15): Manufacturer identification".to_string(),
447 });
448 }
449
450 for creator in &sbom.document.creators {
452 if creator.creator_type == CreatorType::Organization
453 && let Some(email) = &creator.email
454 && !is_valid_email_format(email)
455 {
456 violations.push(Violation {
457 severity: ViolationSeverity::Warning,
458 category: ViolationCategory::DocumentMetadata,
459 message: format!(
460 "[CRA Art. 13(15)] Manufacturer email '{email}' appears invalid"
461 ),
462 element: None,
463 requirement: "CRA Art. 13(15): Valid contact information".to_string(),
464 });
465 }
466 }
467
468 if sbom.document.name.is_none() {
469 violations.push(Violation {
470 severity: ViolationSeverity::Warning,
471 category: ViolationCategory::DocumentMetadata,
472 message: "[CRA Art. 13(12)] SBOM should include the product name".to_string(),
473 element: None,
474 requirement: "CRA Art. 13(12): Product identification".to_string(),
475 });
476 }
477
478 let has_doc_security_contact = sbom.document.security_contact.is_some()
481 || sbom.document.vulnerability_disclosure_url.is_some();
482
483 let has_component_security_contact = sbom.components.values().any(|comp| {
485 comp.external_refs.iter().any(|r| {
486 matches!(
487 r.ref_type,
488 ExternalRefType::SecurityContact
489 | ExternalRefType::Support
490 | ExternalRefType::Advisories
491 )
492 })
493 });
494
495 if !has_doc_security_contact && !has_component_security_contact {
496 violations.push(Violation {
497 severity: ViolationSeverity::Warning,
498 category: ViolationCategory::SecurityInfo,
499 message: "[CRA Art. 13(6)] SBOM should include a security contact or vulnerability disclosure reference".to_string(),
500 element: None,
501 requirement: "CRA Art. 13(6): Vulnerability disclosure contact".to_string(),
502 });
503 }
504
505 if sbom.primary_component_id.is_none() && sbom.components.len() > 1 {
507 violations.push(Violation {
508 severity: ViolationSeverity::Warning,
509 category: ViolationCategory::DocumentMetadata,
510 message: "[CRA Annex I] SBOM should identify the primary product component (CycloneDX metadata.component or SPDX documentDescribes)".to_string(),
511 element: None,
512 requirement: "CRA Annex I: Primary product identification".to_string(),
513 });
514 }
515
516 if sbom.document.support_end_date.is_none() {
518 violations.push(Violation {
519 severity: ViolationSeverity::Info,
520 category: ViolationCategory::SecurityInfo,
521 message: "[CRA Art. 13(8)] Consider specifying a support end date for security updates".to_string(),
522 element: None,
523 requirement: "CRA Art. 13(8): Support period disclosure".to_string(),
524 });
525 }
526
527 let format_ok = match sbom.document.format {
531 SbomFormat::CycloneDx => {
532 let v = &sbom.document.spec_version;
533 !(v.starts_with("1.0")
534 || v.starts_with("1.1")
535 || v.starts_with("1.2")
536 || v.starts_with("1.3"))
537 }
538 SbomFormat::Spdx => {
539 let v = &sbom.document.spec_version;
540 v.starts_with("2.3") || v.starts_with("3.")
541 }
542 };
543 if !format_ok {
544 violations.push(Violation {
545 severity: ViolationSeverity::Warning,
546 category: ViolationCategory::FormatSpecific,
547 message: format!(
548 "[CRA Art. 13(4)] SBOM format version {} {} may not meet CRA machine-readable requirements; use CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+",
549 sbom.document.format, sbom.document.spec_version
550 ),
551 element: None,
552 requirement: "CRA Art. 13(4): Machine-readable SBOM format".to_string(),
553 });
554 }
555
556 if let Some(ref primary_id) = sbom.primary_component_id
560 && let Some(primary) = sbom.components.get(primary_id)
561 && primary.identifiers.purl.is_none()
562 && primary.identifiers.cpe.is_empty()
563 {
564 violations.push(Violation {
565 severity: ViolationSeverity::Warning,
566 category: ViolationCategory::ComponentIdentification,
567 message: format!(
568 "[CRA Annex I, Part II] Primary component '{}' missing unique identifier (PURL/CPE) for cross-update traceability",
569 primary.name
570 ),
571 element: Some(primary.name.clone()),
572 requirement: "CRA Annex I, Part II, 1: Product identifier traceability across updates".to_string(),
573 });
574 }
575 }
576
577 if matches!(self.level, ComplianceLevel::CraPhase2) {
579 let has_vuln_disclosure_policy = sbom.document.vulnerability_disclosure_url.is_some()
582 || sbom.components.values().any(|comp| {
583 comp.external_refs
584 .iter()
585 .any(|r| matches!(r.ref_type, ExternalRefType::Advisories))
586 });
587 if !has_vuln_disclosure_policy {
588 violations.push(Violation {
589 severity: ViolationSeverity::Warning,
590 category: ViolationCategory::SecurityInfo,
591 message: "[CRA Art. 13(7)] SBOM should reference a coordinated vulnerability disclosure policy (advisories URL or disclosure URL)".to_string(),
592 element: None,
593 requirement: "CRA Art. 13(7): Coordinated vulnerability disclosure policy".to_string(),
594 });
595 }
596
597 let has_lifecycle_info = sbom.document.support_end_date.is_some()
602 || sbom.components.values().any(|comp| {
603 comp.extensions.properties.iter().any(|p| {
604 let name_lower = p.name.to_lowercase();
605 name_lower.contains("lifecycle")
606 || name_lower.contains("end-of-life")
607 || name_lower.contains("eol")
608 || name_lower.contains("end-of-support")
609 })
610 });
611 if !has_lifecycle_info {
612 violations.push(Violation {
613 severity: ViolationSeverity::Info,
614 category: ViolationCategory::SecurityInfo,
615 message: "[CRA Art. 13(11)] Consider including component lifecycle/end-of-support information".to_string(),
616 element: None,
617 requirement: "CRA Art. 13(11): Component lifecycle status".to_string(),
618 });
619 }
620
621 let has_conformity_ref = sbom.components.values().any(|comp| {
624 comp.external_refs.iter().any(|r| {
625 matches!(
626 r.ref_type,
627 ExternalRefType::Attestation | ExternalRefType::Certification
628 ) || (matches!(r.ref_type, ExternalRefType::Other(ref s) if s.to_lowercase().contains("declaration-of-conformity"))
629 )
630 })
631 });
632 if !has_conformity_ref {
633 violations.push(Violation {
634 severity: ViolationSeverity::Info,
635 category: ViolationCategory::DocumentMetadata,
636 message: "[CRA Annex VII] Consider including a reference to the EU Declaration of Conformity (attestation or certification external reference)".to_string(),
637 element: None,
638 requirement: "CRA Annex VII: EU Declaration of Conformity reference".to_string(),
639 });
640 }
641 }
642
643 if matches!(self.level, ComplianceLevel::FdaMedicalDevice) {
645 let has_org = sbom
646 .document
647 .creators
648 .iter()
649 .any(|c| c.creator_type == CreatorType::Organization);
650 if !has_org {
651 violations.push(Violation {
652 severity: ViolationSeverity::Warning,
653 category: ViolationCategory::DocumentMetadata,
654 message: "FDA: SBOM should have manufacturer (organization) as creator"
655 .to_string(),
656 element: None,
657 requirement: "FDA: Manufacturer identification".to_string(),
658 });
659 }
660
661 let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
663 if !has_contact {
664 violations.push(Violation {
665 severity: ViolationSeverity::Warning,
666 category: ViolationCategory::DocumentMetadata,
667 message: "FDA: SBOM creators should include contact email".to_string(),
668 element: None,
669 requirement: "FDA: Contact information".to_string(),
670 });
671 }
672
673 if sbom.document.name.is_none() {
675 violations.push(Violation {
676 severity: ViolationSeverity::Warning,
677 category: ViolationCategory::DocumentMetadata,
678 message: "FDA: SBOM should have a document name/title".to_string(),
679 element: None,
680 requirement: "FDA: Document identification".to_string(),
681 });
682 }
683 }
684
685 if matches!(
687 self.level,
688 ComplianceLevel::NtiaMinimum | ComplianceLevel::Comprehensive
689 ) {
690 }
693
694 if matches!(
696 self.level,
697 ComplianceLevel::Standard
698 | ComplianceLevel::FdaMedicalDevice
699 | ComplianceLevel::CraPhase1
700 | ComplianceLevel::CraPhase2
701 | ComplianceLevel::Comprehensive
702 ) && sbom.document.serial_number.is_none()
703 {
704 violations.push(Violation {
705 severity: ViolationSeverity::Warning,
706 category: ViolationCategory::DocumentMetadata,
707 message: "SBOM should have a serial number/unique identifier".to_string(),
708 element: None,
709 requirement: "Document unique identification".to_string(),
710 });
711 }
712 }
713
714 fn check_components(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
715 use crate::model::HashAlgorithm;
716
717 for comp in sbom.components.values() {
718 if comp.name.is_empty() {
721 violations.push(Violation {
722 severity: ViolationSeverity::Error,
723 category: ViolationCategory::ComponentIdentification,
724 message: "Component must have a name".to_string(),
725 element: Some(comp.identifiers.format_id.clone()),
726 requirement: "Component name (required)".to_string(),
727 });
728 }
729
730 if matches!(
732 self.level,
733 ComplianceLevel::NtiaMinimum
734 | ComplianceLevel::FdaMedicalDevice
735 | ComplianceLevel::Standard
736 | ComplianceLevel::CraPhase1
737 | ComplianceLevel::CraPhase2
738 | ComplianceLevel::Comprehensive
739 ) && comp.version.is_none()
740 {
741 let (req, msg) = match self.level {
742 ComplianceLevel::FdaMedicalDevice => (
743 "FDA: Component version".to_string(),
744 format!("Component '{}' missing version", comp.name),
745 ),
746 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
747 "CRA Art. 13(12): Component version".to_string(),
748 format!(
749 "[CRA Art. 13(12)] Component '{}' missing version",
750 comp.name
751 ),
752 ),
753 _ => (
754 "NTIA: Component version".to_string(),
755 format!("Component '{}' missing version", comp.name),
756 ),
757 };
758 violations.push(Violation {
759 severity: ViolationSeverity::Error,
760 category: ViolationCategory::ComponentIdentification,
761 message: msg,
762 element: Some(comp.name.clone()),
763 requirement: req,
764 });
765 }
766
767 if matches!(
769 self.level,
770 ComplianceLevel::Standard
771 | ComplianceLevel::FdaMedicalDevice
772 | ComplianceLevel::CraPhase1
773 | ComplianceLevel::CraPhase2
774 | ComplianceLevel::Comprehensive
775 ) && comp.identifiers.purl.is_none()
776 && comp.identifiers.cpe.is_empty()
777 && comp.identifiers.swid.is_none()
778 {
779 let severity = if matches!(
780 self.level,
781 ComplianceLevel::FdaMedicalDevice
782 | ComplianceLevel::CraPhase1
783 | ComplianceLevel::CraPhase2
784 ) {
785 ViolationSeverity::Error
786 } else {
787 ViolationSeverity::Warning
788 };
789 let (message, requirement) = match self.level {
790 ComplianceLevel::FdaMedicalDevice => (
791 format!(
792 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
793 comp.name
794 ),
795 "FDA: Unique component identifier".to_string(),
796 ),
797 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
798 format!(
799 "[CRA Annex I] Component '{}' missing unique identifier (PURL/CPE/SWID)",
800 comp.name
801 ),
802 "CRA Annex I: Unique component identifier (PURL/CPE/SWID)".to_string(),
803 ),
804 _ => (
805 format!(
806 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
807 comp.name
808 ),
809 "Standard identifier (PURL/CPE)".to_string(),
810 ),
811 };
812 violations.push(Violation {
813 severity,
814 category: ViolationCategory::ComponentIdentification,
815 message,
816 element: Some(comp.name.clone()),
817 requirement,
818 });
819 }
820
821 if matches!(
823 self.level,
824 ComplianceLevel::NtiaMinimum
825 | ComplianceLevel::FdaMedicalDevice
826 | ComplianceLevel::CraPhase1
827 | ComplianceLevel::CraPhase2
828 | ComplianceLevel::Comprehensive
829 ) && comp.supplier.is_none()
830 && comp.author.is_none()
831 {
832 let severity = match self.level {
833 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => {
834 ViolationSeverity::Warning
835 }
836 _ => ViolationSeverity::Error,
837 };
838 let (message, requirement) = match self.level {
839 ComplianceLevel::FdaMedicalDevice => (
840 format!("Component '{}' missing supplier/manufacturer", comp.name),
841 "FDA: Supplier/manufacturer information".to_string(),
842 ),
843 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
844 format!(
845 "[CRA Art. 13(15)] Component '{}' missing supplier/manufacturer",
846 comp.name
847 ),
848 "CRA Art. 13(15): Supplier/manufacturer information".to_string(),
849 ),
850 _ => (
851 format!("Component '{}' missing supplier/manufacturer", comp.name),
852 "NTIA: Supplier information".to_string(),
853 ),
854 };
855 violations.push(Violation {
856 severity,
857 category: ViolationCategory::SupplierInfo,
858 message,
859 element: Some(comp.name.clone()),
860 requirement,
861 });
862 }
863
864 if matches!(
866 self.level,
867 ComplianceLevel::Standard | ComplianceLevel::Comprehensive
868 ) && comp.licenses.declared.is_empty()
869 && comp.licenses.concluded.is_none()
870 {
871 violations.push(Violation {
872 severity: ViolationSeverity::Warning,
873 category: ViolationCategory::LicenseInfo,
874 message: format!("Component '{}' should have license information", comp.name),
875 element: Some(comp.name.clone()),
876 requirement: "License declaration".to_string(),
877 });
878 }
879
880 if matches!(
882 self.level,
883 ComplianceLevel::FdaMedicalDevice | ComplianceLevel::Comprehensive
884 ) {
885 if comp.hashes.is_empty() {
886 violations.push(Violation {
887 severity: if self.level == ComplianceLevel::FdaMedicalDevice {
888 ViolationSeverity::Error
889 } else {
890 ViolationSeverity::Warning
891 },
892 category: ViolationCategory::IntegrityInfo,
893 message: format!("Component '{}' missing cryptographic hash", comp.name),
894 element: Some(comp.name.clone()),
895 requirement: if self.level == ComplianceLevel::FdaMedicalDevice {
896 "FDA: Cryptographic hash for integrity".to_string()
897 } else {
898 "Integrity verification (hashes)".to_string()
899 },
900 });
901 } else if self.level == ComplianceLevel::FdaMedicalDevice {
902 let has_strong_hash = comp.hashes.iter().any(|h| {
904 matches!(
905 h.algorithm,
906 HashAlgorithm::Sha256
907 | HashAlgorithm::Sha384
908 | HashAlgorithm::Sha512
909 | HashAlgorithm::Sha3_256
910 | HashAlgorithm::Sha3_384
911 | HashAlgorithm::Sha3_512
912 | HashAlgorithm::Blake2b256
913 | HashAlgorithm::Blake2b384
914 | HashAlgorithm::Blake2b512
915 | HashAlgorithm::Blake3
916 | HashAlgorithm::Streebog256
917 | HashAlgorithm::Streebog512
918 )
919 });
920 if !has_strong_hash {
921 violations.push(Violation {
922 severity: ViolationSeverity::Warning,
923 category: ViolationCategory::IntegrityInfo,
924 message: format!(
925 "Component '{}' has only weak hash algorithm (use SHA-256+)",
926 comp.name
927 ),
928 element: Some(comp.name.clone()),
929 requirement: "FDA: Strong cryptographic hash (SHA-256 or better)"
930 .to_string(),
931 });
932 }
933 }
934 }
935
936 if self.level.is_cra() && comp.hashes.is_empty() {
938 violations.push(Violation {
939 severity: ViolationSeverity::Info,
940 category: ViolationCategory::IntegrityInfo,
941 message: format!(
942 "[CRA Annex I] Component '{}' missing cryptographic hash (recommended for integrity)",
943 comp.name
944 ),
945 element: Some(comp.name.clone()),
946 requirement: "CRA Annex I: Component integrity information (hash)".to_string(),
947 });
948 }
949 }
950 }
951
952 fn check_dependencies(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
953 if matches!(
955 self.level,
956 ComplianceLevel::NtiaMinimum
957 | ComplianceLevel::FdaMedicalDevice
958 | ComplianceLevel::CraPhase1
959 | ComplianceLevel::CraPhase2
960 | ComplianceLevel::Comprehensive
961 ) {
962 let has_deps = !sbom.edges.is_empty();
963 let has_multiple_components = sbom.components.len() > 1;
964
965 if has_multiple_components && !has_deps {
966 let (message, requirement) = match self.level {
967 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
968 "[CRA Annex I] SBOM with multiple components must include dependency relationships".to_string(),
969 "CRA Annex I: Dependency relationships".to_string(),
970 ),
971 _ => (
972 "SBOM with multiple components must include dependency relationships".to_string(),
973 "NTIA: Dependency relationships".to_string(),
974 ),
975 };
976 violations.push(Violation {
977 severity: ViolationSeverity::Error,
978 category: ViolationCategory::DependencyInfo,
979 message,
980 element: None,
981 requirement,
982 });
983 }
984 }
985
986 if self.level.is_cra() && sbom.components.len() > 1 && sbom.primary_component_id.is_none() {
988 use std::collections::HashSet;
989 let mut incoming: HashSet<&crate::model::CanonicalId> = HashSet::new();
990 for edge in &sbom.edges {
991 incoming.insert(&edge.to);
992 }
993 let root_count = sbom.components.len().saturating_sub(incoming.len());
994 if root_count > 1 {
995 violations.push(Violation {
996 severity: ViolationSeverity::Warning,
997 category: ViolationCategory::DependencyInfo,
998 message: "[CRA Annex I] SBOM appears to have multiple root components; identify a primary product component for top-level dependencies".to_string(),
999 element: None,
1000 requirement: "CRA Annex I: Top-level dependency clarity".to_string(),
1001 });
1002 }
1003 }
1004 }
1005
1006 fn check_vulnerability_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1007 if !matches!(self.level, ComplianceLevel::CraPhase2) {
1008 return;
1009 }
1010
1011 for (comp, vuln) in sbom.all_vulnerabilities() {
1012 if vuln.severity.is_none() && vuln.cvss.is_empty() {
1013 violations.push(Violation {
1014 severity: ViolationSeverity::Warning,
1015 category: ViolationCategory::SecurityInfo,
1016 message: format!(
1017 "[CRA Art. 13(6)] Vulnerability '{}' in '{}' lacks severity or CVSS score",
1018 vuln.id, comp.name
1019 ),
1020 element: Some(comp.name.clone()),
1021 requirement: "CRA Art. 13(6): Vulnerability metadata completeness".to_string(),
1022 });
1023 }
1024
1025 if let Some(remediation) = &vuln.remediation
1026 && remediation.fixed_version.is_none()
1027 && remediation.description.is_none()
1028 {
1029 violations.push(Violation {
1030 severity: ViolationSeverity::Info,
1031 category: ViolationCategory::SecurityInfo,
1032 message: format!(
1033 "[CRA Art. 13(6)] Vulnerability '{}' in '{}' has remediation without details",
1034 vuln.id, comp.name
1035 ),
1036 element: Some(comp.name.clone()),
1037 requirement: "CRA Art. 13(6): Remediation detail".to_string(),
1038 });
1039 }
1040 }
1041 }
1042
1043 fn check_cra_gaps(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1045 let age_days = (chrono::Utc::now() - sbom.document.created).num_days();
1047 if age_days > 90 {
1048 violations.push(Violation {
1049 severity: ViolationSeverity::Warning,
1050 category: ViolationCategory::DocumentMetadata,
1051 message: format!(
1052 "[CRA Art. 13(3)] SBOM is {age_days} days old; CRA requires timely updates when components change"
1053 ),
1054 element: None,
1055 requirement: "CRA Art. 13(3): SBOM update frequency".to_string(),
1056 });
1057 } else if age_days > 30 {
1058 violations.push(Violation {
1059 severity: ViolationSeverity::Info,
1060 category: ViolationCategory::DocumentMetadata,
1061 message: format!(
1062 "[CRA Art. 13(3)] SBOM is {age_days} days old; consider regenerating after component changes"
1063 ),
1064 element: None,
1065 requirement: "CRA Art. 13(3): SBOM update frequency".to_string(),
1066 });
1067 }
1068
1069 let total = sbom.components.len();
1071 let without_license = sbom
1072 .components
1073 .values()
1074 .filter(|c| c.licenses.declared.is_empty() && c.licenses.concluded.is_none())
1075 .count();
1076 if without_license > 0 {
1077 let pct = (without_license * 100) / total.max(1);
1078 let severity = if pct > 50 {
1079 ViolationSeverity::Warning
1080 } else {
1081 ViolationSeverity::Info
1082 };
1083 violations.push(Violation {
1084 severity,
1085 category: ViolationCategory::LicenseInfo,
1086 message: format!(
1087 "[CRA Art. 13(5)] {without_license}/{total} components ({pct}%) missing license information"
1088 ),
1089 element: None,
1090 requirement: "CRA Art. 13(5): Licensed component tracking".to_string(),
1091 });
1092 }
1093
1094 let has_vuln_data = sbom
1097 .components
1098 .values()
1099 .any(|c| !c.vulnerabilities.is_empty());
1100 let has_vuln_assertion = sbom.components.values().any(|comp| {
1101 comp.external_refs.iter().any(|r| {
1102 matches!(
1103 r.ref_type,
1104 crate::model::ExternalRefType::VulnerabilityAssertion
1105 | crate::model::ExternalRefType::ExploitabilityStatement
1106 )
1107 })
1108 });
1109 if !has_vuln_data && !has_vuln_assertion {
1110 violations.push(Violation {
1111 severity: ViolationSeverity::Info,
1112 category: ViolationCategory::SecurityInfo,
1113 message:
1114 "[CRA Art. 13(9)] No vulnerability data or vulnerability assertion found; \
1115 include vulnerability information or a statement of no known vulnerabilities"
1116 .to_string(),
1117 element: None,
1118 requirement: "CRA Art. 13(9): Known vulnerabilities statement".to_string(),
1119 });
1120 }
1121
1122 if !sbom.edges.is_empty() {
1125 let transitive_without_supplier = sbom
1126 .components
1127 .values()
1128 .filter(|c| c.supplier.is_none() && c.author.is_none())
1129 .count();
1130 if transitive_without_supplier > 0 {
1131 let pct = (transitive_without_supplier * 100) / total.max(1);
1132 if pct > 30 {
1133 violations.push(Violation {
1134 severity: ViolationSeverity::Warning,
1135 category: ViolationCategory::SupplierInfo,
1136 message: format!(
1137 "[CRA Annex I, Part III] {transitive_without_supplier}/{total} components ({pct}%) \
1138 missing supplier information for supply chain transparency"
1139 ),
1140 element: None,
1141 requirement: "CRA Annex I, Part III: Supply chain transparency".to_string(),
1142 });
1143 }
1144 }
1145 }
1146
1147 let has_doc_integrity = sbom.document.serial_number.is_some()
1150 || sbom.components.values().any(|comp| {
1151 comp.external_refs.iter().any(|r| {
1152 matches!(
1153 r.ref_type,
1154 crate::model::ExternalRefType::Attestation
1155 | crate::model::ExternalRefType::Certification
1156 ) && !r.hashes.is_empty()
1157 })
1158 });
1159 if !has_doc_integrity {
1160 violations.push(Violation {
1161 severity: ViolationSeverity::Info,
1162 category: ViolationCategory::IntegrityInfo,
1163 message: "[CRA Annex III] Consider adding document-level integrity metadata \
1164 (serial number, digital signature, or attestation with hash)"
1165 .to_string(),
1166 element: None,
1167 requirement: "CRA Annex III: Document signature/integrity".to_string(),
1168 });
1169 }
1170
1171 let eol_count = sbom
1174 .components
1175 .values()
1176 .filter(|c| {
1177 c.eol
1178 .as_ref()
1179 .is_some_and(|e| e.status == crate::model::EolStatus::EndOfLife)
1180 })
1181 .count();
1182 if eol_count > 0 {
1183 violations.push(Violation {
1184 severity: ViolationSeverity::Warning,
1185 category: ViolationCategory::SecurityInfo,
1186 message: format!(
1187 "[CRA Art. 13(8)] {eol_count} component(s) have reached end-of-life and no longer receive security updates"
1188 ),
1189 element: None,
1190 requirement: "CRA Art. 13(8): Support period / lifecycle management".to_string(),
1191 });
1192 }
1193
1194 let approaching_eol_count = sbom
1195 .components
1196 .values()
1197 .filter(|c| {
1198 c.eol
1199 .as_ref()
1200 .is_some_and(|e| e.status == crate::model::EolStatus::ApproachingEol)
1201 })
1202 .count();
1203 if approaching_eol_count > 0 {
1204 violations.push(Violation {
1205 severity: ViolationSeverity::Info,
1206 category: ViolationCategory::SecurityInfo,
1207 message: format!(
1208 "[CRA Art. 13(11)] {approaching_eol_count} component(s) are approaching end-of-life within 6 months"
1209 ),
1210 element: None,
1211 requirement: "CRA Art. 13(11): Component lifecycle monitoring".to_string(),
1212 });
1213 }
1214
1215 if sbom.document.format == crate::model::SbomFormat::Spdx
1217 && sbom.document.spec_version.starts_with("3.")
1218 {
1219 let has_vulns = sbom
1221 .components
1222 .values()
1223 .any(|c| !c.vulnerabilities.is_empty());
1224 let has_security_profile = sbom
1225 .document
1226 .distribution_classification
1227 .as_ref()
1228 .is_some_and(|p| p.to_lowercase().contains("security"));
1229
1230 if has_vulns && !has_security_profile {
1231 violations.push(Violation {
1232 severity: ViolationSeverity::Info,
1233 category: ViolationCategory::DocumentMetadata,
1234 message:
1235 "[CRA Art. 13(6)] SPDX 3.0 document contains vulnerabilities but does not declare Security profile conformance; declare profileConformance: [\"security\"] for CRA Art. 13(6) compliance"
1236 .to_string(),
1237 element: None,
1238 requirement: "CRA Art. 13(6): SPDX 3.0 Security profile conformance"
1239 .to_string(),
1240 });
1241 }
1242
1243 let has_licenses = sbom
1245 .components
1246 .values()
1247 .any(|c| !c.licenses.declared.is_empty() || c.licenses.concluded.is_some());
1248 let has_licensing_profile = sbom
1249 .document
1250 .distribution_classification
1251 .as_ref()
1252 .is_some_and(|p| {
1253 p.to_lowercase().contains("simplelicensing")
1254 || p.to_lowercase().contains("licensing")
1255 });
1256
1257 if has_licenses && !has_licensing_profile {
1258 violations.push(Violation {
1259 severity: ViolationSeverity::Info,
1260 category: ViolationCategory::LicenseInfo,
1261 message:
1262 "[CRA Art. 13(5)] SPDX 3.0 document tracks licenses but does not declare SimpleLicensing profile conformance; declare profileConformance: [\"simpleLicensing\"] for completeness"
1263 .to_string(),
1264 element: None,
1265 requirement: "CRA Art. 13(5): SPDX 3.0 SimpleLicensing profile conformance"
1266 .to_string(),
1267 });
1268 }
1269 }
1270 }
1271
1272 fn check_nist_ssdf(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1274 use crate::model::ExternalRefType;
1275
1276 if sbom.document.creators.is_empty() {
1278 violations.push(Violation {
1279 severity: ViolationSeverity::Error,
1280 category: ViolationCategory::DocumentMetadata,
1281 message:
1282 "SBOM must identify its creator (tool or organization) for provenance tracking"
1283 .to_string(),
1284 element: None,
1285 requirement: "NIST SSDF PS.1: Provenance — creator identification".to_string(),
1286 });
1287 }
1288
1289 let has_tool_creator = sbom
1290 .document
1291 .creators
1292 .iter()
1293 .any(|c| c.creator_type == crate::model::CreatorType::Tool);
1294 if !has_tool_creator {
1295 violations.push(Violation {
1296 severity: ViolationSeverity::Warning,
1297 category: ViolationCategory::DocumentMetadata,
1298 message: "SBOM should identify the generation tool for automated provenance"
1299 .to_string(),
1300 element: None,
1301 requirement: "NIST SSDF PS.1: Provenance — tool identification".to_string(),
1302 });
1303 }
1304
1305 let total = sbom.components.len();
1307 let without_hash = sbom
1308 .components
1309 .values()
1310 .filter(|c| c.hashes.is_empty())
1311 .count();
1312 if without_hash > 0 {
1313 let pct = (without_hash * 100) / total.max(1);
1314 violations.push(Violation {
1315 severity: if pct > 50 {
1316 ViolationSeverity::Error
1317 } else {
1318 ViolationSeverity::Warning
1319 },
1320 category: ViolationCategory::IntegrityInfo,
1321 message: format!(
1322 "{without_hash}/{total} components ({pct}%) missing cryptographic hashes for build integrity"
1323 ),
1324 element: None,
1325 requirement: "NIST SSDF PS.2: Build integrity — component hashes".to_string(),
1326 });
1327 }
1328
1329 let has_vcs_ref = sbom.components.values().any(|comp| {
1331 comp.external_refs
1332 .iter()
1333 .any(|r| matches!(r.ref_type, ExternalRefType::Vcs))
1334 });
1335 if !has_vcs_ref {
1336 violations.push(Violation {
1337 severity: ViolationSeverity::Warning,
1338 category: ViolationCategory::ComponentIdentification,
1339 message: "No components reference a VCS repository; include source repository links for traceability"
1340 .to_string(),
1341 element: None,
1342 requirement: "NIST SSDF PO.1: Source code provenance — VCS references".to_string(),
1343 });
1344 }
1345
1346 let has_build_ref = sbom.components.values().any(|comp| {
1348 comp.external_refs.iter().any(|r| {
1349 matches!(
1350 r.ref_type,
1351 ExternalRefType::BuildMeta | ExternalRefType::BuildSystem
1352 )
1353 })
1354 });
1355 if !has_build_ref {
1356 violations.push(Violation {
1357 severity: ViolationSeverity::Info,
1358 category: ViolationCategory::DocumentMetadata,
1359 message: "No build metadata references found; include build system information for reproducibility"
1360 .to_string(),
1361 element: None,
1362 requirement: "NIST SSDF PO.3: Build provenance — build metadata".to_string(),
1363 });
1364 }
1365
1366 if sbom.components.len() > 1 && sbom.edges.is_empty() {
1368 violations.push(Violation {
1369 severity: ViolationSeverity::Error,
1370 category: ViolationCategory::DependencyInfo,
1371 message: "SBOM with multiple components must include dependency relationships"
1372 .to_string(),
1373 element: None,
1374 requirement: "NIST SSDF PW.4: Dependency management — relationships".to_string(),
1375 });
1376 }
1377
1378 let has_vuln_info = sbom
1380 .components
1381 .values()
1382 .any(|c| !c.vulnerabilities.is_empty());
1383 let has_security_ref = sbom.components.values().any(|comp| {
1384 comp.external_refs.iter().any(|r| {
1385 matches!(
1386 r.ref_type,
1387 ExternalRefType::Advisories
1388 | ExternalRefType::SecurityContact
1389 | ExternalRefType::VulnerabilityAssertion
1390 )
1391 })
1392 });
1393 if !has_vuln_info && !has_security_ref {
1394 violations.push(Violation {
1395 severity: ViolationSeverity::Info,
1396 category: ViolationCategory::SecurityInfo,
1397 message: "No vulnerability or security advisory references found; \
1398 include vulnerability data or security contact for incident response"
1399 .to_string(),
1400 element: None,
1401 requirement: "NIST SSDF PW.6: Vulnerability information".to_string(),
1402 });
1403 }
1404
1405 let without_id = sbom
1407 .components
1408 .values()
1409 .filter(|c| {
1410 c.identifiers.purl.is_none()
1411 && c.identifiers.cpe.is_empty()
1412 && c.identifiers.swid.is_none()
1413 })
1414 .count();
1415 if without_id > 0 {
1416 violations.push(Violation {
1417 severity: ViolationSeverity::Warning,
1418 category: ViolationCategory::ComponentIdentification,
1419 message: format!(
1420 "{without_id}/{total} components missing unique identifier (PURL/CPE/SWID)"
1421 ),
1422 element: None,
1423 requirement: "NIST SSDF RV.1: Component identification — unique identifiers"
1424 .to_string(),
1425 });
1426 }
1427
1428 let without_supplier = sbom
1430 .components
1431 .values()
1432 .filter(|c| c.supplier.is_none() && c.author.is_none())
1433 .count();
1434 if without_supplier > 0 {
1435 violations.push(Violation {
1436 severity: ViolationSeverity::Warning,
1437 category: ViolationCategory::SupplierInfo,
1438 message: format!(
1439 "{without_supplier}/{total} components missing supplier/author information"
1440 ),
1441 element: None,
1442 requirement: "NIST SSDF PS.3: Supplier identification".to_string(),
1443 });
1444 }
1445 }
1446
1447 fn check_eo14028(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1449 use crate::model::ExternalRefType;
1450
1451 let format_ok = match sbom.document.format {
1453 crate::model::SbomFormat::CycloneDx => {
1454 let v = &sbom.document.spec_version;
1455 !(v.starts_with("1.0")
1456 || v.starts_with("1.1")
1457 || v.starts_with("1.2")
1458 || v.starts_with("1.3"))
1459 }
1460 crate::model::SbomFormat::Spdx => {
1461 let v = &sbom.document.spec_version;
1462 v.starts_with("2.3") || v.starts_with("3.")
1463 }
1464 };
1465 if !format_ok {
1466 violations.push(Violation {
1467 severity: ViolationSeverity::Error,
1468 category: ViolationCategory::FormatSpecific,
1469 message: format!(
1470 "SBOM format {} {} does not meet EO 14028 machine-readable requirements; \
1471 use CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+",
1472 sbom.document.format, sbom.document.spec_version
1473 ),
1474 element: None,
1475 requirement: "EO 14028 Sec 4(e): Machine-readable SBOM format".to_string(),
1476 });
1477 }
1478
1479 let has_tool = sbom
1481 .document
1482 .creators
1483 .iter()
1484 .any(|c| c.creator_type == crate::model::CreatorType::Tool);
1485 if !has_tool {
1486 violations.push(Violation {
1487 severity: ViolationSeverity::Warning,
1488 category: ViolationCategory::DocumentMetadata,
1489 message: "SBOM should be auto-generated by a tool; no tool creator identified"
1490 .to_string(),
1491 element: None,
1492 requirement: "EO 14028 Sec 4(e): Automated SBOM generation".to_string(),
1493 });
1494 }
1495
1496 if sbom.document.creators.is_empty() {
1498 violations.push(Violation {
1499 severity: ViolationSeverity::Error,
1500 category: ViolationCategory::DocumentMetadata,
1501 message: "SBOM must identify its creator (vendor or tool)".to_string(),
1502 element: None,
1503 requirement: "EO 14028 Sec 4(e): SBOM creator identification".to_string(),
1504 });
1505 }
1506
1507 let total = sbom.components.len();
1509 let without_id = sbom
1510 .components
1511 .values()
1512 .filter(|c| {
1513 c.identifiers.purl.is_none()
1514 && c.identifiers.cpe.is_empty()
1515 && c.identifiers.swid.is_none()
1516 })
1517 .count();
1518 if without_id > 0 {
1519 violations.push(Violation {
1520 severity: ViolationSeverity::Error,
1521 category: ViolationCategory::ComponentIdentification,
1522 message: format!(
1523 "{without_id}/{total} components missing unique identifier (PURL/CPE/SWID)"
1524 ),
1525 element: None,
1526 requirement: "EO 14028 Sec 4(e): Component unique identification".to_string(),
1527 });
1528 }
1529
1530 if sbom.components.len() > 1 && sbom.edges.is_empty() {
1532 violations.push(Violation {
1533 severity: ViolationSeverity::Error,
1534 category: ViolationCategory::DependencyInfo,
1535 message: "SBOM with multiple components must include dependency relationships"
1536 .to_string(),
1537 element: None,
1538 requirement: "EO 14028 Sec 4(e): Dependency relationships".to_string(),
1539 });
1540 }
1541
1542 let without_version = sbom
1544 .components
1545 .values()
1546 .filter(|c| c.version.is_none())
1547 .count();
1548 if without_version > 0 {
1549 violations.push(Violation {
1550 severity: ViolationSeverity::Error,
1551 category: ViolationCategory::ComponentIdentification,
1552 message: format!(
1553 "{without_version}/{total} components missing version information"
1554 ),
1555 element: None,
1556 requirement: "EO 14028 Sec 4(e): Component version".to_string(),
1557 });
1558 }
1559
1560 let without_hash = sbom
1562 .components
1563 .values()
1564 .filter(|c| c.hashes.is_empty())
1565 .count();
1566 if without_hash > 0 {
1567 violations.push(Violation {
1568 severity: ViolationSeverity::Warning,
1569 category: ViolationCategory::IntegrityInfo,
1570 message: format!("{without_hash}/{total} components missing cryptographic hashes"),
1571 element: None,
1572 requirement: "EO 14028 Sec 4(e): Component integrity verification".to_string(),
1573 });
1574 }
1575
1576 let has_security_ref = sbom.document.security_contact.is_some()
1578 || sbom.document.vulnerability_disclosure_url.is_some()
1579 || sbom.components.values().any(|comp| {
1580 comp.external_refs.iter().any(|r| {
1581 matches!(
1582 r.ref_type,
1583 ExternalRefType::SecurityContact | ExternalRefType::Advisories
1584 )
1585 })
1586 });
1587 if !has_security_ref {
1588 violations.push(Violation {
1589 severity: ViolationSeverity::Warning,
1590 category: ViolationCategory::SecurityInfo,
1591 message: "No security contact or vulnerability disclosure reference found"
1592 .to_string(),
1593 element: None,
1594 requirement: "EO 14028 Sec 4(g): Vulnerability disclosure process".to_string(),
1595 });
1596 }
1597
1598 let without_supplier = sbom
1600 .components
1601 .values()
1602 .filter(|c| c.supplier.is_none() && c.author.is_none())
1603 .count();
1604 if without_supplier > 0 {
1605 let pct = (without_supplier * 100) / total.max(1);
1606 if pct > 30 {
1607 violations.push(Violation {
1608 severity: ViolationSeverity::Warning,
1609 category: ViolationCategory::SupplierInfo,
1610 message: format!(
1611 "{without_supplier}/{total} components ({pct}%) missing supplier information"
1612 ),
1613 element: None,
1614 requirement: "EO 14028 Sec 4(e): Supplier identification".to_string(),
1615 });
1616 }
1617 }
1618 }
1619
1620 fn check_format_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1621 match sbom.document.format {
1622 SbomFormat::CycloneDx => {
1623 self.check_cyclonedx_specific(sbom, violations);
1624 }
1625 SbomFormat::Spdx => {
1626 self.check_spdx_specific(sbom, violations);
1627 }
1628 }
1629 }
1630
1631 fn check_cyclonedx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1632 let version = &sbom.document.spec_version;
1634
1635 if version.starts_with("1.3") || version.starts_with("1.2") || version.starts_with("1.1") {
1637 violations.push(Violation {
1638 severity: ViolationSeverity::Info,
1639 category: ViolationCategory::FormatSpecific,
1640 message: format!("CycloneDX {version} is outdated, consider upgrading to 1.7+"),
1641 element: None,
1642 requirement: "Current CycloneDX version".to_string(),
1643 });
1644 }
1645
1646 for comp in sbom.components.values() {
1648 if comp.identifiers.format_id == comp.name {
1649 violations.push(Violation {
1651 severity: ViolationSeverity::Info,
1652 category: ViolationCategory::FormatSpecific,
1653 message: format!("Component '{}' may be missing bom-ref", comp.name),
1654 element: Some(comp.name.clone()),
1655 requirement: "CycloneDX: bom-ref for dependency tracking".to_string(),
1656 });
1657 }
1658 }
1659 }
1660
1661 fn check_spdx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1662 let version = &sbom.document.spec_version;
1664
1665 if !version.starts_with("2.") && !version.starts_with("3.") {
1667 violations.push(Violation {
1668 severity: ViolationSeverity::Warning,
1669 category: ViolationCategory::FormatSpecific,
1670 message: format!("Unknown SPDX version: {version}"),
1671 element: None,
1672 requirement: "Valid SPDX version".to_string(),
1673 });
1674 }
1675
1676 let is_spdx3 = version.starts_with("3.");
1679 for comp in sbom.components.values() {
1680 let valid_id = if is_spdx3 {
1681 comp.identifiers.format_id.contains(':')
1683 } else {
1684 comp.identifiers.format_id.starts_with("SPDXRef-")
1685 };
1686 if !valid_id {
1687 let expected = if is_spdx3 {
1688 "SPDX 3.0: URN/IRI identifier format"
1689 } else {
1690 "SPDX 2.x: SPDXRef- identifier format"
1691 };
1692 violations.push(Violation {
1693 severity: ViolationSeverity::Info,
1694 category: ViolationCategory::FormatSpecific,
1695 message: format!(
1696 "Component '{}' has non-standard SPDX identifier format",
1697 comp.name
1698 ),
1699 element: Some(comp.name.clone()),
1700 requirement: expected.to_string(),
1701 });
1702 }
1703 }
1704 }
1705}
1706
1707impl Default for ComplianceChecker {
1708 fn default() -> Self {
1709 Self::new(ComplianceLevel::Standard)
1710 }
1711}
1712
1713fn is_valid_email_format(email: &str) -> bool {
1715 if email.contains(' ') || email.is_empty() {
1717 return false;
1718 }
1719
1720 let parts: Vec<&str> = email.split('@').collect();
1721 if parts.len() != 2 {
1722 return false;
1723 }
1724
1725 let local = parts[0];
1726 let domain = parts[1];
1727
1728 if local.is_empty() {
1730 return false;
1731 }
1732
1733 if domain.is_empty()
1735 || !domain.contains('.')
1736 || domain.starts_with('.')
1737 || domain.ends_with('.')
1738 {
1739 return false;
1740 }
1741
1742 true
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747 use super::*;
1748
1749 #[test]
1750 fn test_compliance_level_names() {
1751 assert_eq!(ComplianceLevel::Minimum.name(), "Minimum");
1752 assert_eq!(ComplianceLevel::NtiaMinimum.name(), "NTIA Minimum Elements");
1753 assert_eq!(ComplianceLevel::CraPhase1.name(), "EU CRA Phase 1 (2027)");
1754 assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2029)");
1755 assert_eq!(ComplianceLevel::NistSsdf.name(), "NIST SSDF (SP 800-218)");
1756 assert_eq!(ComplianceLevel::Eo14028.name(), "EO 14028 Section 4");
1757 }
1758
1759 #[test]
1760 fn test_nist_ssdf_empty_sbom() {
1761 let sbom = NormalizedSbom::default();
1762 let checker = ComplianceChecker::new(ComplianceLevel::NistSsdf);
1763 let result = checker.check(&sbom);
1764 assert!(
1766 result
1767 .violations
1768 .iter()
1769 .any(|v| v.requirement.contains("PS.1"))
1770 );
1771 }
1772
1773 #[test]
1774 fn test_eo14028_empty_sbom() {
1775 let sbom = NormalizedSbom::default();
1776 let checker = ComplianceChecker::new(ComplianceLevel::Eo14028);
1777 let result = checker.check(&sbom);
1778 assert!(
1779 result
1780 .violations
1781 .iter()
1782 .any(|v| v.requirement.contains("EO 14028"))
1783 );
1784 }
1785
1786 #[test]
1787 fn test_compliance_result_counts() {
1788 let violations = vec![
1789 Violation {
1790 severity: ViolationSeverity::Error,
1791 category: ViolationCategory::ComponentIdentification,
1792 message: "Error 1".to_string(),
1793 element: None,
1794 requirement: "Test".to_string(),
1795 },
1796 Violation {
1797 severity: ViolationSeverity::Warning,
1798 category: ViolationCategory::LicenseInfo,
1799 message: "Warning 1".to_string(),
1800 element: None,
1801 requirement: "Test".to_string(),
1802 },
1803 Violation {
1804 severity: ViolationSeverity::Info,
1805 category: ViolationCategory::FormatSpecific,
1806 message: "Info 1".to_string(),
1807 element: None,
1808 requirement: "Test".to_string(),
1809 },
1810 ];
1811
1812 let result = ComplianceResult::new(ComplianceLevel::Standard, violations);
1813 assert!(!result.is_compliant);
1814 assert_eq!(result.error_count, 1);
1815 assert_eq!(result.warning_count, 1);
1816 assert_eq!(result.info_count, 1);
1817 }
1818}