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