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