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 Comprehensive,
53}
54
55impl ComplianceLevel {
56 #[must_use]
58 pub const fn name(&self) -> &'static str {
59 match self {
60 Self::Minimum => "Minimum",
61 Self::Standard => "Standard",
62 Self::NtiaMinimum => "NTIA Minimum Elements",
63 Self::CraPhase1 => "EU CRA Phase 1 (2027)",
64 Self::CraPhase2 => "EU CRA Phase 2 (2029)",
65 Self::FdaMedicalDevice => "FDA Medical Device",
66 Self::Comprehensive => "Comprehensive",
67 }
68 }
69
70 #[must_use]
72 pub const fn description(&self) -> &'static str {
73 match self {
74 Self::Minimum => "Basic component identification only",
75 Self::Standard => "Recommended fields for general use",
76 Self::NtiaMinimum => "NTIA minimum elements for software transparency",
77 Self::CraPhase1 => "CRA reporting obligations — product ID, SBOM format, manufacturer (deadline: 11 Dec 2027)",
78 Self::CraPhase2 => "Full CRA compliance — adds vulnerability metadata, lifecycle, disclosure (deadline: 11 Dec 2029)",
79 Self::FdaMedicalDevice => "FDA premarket submission requirements for medical devices",
80 Self::Comprehensive => "All recommended fields and best practices",
81 }
82 }
83
84 #[must_use]
86 pub const fn all() -> &'static [Self] {
87 &[
88 Self::Minimum,
89 Self::Standard,
90 Self::NtiaMinimum,
91 Self::CraPhase1,
92 Self::CraPhase2,
93 Self::FdaMedicalDevice,
94 Self::Comprehensive,
95 ]
96 }
97
98 #[must_use]
100 pub const fn is_cra(&self) -> bool {
101 matches!(self, Self::CraPhase1 | Self::CraPhase2)
102 }
103
104 #[must_use]
106 pub const fn cra_phase(&self) -> Option<CraPhase> {
107 match self {
108 Self::CraPhase1 => Some(CraPhase::Phase1),
109 Self::CraPhase2 => Some(CraPhase::Phase2),
110 _ => None,
111 }
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Violation {
118 pub severity: ViolationSeverity,
120 pub category: ViolationCategory,
122 pub message: String,
124 pub element: Option<String>,
126 pub requirement: String,
128}
129
130impl Violation {
131 #[must_use]
133 pub fn remediation_guidance(&self) -> &'static str {
134 let req = self.requirement.to_lowercase();
135 if req.contains("art. 13(4)") {
136 "Ensure the SBOM is produced in CycloneDX 1.4+ (JSON or XML) or SPDX 2.3+ (JSON or tag-value). Older format versions may not be recognized as machine-readable under the CRA."
137 } else if req.contains("art. 13(6)") && req.contains("vulnerability metadata") {
138 "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."
139 } else if req.contains("art. 13(6)") {
140 "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."
141 } else if req.contains("art. 13(7)") {
142 "Reference a coordinated vulnerability disclosure policy. CycloneDX: add an externalReference of type 'advisories' linking to your disclosure policy. SPDX: add an external document reference."
143 } else if req.contains("art. 13(8)") {
144 "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."
145 } else if req.contains("art. 13(11)") {
146 "Include lifecycle or end-of-support metadata for components. CycloneDX: use component properties (e.g., cdx:lifecycle:status). SPDX: use annotations."
147 } else if req.contains("art. 13(12)") && req.contains("version") {
148 "Every component must have a version string. Use the actual release version (e.g., '1.2.3'), not a range or placeholder."
149 } else if req.contains("art. 13(12)") {
150 "The SBOM must identify the product by name. CycloneDX: set metadata.component.name. SPDX: set documentDescribes with the primary package name."
151 } else if req.contains("art. 13(15)") && req.contains("email") {
152 "Provide a valid contact email for the manufacturer. The email must contain an @ sign with valid local and domain parts."
153 } else if req.contains("art. 13(15)") {
154 "Identify the manufacturer/supplier. CycloneDX: set metadata.manufacturer or component.supplier. SPDX: set PackageSupplier."
155 } else if req.contains("annex vii") {
156 "Reference the EU Declaration of Conformity. CycloneDX: add an externalReference of type 'attestation' or 'certification'. SPDX: add an external document reference."
157 } else if req.contains("annex i") && req.contains("identifier") {
158 "Add a PURL, CPE, or SWID tag to each component for unique identification. PURLs are preferred (e.g., pkg:npm/lodash@4.17.21)."
159 } else if req.contains("annex i") && req.contains("dependency") {
160 "Add dependency relationships between components. CycloneDX: use the dependencies array. SPDX: use DEPENDS_ON relationships."
161 } else if req.contains("annex i") && req.contains("primary") {
162 "Identify the top-level product component. CycloneDX: set metadata.component. SPDX: use documentDescribes to point to the primary package."
163 } else if req.contains("annex i") && req.contains("hash") {
164 "Add cryptographic hashes (SHA-256 or stronger) to components for integrity verification."
165 } else if req.contains("annex i") && req.contains("traceability") {
166 "The primary product component needs a stable unique identifier (PURL or CPE) that persists across software updates for traceability."
167 } else {
168 "Review the requirement and update the SBOM accordingly. Consult the EU CRA regulation (EU 2024/2847) for detailed guidance."
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175pub enum ViolationSeverity {
176 Error,
178 Warning,
180 Info,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186pub enum ViolationCategory {
187 DocumentMetadata,
189 ComponentIdentification,
191 DependencyInfo,
193 LicenseInfo,
195 SupplierInfo,
197 IntegrityInfo,
199 SecurityInfo,
201 FormatSpecific,
203}
204
205impl ViolationCategory {
206 #[must_use]
207 pub const fn name(&self) -> &'static str {
208 match self {
209 Self::DocumentMetadata => "Document Metadata",
210 Self::ComponentIdentification => "Component Identification",
211 Self::DependencyInfo => "Dependency Information",
212 Self::LicenseInfo => "License Information",
213 Self::SupplierInfo => "Supplier Information",
214 Self::IntegrityInfo => "Integrity Information",
215 Self::SecurityInfo => "Security Information",
216 Self::FormatSpecific => "Format-Specific",
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct ComplianceResult {
224 pub is_compliant: bool,
226 pub level: ComplianceLevel,
228 pub violations: Vec<Violation>,
230 pub error_count: usize,
232 pub warning_count: usize,
234 pub info_count: usize,
236}
237
238impl ComplianceResult {
239 #[must_use]
241 pub fn new(level: ComplianceLevel, violations: Vec<Violation>) -> Self {
242 let error_count = violations
243 .iter()
244 .filter(|v| v.severity == ViolationSeverity::Error)
245 .count();
246 let warning_count = violations
247 .iter()
248 .filter(|v| v.severity == ViolationSeverity::Warning)
249 .count();
250 let info_count = violations
251 .iter()
252 .filter(|v| v.severity == ViolationSeverity::Info)
253 .count();
254
255 Self {
256 is_compliant: error_count == 0,
257 level,
258 violations,
259 error_count,
260 warning_count,
261 info_count,
262 }
263 }
264
265 #[must_use]
267 pub fn violations_by_severity(&self, severity: ViolationSeverity) -> Vec<&Violation> {
268 self.violations
269 .iter()
270 .filter(|v| v.severity == severity)
271 .collect()
272 }
273
274 #[must_use]
276 pub fn violations_by_category(&self, category: ViolationCategory) -> Vec<&Violation> {
277 self.violations
278 .iter()
279 .filter(|v| v.category == category)
280 .collect()
281 }
282}
283
284#[derive(Debug, Clone)]
286pub struct ComplianceChecker {
287 level: ComplianceLevel,
289}
290
291impl ComplianceChecker {
292 #[must_use]
294 pub const fn new(level: ComplianceLevel) -> Self {
295 Self { level }
296 }
297
298 #[must_use]
300 pub fn check(&self, sbom: &NormalizedSbom) -> ComplianceResult {
301 let mut violations = Vec::new();
302
303 self.check_document_metadata(sbom, &mut violations);
305
306 self.check_components(sbom, &mut violations);
308
309 self.check_dependencies(sbom, &mut violations);
311
312 self.check_vulnerability_metadata(sbom, &mut violations);
314
315 self.check_format_specific(sbom, &mut violations);
317
318 ComplianceResult::new(self.level, violations)
319 }
320
321 fn check_document_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
322 use crate::model::{CreatorType, ExternalRefType};
323
324 if sbom.document.creators.is_empty() {
326 violations.push(Violation {
327 severity: match self.level {
328 ComplianceLevel::Minimum => ViolationSeverity::Warning,
329 _ => ViolationSeverity::Error,
330 },
331 category: ViolationCategory::DocumentMetadata,
332 message: "SBOM must have creator/tool information".to_string(),
333 element: None,
334 requirement: "Document creator identification".to_string(),
335 });
336 }
337
338 if self.level.is_cra() {
340 let has_org = sbom
341 .document
342 .creators
343 .iter()
344 .any(|c| c.creator_type == CreatorType::Organization);
345 if !has_org {
346 violations.push(Violation {
347 severity: ViolationSeverity::Warning,
348 category: ViolationCategory::DocumentMetadata,
349 message: "[CRA Art. 13(15)] SBOM should identify the manufacturer (organization)"
350 .to_string(),
351 element: None,
352 requirement: "CRA Art. 13(15): Manufacturer identification".to_string(),
353 });
354 }
355
356 for creator in &sbom.document.creators {
358 if creator.creator_type == CreatorType::Organization {
359 if let Some(email) = &creator.email {
360 if !is_valid_email_format(email) {
361 violations.push(Violation {
362 severity: ViolationSeverity::Warning,
363 category: ViolationCategory::DocumentMetadata,
364 message: format!(
365 "[CRA Art. 13(15)] Manufacturer email '{email}' appears invalid"
366 ),
367 element: None,
368 requirement: "CRA Art. 13(15): Valid contact information".to_string(),
369 });
370 }
371 }
372 }
373 }
374
375 if sbom.document.name.is_none() {
376 violations.push(Violation {
377 severity: ViolationSeverity::Warning,
378 category: ViolationCategory::DocumentMetadata,
379 message: "[CRA Art. 13(12)] SBOM should include the product name".to_string(),
380 element: None,
381 requirement: "CRA Art. 13(12): Product identification".to_string(),
382 });
383 }
384
385 let has_doc_security_contact = sbom.document.security_contact.is_some()
388 || sbom.document.vulnerability_disclosure_url.is_some();
389
390 let has_component_security_contact = sbom.components.values().any(|comp| {
392 comp.external_refs.iter().any(|r| {
393 matches!(
394 r.ref_type,
395 ExternalRefType::SecurityContact
396 | ExternalRefType::Support
397 | ExternalRefType::Advisories
398 )
399 })
400 });
401
402 if !has_doc_security_contact && !has_component_security_contact {
403 violations.push(Violation {
404 severity: ViolationSeverity::Warning,
405 category: ViolationCategory::SecurityInfo,
406 message: "[CRA Art. 13(6)] SBOM should include a security contact or vulnerability disclosure reference".to_string(),
407 element: None,
408 requirement: "CRA Art. 13(6): Vulnerability disclosure contact".to_string(),
409 });
410 }
411
412 if sbom.primary_component_id.is_none() && sbom.components.len() > 1 {
414 violations.push(Violation {
415 severity: ViolationSeverity::Warning,
416 category: ViolationCategory::DocumentMetadata,
417 message: "[CRA Annex I] SBOM should identify the primary product component (CycloneDX metadata.component or SPDX documentDescribes)".to_string(),
418 element: None,
419 requirement: "CRA Annex I: Primary product identification".to_string(),
420 });
421 }
422
423 if sbom.document.support_end_date.is_none() {
425 violations.push(Violation {
426 severity: ViolationSeverity::Info,
427 category: ViolationCategory::SecurityInfo,
428 message: "[CRA Art. 13(8)] Consider specifying a support end date for security updates".to_string(),
429 element: None,
430 requirement: "CRA Art. 13(8): Support period disclosure".to_string(),
431 });
432 }
433
434 let format_ok = match sbom.document.format {
438 SbomFormat::CycloneDx => {
439 let v = &sbom.document.spec_version;
440 !(v.starts_with("1.0")
441 || v.starts_with("1.1")
442 || v.starts_with("1.2")
443 || v.starts_with("1.3"))
444 }
445 SbomFormat::Spdx => {
446 let v = &sbom.document.spec_version;
447 v.starts_with("2.3") || v.starts_with("3.")
448 }
449 };
450 if !format_ok {
451 violations.push(Violation {
452 severity: ViolationSeverity::Warning,
453 category: ViolationCategory::FormatSpecific,
454 message: format!(
455 "[CRA Art. 13(4)] SBOM format version {} {} may not meet CRA machine-readable requirements; use CycloneDX 1.4+ or SPDX 2.3+",
456 sbom.document.format, sbom.document.spec_version
457 ),
458 element: None,
459 requirement: "CRA Art. 13(4): Machine-readable SBOM format".to_string(),
460 });
461 }
462
463 if let Some(ref primary_id) = sbom.primary_component_id {
467 if let Some(primary) = sbom.components.get(primary_id) {
468 if primary.identifiers.purl.is_none() && primary.identifiers.cpe.is_empty() {
469 violations.push(Violation {
470 severity: ViolationSeverity::Warning,
471 category: ViolationCategory::ComponentIdentification,
472 message: format!(
473 "[CRA Annex I, Part II] Primary component '{}' missing unique identifier (PURL/CPE) for cross-update traceability",
474 primary.name
475 ),
476 element: Some(primary.name.clone()),
477 requirement: "CRA Annex I, Part II, 1: Product identifier traceability across updates".to_string(),
478 });
479 }
480 }
481 }
482 }
483
484 if matches!(self.level, ComplianceLevel::CraPhase2) {
486 let has_vuln_disclosure_policy = sbom.document.vulnerability_disclosure_url.is_some()
489 || sbom.components.values().any(|comp| {
490 comp.external_refs
491 .iter()
492 .any(|r| matches!(r.ref_type, ExternalRefType::Advisories))
493 });
494 if !has_vuln_disclosure_policy {
495 violations.push(Violation {
496 severity: ViolationSeverity::Warning,
497 category: ViolationCategory::SecurityInfo,
498 message: "[CRA Art. 13(7)] SBOM should reference a coordinated vulnerability disclosure policy (advisories URL or disclosure URL)".to_string(),
499 element: None,
500 requirement: "CRA Art. 13(7): Coordinated vulnerability disclosure policy".to_string(),
501 });
502 }
503
504 let has_lifecycle_info = sbom.document.support_end_date.is_some()
509 || sbom.components.values().any(|comp| {
510 comp.extensions.properties.iter().any(|p| {
511 let name_lower = p.name.to_lowercase();
512 name_lower.contains("lifecycle")
513 || name_lower.contains("end-of-life")
514 || name_lower.contains("eol")
515 || name_lower.contains("end-of-support")
516 })
517 });
518 if !has_lifecycle_info {
519 violations.push(Violation {
520 severity: ViolationSeverity::Info,
521 category: ViolationCategory::SecurityInfo,
522 message: "[CRA Art. 13(11)] Consider including component lifecycle/end-of-support information".to_string(),
523 element: None,
524 requirement: "CRA Art. 13(11): Component lifecycle status".to_string(),
525 });
526 }
527
528 let has_conformity_ref = sbom.components.values().any(|comp| {
531 comp.external_refs.iter().any(|r| {
532 matches!(
533 r.ref_type,
534 ExternalRefType::Attestation | ExternalRefType::Certification
535 ) || (matches!(r.ref_type, ExternalRefType::Other(ref s) if s.to_lowercase().contains("declaration-of-conformity"))
536 )
537 })
538 });
539 if !has_conformity_ref {
540 violations.push(Violation {
541 severity: ViolationSeverity::Info,
542 category: ViolationCategory::DocumentMetadata,
543 message: "[CRA Annex VII] Consider including a reference to the EU Declaration of Conformity (attestation or certification external reference)".to_string(),
544 element: None,
545 requirement: "CRA Annex VII: EU Declaration of Conformity reference".to_string(),
546 });
547 }
548 }
549
550 if matches!(self.level, ComplianceLevel::FdaMedicalDevice) {
552 let has_org = sbom
553 .document
554 .creators
555 .iter()
556 .any(|c| c.creator_type == CreatorType::Organization);
557 if !has_org {
558 violations.push(Violation {
559 severity: ViolationSeverity::Warning,
560 category: ViolationCategory::DocumentMetadata,
561 message: "FDA: SBOM should have manufacturer (organization) as creator"
562 .to_string(),
563 element: None,
564 requirement: "FDA: Manufacturer identification".to_string(),
565 });
566 }
567
568 let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
570 if !has_contact {
571 violations.push(Violation {
572 severity: ViolationSeverity::Warning,
573 category: ViolationCategory::DocumentMetadata,
574 message: "FDA: SBOM creators should include contact email".to_string(),
575 element: None,
576 requirement: "FDA: Contact information".to_string(),
577 });
578 }
579
580 if sbom.document.name.is_none() {
582 violations.push(Violation {
583 severity: ViolationSeverity::Warning,
584 category: ViolationCategory::DocumentMetadata,
585 message: "FDA: SBOM should have a document name/title".to_string(),
586 element: None,
587 requirement: "FDA: Document identification".to_string(),
588 });
589 }
590 }
591
592 if matches!(
594 self.level,
595 ComplianceLevel::NtiaMinimum | ComplianceLevel::Comprehensive
596 ) {
597 }
600
601 if matches!(
603 self.level,
604 ComplianceLevel::Standard
605 | ComplianceLevel::FdaMedicalDevice
606 | ComplianceLevel::CraPhase1
607 | ComplianceLevel::CraPhase2
608 | ComplianceLevel::Comprehensive
609 ) && sbom.document.serial_number.is_none()
610 {
611 violations.push(Violation {
612 severity: ViolationSeverity::Warning,
613 category: ViolationCategory::DocumentMetadata,
614 message: "SBOM should have a serial number/unique identifier".to_string(),
615 element: None,
616 requirement: "Document unique identification".to_string(),
617 });
618 }
619 }
620
621 fn check_components(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
622 use crate::model::HashAlgorithm;
623
624 for comp in sbom.components.values() {
625 if comp.name.is_empty() {
628 violations.push(Violation {
629 severity: ViolationSeverity::Error,
630 category: ViolationCategory::ComponentIdentification,
631 message: "Component must have a name".to_string(),
632 element: Some(comp.identifiers.format_id.clone()),
633 requirement: "Component name (required)".to_string(),
634 });
635 }
636
637 if matches!(
639 self.level,
640 ComplianceLevel::NtiaMinimum
641 | ComplianceLevel::FdaMedicalDevice
642 | ComplianceLevel::Standard
643 | ComplianceLevel::CraPhase1
644 | ComplianceLevel::CraPhase2
645 | ComplianceLevel::Comprehensive
646 ) && comp.version.is_none()
647 {
648 let (req, msg) = match self.level {
649 ComplianceLevel::FdaMedicalDevice => (
650 "FDA: Component version".to_string(),
651 format!("Component '{}' missing version", comp.name),
652 ),
653 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
654 "CRA Art. 13(12): Component version".to_string(),
655 format!("[CRA Art. 13(12)] Component '{}' missing version", comp.name),
656 ),
657 _ => (
658 "NTIA: Component version".to_string(),
659 format!("Component '{}' missing version", comp.name),
660 ),
661 };
662 violations.push(Violation {
663 severity: ViolationSeverity::Error,
664 category: ViolationCategory::ComponentIdentification,
665 message: msg,
666 element: Some(comp.name.clone()),
667 requirement: req,
668 });
669 }
670
671 if matches!(
673 self.level,
674 ComplianceLevel::Standard
675 | ComplianceLevel::FdaMedicalDevice
676 | ComplianceLevel::CraPhase1
677 | ComplianceLevel::CraPhase2
678 | ComplianceLevel::Comprehensive
679 ) && comp.identifiers.purl.is_none()
680 && comp.identifiers.cpe.is_empty()
681 && comp.identifiers.swid.is_none()
682 {
683 let severity = if matches!(
684 self.level,
685 ComplianceLevel::FdaMedicalDevice | ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2
686 ) {
687 ViolationSeverity::Error
688 } else {
689 ViolationSeverity::Warning
690 };
691 let (message, requirement) = match self.level {
692 ComplianceLevel::FdaMedicalDevice => (
693 format!(
694 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
695 comp.name
696 ),
697 "FDA: Unique component identifier".to_string(),
698 ),
699 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
700 format!(
701 "[CRA Annex I] Component '{}' missing unique identifier (PURL/CPE/SWID)",
702 comp.name
703 ),
704 "CRA Annex I: Unique component identifier (PURL/CPE/SWID)".to_string(),
705 ),
706 _ => (
707 format!(
708 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
709 comp.name
710 ),
711 "Standard identifier (PURL/CPE)".to_string(),
712 ),
713 };
714 violations.push(Violation {
715 severity,
716 category: ViolationCategory::ComponentIdentification,
717 message,
718 element: Some(comp.name.clone()),
719 requirement,
720 });
721 }
722
723 if matches!(
725 self.level,
726 ComplianceLevel::NtiaMinimum
727 | ComplianceLevel::FdaMedicalDevice
728 | ComplianceLevel::CraPhase1
729 | ComplianceLevel::CraPhase2
730 | ComplianceLevel::Comprehensive
731 ) && comp.supplier.is_none()
732 && comp.author.is_none()
733 {
734 let severity = match self.level {
735 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => ViolationSeverity::Warning,
736 _ => ViolationSeverity::Error,
737 };
738 let (message, requirement) = match self.level {
739 ComplianceLevel::FdaMedicalDevice => (
740 format!("Component '{}' missing supplier/manufacturer", comp.name),
741 "FDA: Supplier/manufacturer information".to_string(),
742 ),
743 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
744 format!(
745 "[CRA Art. 13(15)] Component '{}' missing supplier/manufacturer",
746 comp.name
747 ),
748 "CRA Art. 13(15): Supplier/manufacturer information".to_string(),
749 ),
750 _ => (
751 format!("Component '{}' missing supplier/manufacturer", comp.name),
752 "NTIA: Supplier information".to_string(),
753 ),
754 };
755 violations.push(Violation {
756 severity,
757 category: ViolationCategory::SupplierInfo,
758 message,
759 element: Some(comp.name.clone()),
760 requirement,
761 });
762 }
763
764 if matches!(
766 self.level,
767 ComplianceLevel::Standard | ComplianceLevel::Comprehensive
768 ) && comp.licenses.declared.is_empty()
769 && comp.licenses.concluded.is_none()
770 {
771 violations.push(Violation {
772 severity: ViolationSeverity::Warning,
773 category: ViolationCategory::LicenseInfo,
774 message: format!(
775 "Component '{}' should have license information",
776 comp.name
777 ),
778 element: Some(comp.name.clone()),
779 requirement: "License declaration".to_string(),
780 });
781 }
782
783 if matches!(
785 self.level,
786 ComplianceLevel::FdaMedicalDevice | ComplianceLevel::Comprehensive
787 ) {
788 if comp.hashes.is_empty() {
789 violations.push(Violation {
790 severity: if self.level == ComplianceLevel::FdaMedicalDevice {
791 ViolationSeverity::Error
792 } else {
793 ViolationSeverity::Warning
794 },
795 category: ViolationCategory::IntegrityInfo,
796 message: format!("Component '{}' missing cryptographic hash", comp.name),
797 element: Some(comp.name.clone()),
798 requirement: if self.level == ComplianceLevel::FdaMedicalDevice {
799 "FDA: Cryptographic hash for integrity".to_string()
800 } else {
801 "Integrity verification (hashes)".to_string()
802 },
803 });
804 } else if self.level == ComplianceLevel::FdaMedicalDevice {
805 let has_strong_hash = comp.hashes.iter().any(|h| {
807 matches!(
808 h.algorithm,
809 HashAlgorithm::Sha256
810 | HashAlgorithm::Sha384
811 | HashAlgorithm::Sha512
812 | HashAlgorithm::Sha3_256
813 | HashAlgorithm::Sha3_384
814 | HashAlgorithm::Sha3_512
815 | HashAlgorithm::Blake2b256
816 | HashAlgorithm::Blake2b384
817 | HashAlgorithm::Blake2b512
818 | HashAlgorithm::Blake3
819 )
820 });
821 if !has_strong_hash {
822 violations.push(Violation {
823 severity: ViolationSeverity::Warning,
824 category: ViolationCategory::IntegrityInfo,
825 message: format!(
826 "Component '{}' has only weak hash algorithm (use SHA-256+)",
827 comp.name
828 ),
829 element: Some(comp.name.clone()),
830 requirement: "FDA: Strong cryptographic hash (SHA-256 or better)"
831 .to_string(),
832 });
833 }
834 }
835 }
836
837 if self.level.is_cra() && comp.hashes.is_empty() {
839 violations.push(Violation {
840 severity: ViolationSeverity::Info,
841 category: ViolationCategory::IntegrityInfo,
842 message: format!(
843 "[CRA Annex I] Component '{}' missing cryptographic hash (recommended for integrity)",
844 comp.name
845 ),
846 element: Some(comp.name.clone()),
847 requirement: "CRA Annex I: Component integrity information (hash)".to_string(),
848 });
849 }
850 }
851 }
852
853 fn check_dependencies(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
854 if matches!(
856 self.level,
857 ComplianceLevel::NtiaMinimum
858 | ComplianceLevel::FdaMedicalDevice
859 | ComplianceLevel::CraPhase1
860 | ComplianceLevel::CraPhase2
861 | ComplianceLevel::Comprehensive
862 ) {
863 let has_deps = !sbom.edges.is_empty();
864 let has_multiple_components = sbom.components.len() > 1;
865
866 if has_multiple_components && !has_deps {
867 let (message, requirement) = match self.level {
868 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
869 "[CRA Annex I] SBOM with multiple components must include dependency relationships".to_string(),
870 "CRA Annex I: Dependency relationships".to_string(),
871 ),
872 _ => (
873 "SBOM with multiple components must include dependency relationships".to_string(),
874 "NTIA: Dependency relationships".to_string(),
875 ),
876 };
877 violations.push(Violation {
878 severity: ViolationSeverity::Error,
879 category: ViolationCategory::DependencyInfo,
880 message,
881 element: None,
882 requirement,
883 });
884 }
885 }
886
887 if self.level.is_cra()
889 && sbom.components.len() > 1
890 && sbom.primary_component_id.is_none()
891 {
892 use std::collections::HashSet;
893 let mut incoming: HashSet<&crate::model::CanonicalId> = HashSet::new();
894 for edge in &sbom.edges {
895 incoming.insert(&edge.to);
896 }
897 let root_count = sbom.components.len().saturating_sub(incoming.len());
898 if root_count > 1 {
899 violations.push(Violation {
900 severity: ViolationSeverity::Warning,
901 category: ViolationCategory::DependencyInfo,
902 message: "[CRA Annex I] SBOM appears to have multiple root components; identify a primary product component for top-level dependencies".to_string(),
903 element: None,
904 requirement: "CRA Annex I: Top-level dependency clarity".to_string(),
905 });
906 }
907 }
908 }
909
910 fn check_vulnerability_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
911 if !matches!(self.level, ComplianceLevel::CraPhase2) {
912 return;
913 }
914
915 for (comp, vuln) in sbom.all_vulnerabilities() {
916 if vuln.severity.is_none() && vuln.cvss.is_empty() {
917 violations.push(Violation {
918 severity: ViolationSeverity::Warning,
919 category: ViolationCategory::SecurityInfo,
920 message: format!(
921 "[CRA Art. 13(6)] Vulnerability '{}' in '{}' lacks severity or CVSS score",
922 vuln.id, comp.name
923 ),
924 element: Some(comp.name.clone()),
925 requirement: "CRA Art. 13(6): Vulnerability metadata completeness".to_string(),
926 });
927 }
928
929 if let Some(remediation) = &vuln.remediation {
930 if remediation.fixed_version.is_none() && remediation.description.is_none() {
931 violations.push(Violation {
932 severity: ViolationSeverity::Info,
933 category: ViolationCategory::SecurityInfo,
934 message: format!(
935 "[CRA Art. 13(6)] Vulnerability '{}' in '{}' has remediation without details",
936 vuln.id, comp.name
937 ),
938 element: Some(comp.name.clone()),
939 requirement: "CRA Art. 13(6): Remediation detail".to_string(),
940 });
941 }
942 }
943 }
944 }
945
946 fn check_format_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
947 match sbom.document.format {
948 SbomFormat::CycloneDx => {
949 self.check_cyclonedx_specific(sbom, violations);
950 }
951 SbomFormat::Spdx => {
952 self.check_spdx_specific(sbom, violations);
953 }
954 }
955 }
956
957 fn check_cyclonedx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
958 let version = &sbom.document.spec_version;
960
961 if version.starts_with("1.3") || version.starts_with("1.2") {
963 violations.push(Violation {
964 severity: ViolationSeverity::Info,
965 category: ViolationCategory::FormatSpecific,
966 message: format!(
967 "CycloneDX {version} is outdated, consider upgrading to 1.5+"
968 ),
969 element: None,
970 requirement: "Current CycloneDX version".to_string(),
971 });
972 }
973
974 for comp in sbom.components.values() {
976 if comp.identifiers.format_id == comp.name {
977 violations.push(Violation {
979 severity: ViolationSeverity::Info,
980 category: ViolationCategory::FormatSpecific,
981 message: format!("Component '{}' may be missing bom-ref", comp.name),
982 element: Some(comp.name.clone()),
983 requirement: "CycloneDX: bom-ref for dependency tracking".to_string(),
984 });
985 }
986 }
987 }
988
989 fn check_spdx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
990 let version = &sbom.document.spec_version;
992
993 if !version.starts_with("2.") && !version.starts_with("3.") {
995 violations.push(Violation {
996 severity: ViolationSeverity::Warning,
997 category: ViolationCategory::FormatSpecific,
998 message: format!("Unknown SPDX version: {version}"),
999 element: None,
1000 requirement: "Valid SPDX version".to_string(),
1001 });
1002 }
1003
1004 for comp in sbom.components.values() {
1006 if !comp.identifiers.format_id.starts_with("SPDXRef-") {
1007 violations.push(Violation {
1008 severity: ViolationSeverity::Info,
1009 category: ViolationCategory::FormatSpecific,
1010 message: format!("Component '{}' has non-standard SPDXID format", comp.name),
1011 element: Some(comp.name.clone()),
1012 requirement: "SPDX: SPDXRef- identifier format".to_string(),
1013 });
1014 }
1015 }
1016 }
1017}
1018
1019impl Default for ComplianceChecker {
1020 fn default() -> Self {
1021 Self::new(ComplianceLevel::Standard)
1022 }
1023}
1024
1025fn is_valid_email_format(email: &str) -> bool {
1027 if email.contains(' ') || email.is_empty() {
1029 return false;
1030 }
1031
1032 let parts: Vec<&str> = email.split('@').collect();
1033 if parts.len() != 2 {
1034 return false;
1035 }
1036
1037 let local = parts[0];
1038 let domain = parts[1];
1039
1040 if local.is_empty() {
1042 return false;
1043 }
1044
1045 if domain.is_empty()
1047 || !domain.contains('.')
1048 || domain.starts_with('.')
1049 || domain.ends_with('.')
1050 {
1051 return false;
1052 }
1053
1054 true
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 #[test]
1062 fn test_compliance_level_names() {
1063 assert_eq!(ComplianceLevel::Minimum.name(), "Minimum");
1064 assert_eq!(ComplianceLevel::NtiaMinimum.name(), "NTIA Minimum Elements");
1065 assert_eq!(ComplianceLevel::CraPhase1.name(), "EU CRA Phase 1 (2027)");
1066 assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2029)");
1067 }
1068
1069 #[test]
1070 fn test_compliance_result_counts() {
1071 let violations = vec![
1072 Violation {
1073 severity: ViolationSeverity::Error,
1074 category: ViolationCategory::ComponentIdentification,
1075 message: "Error 1".to_string(),
1076 element: None,
1077 requirement: "Test".to_string(),
1078 },
1079 Violation {
1080 severity: ViolationSeverity::Warning,
1081 category: ViolationCategory::LicenseInfo,
1082 message: "Warning 1".to_string(),
1083 element: None,
1084 requirement: "Test".to_string(),
1085 },
1086 Violation {
1087 severity: ViolationSeverity::Info,
1088 category: ViolationCategory::FormatSpecific,
1089 message: "Info 1".to_string(),
1090 element: None,
1091 requirement: "Test".to_string(),
1092 },
1093 ];
1094
1095 let result = ComplianceResult::new(ComplianceLevel::Standard, violations);
1096 assert!(!result.is_compliant);
1097 assert_eq!(result.error_count, 1);
1098 assert_eq!(result.warning_count, 1);
1099 assert_eq!(result.info_count, 1);
1100 }
1101}