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), SPDX 2.3+ (JSON or tag-value), or SPDX 3.0+ (JSON-LD). Older format versions may not be recognized as machine-readable under the CRA."
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 2.x: use PackageLicenseDeclared / PackageLicenseConcluded. SPDX 3.0: use HAS_DECLARED_LICENSE / HAS_CONCLUDED_LICENSE relationships."
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+, SPDX 2.3+, or SPDX 3.0+), 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, Hash, 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 #[must_use]
254 pub const fn short_name(&self) -> &'static str {
255 match self {
256 Self::DocumentMetadata => "Doc Meta",
257 Self::ComponentIdentification => "Comp IDs",
258 Self::DependencyInfo => "Deps",
259 Self::LicenseInfo => "License",
260 Self::SupplierInfo => "Supplier",
261 Self::IntegrityInfo => "Integrity",
262 Self::SecurityInfo => "Security",
263 Self::FormatSpecific => "Format",
264 }
265 }
266
267 #[must_use]
269 pub const fn all() -> &'static [Self] {
270 &[
271 Self::SupplierInfo,
272 Self::ComponentIdentification,
273 Self::DocumentMetadata,
274 Self::IntegrityInfo,
275 Self::LicenseInfo,
276 Self::DependencyInfo,
277 Self::SecurityInfo,
278 Self::FormatSpecific,
279 ]
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct ComplianceResult {
286 pub is_compliant: bool,
288 pub level: ComplianceLevel,
290 pub violations: Vec<Violation>,
292 pub error_count: usize,
294 pub warning_count: usize,
296 pub info_count: usize,
298}
299
300impl ComplianceResult {
301 #[must_use]
303 pub fn new(level: ComplianceLevel, violations: Vec<Violation>) -> Self {
304 let error_count = violations
305 .iter()
306 .filter(|v| v.severity == ViolationSeverity::Error)
307 .count();
308 let warning_count = violations
309 .iter()
310 .filter(|v| v.severity == ViolationSeverity::Warning)
311 .count();
312 let info_count = violations
313 .iter()
314 .filter(|v| v.severity == ViolationSeverity::Info)
315 .count();
316
317 Self {
318 is_compliant: error_count == 0,
319 level,
320 violations,
321 error_count,
322 warning_count,
323 info_count,
324 }
325 }
326
327 #[must_use]
329 pub fn violations_by_severity(&self, severity: ViolationSeverity) -> Vec<&Violation> {
330 self.violations
331 .iter()
332 .filter(|v| v.severity == severity)
333 .collect()
334 }
335
336 #[must_use]
338 pub fn violations_by_category(&self, category: ViolationCategory) -> Vec<&Violation> {
339 self.violations
340 .iter()
341 .filter(|v| v.category == category)
342 .collect()
343 }
344}
345
346#[derive(Debug, Clone)]
348pub struct ComplianceChecker {
349 level: ComplianceLevel,
351}
352
353impl ComplianceChecker {
354 #[must_use]
356 pub const fn new(level: ComplianceLevel) -> Self {
357 Self { level }
358 }
359
360 #[must_use]
362 pub fn check(&self, sbom: &NormalizedSbom) -> ComplianceResult {
363 let mut violations = Vec::new();
364
365 match self.level {
366 ComplianceLevel::NistSsdf => {
367 self.check_nist_ssdf(sbom, &mut violations);
368 }
369 ComplianceLevel::Eo14028 => {
370 self.check_eo14028(sbom, &mut violations);
371 }
372 _ => {
373 self.check_document_metadata(sbom, &mut violations);
375
376 self.check_components(sbom, &mut violations);
378
379 self.check_dependencies(sbom, &mut violations);
381
382 self.check_vulnerability_metadata(sbom, &mut violations);
384
385 self.check_format_specific(sbom, &mut violations);
387
388 if self.level.is_cra() {
390 self.check_cra_gaps(sbom, &mut violations);
391 }
392 }
393 }
394
395 ComplianceResult::new(self.level, violations)
396 }
397
398 fn check_document_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
399 use crate::model::{CreatorType, ExternalRefType};
400
401 if sbom.document.creators.is_empty() {
403 violations.push(Violation {
404 severity: match self.level {
405 ComplianceLevel::Minimum => ViolationSeverity::Warning,
406 _ => ViolationSeverity::Error,
407 },
408 category: ViolationCategory::DocumentMetadata,
409 message: "SBOM must have creator/tool information".to_string(),
410 element: None,
411 requirement: "Document creator identification".to_string(),
412 });
413 }
414
415 if self.level.is_cra() {
417 let has_org = sbom
418 .document
419 .creators
420 .iter()
421 .any(|c| c.creator_type == CreatorType::Organization);
422 if !has_org {
423 violations.push(Violation {
424 severity: ViolationSeverity::Warning,
425 category: ViolationCategory::DocumentMetadata,
426 message:
427 "[CRA Art. 13(15)] SBOM should identify the manufacturer (organization)"
428 .to_string(),
429 element: None,
430 requirement: "CRA Art. 13(15): Manufacturer identification".to_string(),
431 });
432 }
433
434 for creator in &sbom.document.creators {
436 if creator.creator_type == CreatorType::Organization
437 && let Some(email) = &creator.email
438 && !is_valid_email_format(email)
439 {
440 violations.push(Violation {
441 severity: ViolationSeverity::Warning,
442 category: ViolationCategory::DocumentMetadata,
443 message: format!(
444 "[CRA Art. 13(15)] Manufacturer email '{email}' appears invalid"
445 ),
446 element: None,
447 requirement: "CRA Art. 13(15): Valid contact information".to_string(),
448 });
449 }
450 }
451
452 if sbom.document.name.is_none() {
453 violations.push(Violation {
454 severity: ViolationSeverity::Warning,
455 category: ViolationCategory::DocumentMetadata,
456 message: "[CRA Art. 13(12)] SBOM should include the product name".to_string(),
457 element: None,
458 requirement: "CRA Art. 13(12): Product identification".to_string(),
459 });
460 }
461
462 let has_doc_security_contact = sbom.document.security_contact.is_some()
465 || sbom.document.vulnerability_disclosure_url.is_some();
466
467 let has_component_security_contact = sbom.components.values().any(|comp| {
469 comp.external_refs.iter().any(|r| {
470 matches!(
471 r.ref_type,
472 ExternalRefType::SecurityContact
473 | ExternalRefType::Support
474 | ExternalRefType::Advisories
475 )
476 })
477 });
478
479 if !has_doc_security_contact && !has_component_security_contact {
480 violations.push(Violation {
481 severity: ViolationSeverity::Warning,
482 category: ViolationCategory::SecurityInfo,
483 message: "[CRA Art. 13(6)] SBOM should include a security contact or vulnerability disclosure reference".to_string(),
484 element: None,
485 requirement: "CRA Art. 13(6): Vulnerability disclosure contact".to_string(),
486 });
487 }
488
489 if sbom.primary_component_id.is_none() && sbom.components.len() > 1 {
491 violations.push(Violation {
492 severity: ViolationSeverity::Warning,
493 category: ViolationCategory::DocumentMetadata,
494 message: "[CRA Annex I] SBOM should identify the primary product component (CycloneDX metadata.component or SPDX documentDescribes)".to_string(),
495 element: None,
496 requirement: "CRA Annex I: Primary product identification".to_string(),
497 });
498 }
499
500 if sbom.document.support_end_date.is_none() {
502 violations.push(Violation {
503 severity: ViolationSeverity::Info,
504 category: ViolationCategory::SecurityInfo,
505 message: "[CRA Art. 13(8)] Consider specifying a support end date for security updates".to_string(),
506 element: None,
507 requirement: "CRA Art. 13(8): Support period disclosure".to_string(),
508 });
509 }
510
511 let format_ok = match sbom.document.format {
515 SbomFormat::CycloneDx => {
516 let v = &sbom.document.spec_version;
517 !(v.starts_with("1.0")
518 || v.starts_with("1.1")
519 || v.starts_with("1.2")
520 || v.starts_with("1.3"))
521 }
522 SbomFormat::Spdx => {
523 let v = &sbom.document.spec_version;
524 v.starts_with("2.3") || v.starts_with("3.")
525 }
526 };
527 if !format_ok {
528 violations.push(Violation {
529 severity: ViolationSeverity::Warning,
530 category: ViolationCategory::FormatSpecific,
531 message: format!(
532 "[CRA Art. 13(4)] SBOM format version {} {} may not meet CRA machine-readable requirements; use CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+",
533 sbom.document.format, sbom.document.spec_version
534 ),
535 element: None,
536 requirement: "CRA Art. 13(4): Machine-readable SBOM format".to_string(),
537 });
538 }
539
540 if let Some(ref primary_id) = sbom.primary_component_id
544 && let Some(primary) = sbom.components.get(primary_id)
545 && primary.identifiers.purl.is_none()
546 && primary.identifiers.cpe.is_empty()
547 {
548 violations.push(Violation {
549 severity: ViolationSeverity::Warning,
550 category: ViolationCategory::ComponentIdentification,
551 message: format!(
552 "[CRA Annex I, Part II] Primary component '{}' missing unique identifier (PURL/CPE) for cross-update traceability",
553 primary.name
554 ),
555 element: Some(primary.name.clone()),
556 requirement: "CRA Annex I, Part II, 1: Product identifier traceability across updates".to_string(),
557 });
558 }
559 }
560
561 if matches!(self.level, ComplianceLevel::CraPhase2) {
563 let has_vuln_disclosure_policy = sbom.document.vulnerability_disclosure_url.is_some()
566 || sbom.components.values().any(|comp| {
567 comp.external_refs
568 .iter()
569 .any(|r| matches!(r.ref_type, ExternalRefType::Advisories))
570 });
571 if !has_vuln_disclosure_policy {
572 violations.push(Violation {
573 severity: ViolationSeverity::Warning,
574 category: ViolationCategory::SecurityInfo,
575 message: "[CRA Art. 13(7)] SBOM should reference a coordinated vulnerability disclosure policy (advisories URL or disclosure URL)".to_string(),
576 element: None,
577 requirement: "CRA Art. 13(7): Coordinated vulnerability disclosure policy".to_string(),
578 });
579 }
580
581 let has_lifecycle_info = sbom.document.support_end_date.is_some()
586 || sbom.components.values().any(|comp| {
587 comp.extensions.properties.iter().any(|p| {
588 let name_lower = p.name.to_lowercase();
589 name_lower.contains("lifecycle")
590 || name_lower.contains("end-of-life")
591 || name_lower.contains("eol")
592 || name_lower.contains("end-of-support")
593 })
594 });
595 if !has_lifecycle_info {
596 violations.push(Violation {
597 severity: ViolationSeverity::Info,
598 category: ViolationCategory::SecurityInfo,
599 message: "[CRA Art. 13(11)] Consider including component lifecycle/end-of-support information".to_string(),
600 element: None,
601 requirement: "CRA Art. 13(11): Component lifecycle status".to_string(),
602 });
603 }
604
605 let has_conformity_ref = sbom.components.values().any(|comp| {
608 comp.external_refs.iter().any(|r| {
609 matches!(
610 r.ref_type,
611 ExternalRefType::Attestation | ExternalRefType::Certification
612 ) || (matches!(r.ref_type, ExternalRefType::Other(ref s) if s.to_lowercase().contains("declaration-of-conformity"))
613 )
614 })
615 });
616 if !has_conformity_ref {
617 violations.push(Violation {
618 severity: ViolationSeverity::Info,
619 category: ViolationCategory::DocumentMetadata,
620 message: "[CRA Annex VII] Consider including a reference to the EU Declaration of Conformity (attestation or certification external reference)".to_string(),
621 element: None,
622 requirement: "CRA Annex VII: EU Declaration of Conformity reference".to_string(),
623 });
624 }
625 }
626
627 if matches!(self.level, ComplianceLevel::FdaMedicalDevice) {
629 let has_org = sbom
630 .document
631 .creators
632 .iter()
633 .any(|c| c.creator_type == CreatorType::Organization);
634 if !has_org {
635 violations.push(Violation {
636 severity: ViolationSeverity::Warning,
637 category: ViolationCategory::DocumentMetadata,
638 message: "FDA: SBOM should have manufacturer (organization) as creator"
639 .to_string(),
640 element: None,
641 requirement: "FDA: Manufacturer identification".to_string(),
642 });
643 }
644
645 let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
647 if !has_contact {
648 violations.push(Violation {
649 severity: ViolationSeverity::Warning,
650 category: ViolationCategory::DocumentMetadata,
651 message: "FDA: SBOM creators should include contact email".to_string(),
652 element: None,
653 requirement: "FDA: Contact information".to_string(),
654 });
655 }
656
657 if sbom.document.name.is_none() {
659 violations.push(Violation {
660 severity: ViolationSeverity::Warning,
661 category: ViolationCategory::DocumentMetadata,
662 message: "FDA: SBOM should have a document name/title".to_string(),
663 element: None,
664 requirement: "FDA: Document identification".to_string(),
665 });
666 }
667 }
668
669 if matches!(
671 self.level,
672 ComplianceLevel::NtiaMinimum | ComplianceLevel::Comprehensive
673 ) {
674 }
677
678 if matches!(
680 self.level,
681 ComplianceLevel::Standard
682 | ComplianceLevel::FdaMedicalDevice
683 | ComplianceLevel::CraPhase1
684 | ComplianceLevel::CraPhase2
685 | ComplianceLevel::Comprehensive
686 ) && sbom.document.serial_number.is_none()
687 {
688 violations.push(Violation {
689 severity: ViolationSeverity::Warning,
690 category: ViolationCategory::DocumentMetadata,
691 message: "SBOM should have a serial number/unique identifier".to_string(),
692 element: None,
693 requirement: "Document unique identification".to_string(),
694 });
695 }
696 }
697
698 fn check_components(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
699 use crate::model::HashAlgorithm;
700
701 for comp in sbom.components.values() {
702 if comp.name.is_empty() {
705 violations.push(Violation {
706 severity: ViolationSeverity::Error,
707 category: ViolationCategory::ComponentIdentification,
708 message: "Component must have a name".to_string(),
709 element: Some(comp.identifiers.format_id.clone()),
710 requirement: "Component name (required)".to_string(),
711 });
712 }
713
714 if matches!(
716 self.level,
717 ComplianceLevel::NtiaMinimum
718 | ComplianceLevel::FdaMedicalDevice
719 | ComplianceLevel::Standard
720 | ComplianceLevel::CraPhase1
721 | ComplianceLevel::CraPhase2
722 | ComplianceLevel::Comprehensive
723 ) && comp.version.is_none()
724 {
725 let (req, msg) = match self.level {
726 ComplianceLevel::FdaMedicalDevice => (
727 "FDA: Component version".to_string(),
728 format!("Component '{}' missing version", comp.name),
729 ),
730 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
731 "CRA Art. 13(12): Component version".to_string(),
732 format!(
733 "[CRA Art. 13(12)] Component '{}' missing version",
734 comp.name
735 ),
736 ),
737 _ => (
738 "NTIA: Component version".to_string(),
739 format!("Component '{}' missing version", comp.name),
740 ),
741 };
742 violations.push(Violation {
743 severity: ViolationSeverity::Error,
744 category: ViolationCategory::ComponentIdentification,
745 message: msg,
746 element: Some(comp.name.clone()),
747 requirement: req,
748 });
749 }
750
751 if matches!(
753 self.level,
754 ComplianceLevel::Standard
755 | ComplianceLevel::FdaMedicalDevice
756 | ComplianceLevel::CraPhase1
757 | ComplianceLevel::CraPhase2
758 | ComplianceLevel::Comprehensive
759 ) && comp.identifiers.purl.is_none()
760 && comp.identifiers.cpe.is_empty()
761 && comp.identifiers.swid.is_none()
762 {
763 let severity = if matches!(
764 self.level,
765 ComplianceLevel::FdaMedicalDevice
766 | ComplianceLevel::CraPhase1
767 | ComplianceLevel::CraPhase2
768 ) {
769 ViolationSeverity::Error
770 } else {
771 ViolationSeverity::Warning
772 };
773 let (message, requirement) = match self.level {
774 ComplianceLevel::FdaMedicalDevice => (
775 format!(
776 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
777 comp.name
778 ),
779 "FDA: Unique component identifier".to_string(),
780 ),
781 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
782 format!(
783 "[CRA Annex I] Component '{}' missing unique identifier (PURL/CPE/SWID)",
784 comp.name
785 ),
786 "CRA Annex I: Unique component identifier (PURL/CPE/SWID)".to_string(),
787 ),
788 _ => (
789 format!(
790 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
791 comp.name
792 ),
793 "Standard identifier (PURL/CPE)".to_string(),
794 ),
795 };
796 violations.push(Violation {
797 severity,
798 category: ViolationCategory::ComponentIdentification,
799 message,
800 element: Some(comp.name.clone()),
801 requirement,
802 });
803 }
804
805 if matches!(
807 self.level,
808 ComplianceLevel::NtiaMinimum
809 | ComplianceLevel::FdaMedicalDevice
810 | ComplianceLevel::CraPhase1
811 | ComplianceLevel::CraPhase2
812 | ComplianceLevel::Comprehensive
813 ) && comp.supplier.is_none()
814 && comp.author.is_none()
815 {
816 let severity = match self.level {
817 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => {
818 ViolationSeverity::Warning
819 }
820 _ => ViolationSeverity::Error,
821 };
822 let (message, requirement) = match self.level {
823 ComplianceLevel::FdaMedicalDevice => (
824 format!("Component '{}' missing supplier/manufacturer", comp.name),
825 "FDA: Supplier/manufacturer information".to_string(),
826 ),
827 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
828 format!(
829 "[CRA Art. 13(15)] Component '{}' missing supplier/manufacturer",
830 comp.name
831 ),
832 "CRA Art. 13(15): Supplier/manufacturer information".to_string(),
833 ),
834 _ => (
835 format!("Component '{}' missing supplier/manufacturer", comp.name),
836 "NTIA: Supplier information".to_string(),
837 ),
838 };
839 violations.push(Violation {
840 severity,
841 category: ViolationCategory::SupplierInfo,
842 message,
843 element: Some(comp.name.clone()),
844 requirement,
845 });
846 }
847
848 if matches!(
850 self.level,
851 ComplianceLevel::Standard | ComplianceLevel::Comprehensive
852 ) && comp.licenses.declared.is_empty()
853 && comp.licenses.concluded.is_none()
854 {
855 violations.push(Violation {
856 severity: ViolationSeverity::Warning,
857 category: ViolationCategory::LicenseInfo,
858 message: format!("Component '{}' should have license information", comp.name),
859 element: Some(comp.name.clone()),
860 requirement: "License declaration".to_string(),
861 });
862 }
863
864 if matches!(
866 self.level,
867 ComplianceLevel::FdaMedicalDevice | ComplianceLevel::Comprehensive
868 ) {
869 if comp.hashes.is_empty() {
870 violations.push(Violation {
871 severity: if self.level == ComplianceLevel::FdaMedicalDevice {
872 ViolationSeverity::Error
873 } else {
874 ViolationSeverity::Warning
875 },
876 category: ViolationCategory::IntegrityInfo,
877 message: format!("Component '{}' missing cryptographic hash", comp.name),
878 element: Some(comp.name.clone()),
879 requirement: if self.level == ComplianceLevel::FdaMedicalDevice {
880 "FDA: Cryptographic hash for integrity".to_string()
881 } else {
882 "Integrity verification (hashes)".to_string()
883 },
884 });
885 } else if self.level == ComplianceLevel::FdaMedicalDevice {
886 let has_strong_hash = comp.hashes.iter().any(|h| {
888 matches!(
889 h.algorithm,
890 HashAlgorithm::Sha256
891 | HashAlgorithm::Sha384
892 | HashAlgorithm::Sha512
893 | HashAlgorithm::Sha3_256
894 | HashAlgorithm::Sha3_384
895 | HashAlgorithm::Sha3_512
896 | HashAlgorithm::Blake2b256
897 | HashAlgorithm::Blake2b384
898 | HashAlgorithm::Blake2b512
899 | HashAlgorithm::Blake3
900 | HashAlgorithm::Streebog256
901 | HashAlgorithm::Streebog512
902 )
903 });
904 if !has_strong_hash {
905 violations.push(Violation {
906 severity: ViolationSeverity::Warning,
907 category: ViolationCategory::IntegrityInfo,
908 message: format!(
909 "Component '{}' has only weak hash algorithm (use SHA-256+)",
910 comp.name
911 ),
912 element: Some(comp.name.clone()),
913 requirement: "FDA: Strong cryptographic hash (SHA-256 or better)"
914 .to_string(),
915 });
916 }
917 }
918 }
919
920 if self.level.is_cra() && comp.hashes.is_empty() {
922 violations.push(Violation {
923 severity: ViolationSeverity::Info,
924 category: ViolationCategory::IntegrityInfo,
925 message: format!(
926 "[CRA Annex I] Component '{}' missing cryptographic hash (recommended for integrity)",
927 comp.name
928 ),
929 element: Some(comp.name.clone()),
930 requirement: "CRA Annex I: Component integrity information (hash)".to_string(),
931 });
932 }
933 }
934 }
935
936 fn check_dependencies(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
937 if matches!(
939 self.level,
940 ComplianceLevel::NtiaMinimum
941 | ComplianceLevel::FdaMedicalDevice
942 | ComplianceLevel::CraPhase1
943 | ComplianceLevel::CraPhase2
944 | ComplianceLevel::Comprehensive
945 ) {
946 let has_deps = !sbom.edges.is_empty();
947 let has_multiple_components = sbom.components.len() > 1;
948
949 if has_multiple_components && !has_deps {
950 let (message, requirement) = match self.level {
951 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2 => (
952 "[CRA Annex I] SBOM with multiple components must include dependency relationships".to_string(),
953 "CRA Annex I: Dependency relationships".to_string(),
954 ),
955 _ => (
956 "SBOM with multiple components must include dependency relationships".to_string(),
957 "NTIA: Dependency relationships".to_string(),
958 ),
959 };
960 violations.push(Violation {
961 severity: ViolationSeverity::Error,
962 category: ViolationCategory::DependencyInfo,
963 message,
964 element: None,
965 requirement,
966 });
967 }
968 }
969
970 if self.level.is_cra() && sbom.components.len() > 1 && sbom.primary_component_id.is_none() {
972 use std::collections::HashSet;
973 let mut incoming: HashSet<&crate::model::CanonicalId> = HashSet::new();
974 for edge in &sbom.edges {
975 incoming.insert(&edge.to);
976 }
977 let root_count = sbom.components.len().saturating_sub(incoming.len());
978 if root_count > 1 {
979 violations.push(Violation {
980 severity: ViolationSeverity::Warning,
981 category: ViolationCategory::DependencyInfo,
982 message: "[CRA Annex I] SBOM appears to have multiple root components; identify a primary product component for top-level dependencies".to_string(),
983 element: None,
984 requirement: "CRA Annex I: Top-level dependency clarity".to_string(),
985 });
986 }
987 }
988 }
989
990 fn check_vulnerability_metadata(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
991 if !matches!(self.level, ComplianceLevel::CraPhase2) {
992 return;
993 }
994
995 for (comp, vuln) in sbom.all_vulnerabilities() {
996 if vuln.severity.is_none() && vuln.cvss.is_empty() {
997 violations.push(Violation {
998 severity: ViolationSeverity::Warning,
999 category: ViolationCategory::SecurityInfo,
1000 message: format!(
1001 "[CRA Art. 13(6)] Vulnerability '{}' in '{}' lacks severity or CVSS score",
1002 vuln.id, comp.name
1003 ),
1004 element: Some(comp.name.clone()),
1005 requirement: "CRA Art. 13(6): Vulnerability metadata completeness".to_string(),
1006 });
1007 }
1008
1009 if let Some(remediation) = &vuln.remediation
1010 && remediation.fixed_version.is_none()
1011 && remediation.description.is_none()
1012 {
1013 violations.push(Violation {
1014 severity: ViolationSeverity::Info,
1015 category: ViolationCategory::SecurityInfo,
1016 message: format!(
1017 "[CRA Art. 13(6)] Vulnerability '{}' in '{}' has remediation without details",
1018 vuln.id, comp.name
1019 ),
1020 element: Some(comp.name.clone()),
1021 requirement: "CRA Art. 13(6): Remediation detail".to_string(),
1022 });
1023 }
1024 }
1025 }
1026
1027 fn check_cra_gaps(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1029 let age_days = (chrono::Utc::now() - sbom.document.created).num_days();
1031 if age_days > 90 {
1032 violations.push(Violation {
1033 severity: ViolationSeverity::Warning,
1034 category: ViolationCategory::DocumentMetadata,
1035 message: format!(
1036 "[CRA Art. 13(3)] SBOM is {age_days} days old; CRA requires timely updates when components change"
1037 ),
1038 element: None,
1039 requirement: "CRA Art. 13(3): SBOM update frequency".to_string(),
1040 });
1041 } else if age_days > 30 {
1042 violations.push(Violation {
1043 severity: ViolationSeverity::Info,
1044 category: ViolationCategory::DocumentMetadata,
1045 message: format!(
1046 "[CRA Art. 13(3)] SBOM is {age_days} days old; consider regenerating after component changes"
1047 ),
1048 element: None,
1049 requirement: "CRA Art. 13(3): SBOM update frequency".to_string(),
1050 });
1051 }
1052
1053 let total = sbom.components.len();
1055 let without_license = sbom
1056 .components
1057 .values()
1058 .filter(|c| c.licenses.declared.is_empty() && c.licenses.concluded.is_none())
1059 .count();
1060 if without_license > 0 {
1061 let pct = (without_license * 100) / total.max(1);
1062 let severity = if pct > 50 {
1063 ViolationSeverity::Warning
1064 } else {
1065 ViolationSeverity::Info
1066 };
1067 violations.push(Violation {
1068 severity,
1069 category: ViolationCategory::LicenseInfo,
1070 message: format!(
1071 "[CRA Art. 13(5)] {without_license}/{total} components ({pct}%) missing license information"
1072 ),
1073 element: None,
1074 requirement: "CRA Art. 13(5): Licensed component tracking".to_string(),
1075 });
1076 }
1077
1078 let has_vuln_data = sbom
1081 .components
1082 .values()
1083 .any(|c| !c.vulnerabilities.is_empty());
1084 let has_vuln_assertion = sbom.components.values().any(|comp| {
1085 comp.external_refs.iter().any(|r| {
1086 matches!(
1087 r.ref_type,
1088 crate::model::ExternalRefType::VulnerabilityAssertion
1089 | crate::model::ExternalRefType::ExploitabilityStatement
1090 )
1091 })
1092 });
1093 if !has_vuln_data && !has_vuln_assertion {
1094 violations.push(Violation {
1095 severity: ViolationSeverity::Info,
1096 category: ViolationCategory::SecurityInfo,
1097 message:
1098 "[CRA Art. 13(9)] No vulnerability data or vulnerability assertion found; \
1099 include vulnerability information or a statement of no known vulnerabilities"
1100 .to_string(),
1101 element: None,
1102 requirement: "CRA Art. 13(9): Known vulnerabilities statement".to_string(),
1103 });
1104 }
1105
1106 if !sbom.edges.is_empty() {
1109 let transitive_without_supplier = sbom
1110 .components
1111 .values()
1112 .filter(|c| c.supplier.is_none() && c.author.is_none())
1113 .count();
1114 if transitive_without_supplier > 0 {
1115 let pct = (transitive_without_supplier * 100) / total.max(1);
1116 if pct > 30 {
1117 violations.push(Violation {
1118 severity: ViolationSeverity::Warning,
1119 category: ViolationCategory::SupplierInfo,
1120 message: format!(
1121 "[CRA Annex I, Part III] {transitive_without_supplier}/{total} components ({pct}%) \
1122 missing supplier information for supply chain transparency"
1123 ),
1124 element: None,
1125 requirement: "CRA Annex I, Part III: Supply chain transparency".to_string(),
1126 });
1127 }
1128 }
1129 }
1130
1131 let has_doc_integrity = sbom.document.serial_number.is_some()
1134 || sbom.components.values().any(|comp| {
1135 comp.external_refs.iter().any(|r| {
1136 matches!(
1137 r.ref_type,
1138 crate::model::ExternalRefType::Attestation
1139 | crate::model::ExternalRefType::Certification
1140 ) && !r.hashes.is_empty()
1141 })
1142 });
1143 if !has_doc_integrity {
1144 violations.push(Violation {
1145 severity: ViolationSeverity::Info,
1146 category: ViolationCategory::IntegrityInfo,
1147 message: "[CRA Annex III] Consider adding document-level integrity metadata \
1148 (serial number, digital signature, or attestation with hash)"
1149 .to_string(),
1150 element: None,
1151 requirement: "CRA Annex III: Document signature/integrity".to_string(),
1152 });
1153 }
1154
1155 let eol_count = sbom
1158 .components
1159 .values()
1160 .filter(|c| {
1161 c.eol
1162 .as_ref()
1163 .is_some_and(|e| e.status == crate::model::EolStatus::EndOfLife)
1164 })
1165 .count();
1166 if eol_count > 0 {
1167 violations.push(Violation {
1168 severity: ViolationSeverity::Warning,
1169 category: ViolationCategory::SecurityInfo,
1170 message: format!(
1171 "[CRA Art. 13(8)] {eol_count} component(s) have reached end-of-life and no longer receive security updates"
1172 ),
1173 element: None,
1174 requirement: "CRA Art. 13(8): Support period / lifecycle management".to_string(),
1175 });
1176 }
1177
1178 let approaching_eol_count = sbom
1179 .components
1180 .values()
1181 .filter(|c| {
1182 c.eol
1183 .as_ref()
1184 .is_some_and(|e| e.status == crate::model::EolStatus::ApproachingEol)
1185 })
1186 .count();
1187 if approaching_eol_count > 0 {
1188 violations.push(Violation {
1189 severity: ViolationSeverity::Info,
1190 category: ViolationCategory::SecurityInfo,
1191 message: format!(
1192 "[CRA Art. 13(11)] {approaching_eol_count} component(s) are approaching end-of-life within 6 months"
1193 ),
1194 element: None,
1195 requirement: "CRA Art. 13(11): Component lifecycle monitoring".to_string(),
1196 });
1197 }
1198
1199 if sbom.document.format == crate::model::SbomFormat::Spdx
1201 && sbom.document.spec_version.starts_with("3.")
1202 {
1203 let has_vulns = sbom
1205 .components
1206 .values()
1207 .any(|c| !c.vulnerabilities.is_empty());
1208 let has_security_profile = sbom
1209 .document
1210 .distribution_classification
1211 .as_ref()
1212 .is_some_and(|p| p.to_lowercase().contains("security"));
1213
1214 if has_vulns && !has_security_profile {
1215 violations.push(Violation {
1216 severity: ViolationSeverity::Info,
1217 category: ViolationCategory::DocumentMetadata,
1218 message:
1219 "[CRA Art. 13(6)] SPDX 3.0 document contains vulnerabilities but does not declare Security profile conformance; declare profileConformance: [\"security\"] for CRA Art. 13(6) compliance"
1220 .to_string(),
1221 element: None,
1222 requirement: "CRA Art. 13(6): SPDX 3.0 Security profile conformance"
1223 .to_string(),
1224 });
1225 }
1226
1227 let has_licenses = sbom
1229 .components
1230 .values()
1231 .any(|c| !c.licenses.declared.is_empty() || c.licenses.concluded.is_some());
1232 let has_licensing_profile = sbom
1233 .document
1234 .distribution_classification
1235 .as_ref()
1236 .is_some_and(|p| {
1237 p.to_lowercase().contains("simplelicensing")
1238 || p.to_lowercase().contains("licensing")
1239 });
1240
1241 if has_licenses && !has_licensing_profile {
1242 violations.push(Violation {
1243 severity: ViolationSeverity::Info,
1244 category: ViolationCategory::LicenseInfo,
1245 message:
1246 "[CRA Art. 13(5)] SPDX 3.0 document tracks licenses but does not declare SimpleLicensing profile conformance; declare profileConformance: [\"simpleLicensing\"] for completeness"
1247 .to_string(),
1248 element: None,
1249 requirement: "CRA Art. 13(5): SPDX 3.0 SimpleLicensing profile conformance"
1250 .to_string(),
1251 });
1252 }
1253 }
1254 }
1255
1256 fn check_nist_ssdf(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1258 use crate::model::ExternalRefType;
1259
1260 if sbom.document.creators.is_empty() {
1262 violations.push(Violation {
1263 severity: ViolationSeverity::Error,
1264 category: ViolationCategory::DocumentMetadata,
1265 message:
1266 "SBOM must identify its creator (tool or organization) for provenance tracking"
1267 .to_string(),
1268 element: None,
1269 requirement: "NIST SSDF PS.1: Provenance — creator identification".to_string(),
1270 });
1271 }
1272
1273 let has_tool_creator = sbom
1274 .document
1275 .creators
1276 .iter()
1277 .any(|c| c.creator_type == crate::model::CreatorType::Tool);
1278 if !has_tool_creator {
1279 violations.push(Violation {
1280 severity: ViolationSeverity::Warning,
1281 category: ViolationCategory::DocumentMetadata,
1282 message: "SBOM should identify the generation tool for automated provenance"
1283 .to_string(),
1284 element: None,
1285 requirement: "NIST SSDF PS.1: Provenance — tool identification".to_string(),
1286 });
1287 }
1288
1289 let total = sbom.components.len();
1291 let without_hash = sbom
1292 .components
1293 .values()
1294 .filter(|c| c.hashes.is_empty())
1295 .count();
1296 if without_hash > 0 {
1297 let pct = (without_hash * 100) / total.max(1);
1298 violations.push(Violation {
1299 severity: if pct > 50 {
1300 ViolationSeverity::Error
1301 } else {
1302 ViolationSeverity::Warning
1303 },
1304 category: ViolationCategory::IntegrityInfo,
1305 message: format!(
1306 "{without_hash}/{total} components ({pct}%) missing cryptographic hashes for build integrity"
1307 ),
1308 element: None,
1309 requirement: "NIST SSDF PS.2: Build integrity — component hashes".to_string(),
1310 });
1311 }
1312
1313 let has_vcs_ref = sbom.components.values().any(|comp| {
1315 comp.external_refs
1316 .iter()
1317 .any(|r| matches!(r.ref_type, ExternalRefType::Vcs))
1318 });
1319 if !has_vcs_ref {
1320 violations.push(Violation {
1321 severity: ViolationSeverity::Warning,
1322 category: ViolationCategory::ComponentIdentification,
1323 message: "No components reference a VCS repository; include source repository links for traceability"
1324 .to_string(),
1325 element: None,
1326 requirement: "NIST SSDF PO.1: Source code provenance — VCS references".to_string(),
1327 });
1328 }
1329
1330 let has_build_ref = sbom.components.values().any(|comp| {
1332 comp.external_refs.iter().any(|r| {
1333 matches!(
1334 r.ref_type,
1335 ExternalRefType::BuildMeta | ExternalRefType::BuildSystem
1336 )
1337 })
1338 });
1339 if !has_build_ref {
1340 violations.push(Violation {
1341 severity: ViolationSeverity::Info,
1342 category: ViolationCategory::DocumentMetadata,
1343 message: "No build metadata references found; include build system information for reproducibility"
1344 .to_string(),
1345 element: None,
1346 requirement: "NIST SSDF PO.3: Build provenance — build metadata".to_string(),
1347 });
1348 }
1349
1350 if sbom.components.len() > 1 && sbom.edges.is_empty() {
1352 violations.push(Violation {
1353 severity: ViolationSeverity::Error,
1354 category: ViolationCategory::DependencyInfo,
1355 message: "SBOM with multiple components must include dependency relationships"
1356 .to_string(),
1357 element: None,
1358 requirement: "NIST SSDF PW.4: Dependency management — relationships".to_string(),
1359 });
1360 }
1361
1362 let has_vuln_info = sbom
1364 .components
1365 .values()
1366 .any(|c| !c.vulnerabilities.is_empty());
1367 let has_security_ref = sbom.components.values().any(|comp| {
1368 comp.external_refs.iter().any(|r| {
1369 matches!(
1370 r.ref_type,
1371 ExternalRefType::Advisories
1372 | ExternalRefType::SecurityContact
1373 | ExternalRefType::VulnerabilityAssertion
1374 )
1375 })
1376 });
1377 if !has_vuln_info && !has_security_ref {
1378 violations.push(Violation {
1379 severity: ViolationSeverity::Info,
1380 category: ViolationCategory::SecurityInfo,
1381 message: "No vulnerability or security advisory references found; \
1382 include vulnerability data or security contact for incident response"
1383 .to_string(),
1384 element: None,
1385 requirement: "NIST SSDF PW.6: Vulnerability information".to_string(),
1386 });
1387 }
1388
1389 let without_id = sbom
1391 .components
1392 .values()
1393 .filter(|c| {
1394 c.identifiers.purl.is_none()
1395 && c.identifiers.cpe.is_empty()
1396 && c.identifiers.swid.is_none()
1397 })
1398 .count();
1399 if without_id > 0 {
1400 violations.push(Violation {
1401 severity: ViolationSeverity::Warning,
1402 category: ViolationCategory::ComponentIdentification,
1403 message: format!(
1404 "{without_id}/{total} components missing unique identifier (PURL/CPE/SWID)"
1405 ),
1406 element: None,
1407 requirement: "NIST SSDF RV.1: Component identification — unique identifiers"
1408 .to_string(),
1409 });
1410 }
1411
1412 let without_supplier = sbom
1414 .components
1415 .values()
1416 .filter(|c| c.supplier.is_none() && c.author.is_none())
1417 .count();
1418 if without_supplier > 0 {
1419 violations.push(Violation {
1420 severity: ViolationSeverity::Warning,
1421 category: ViolationCategory::SupplierInfo,
1422 message: format!(
1423 "{without_supplier}/{total} components missing supplier/author information"
1424 ),
1425 element: None,
1426 requirement: "NIST SSDF PS.3: Supplier identification".to_string(),
1427 });
1428 }
1429 }
1430
1431 fn check_eo14028(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1433 use crate::model::ExternalRefType;
1434
1435 let format_ok = match sbom.document.format {
1437 crate::model::SbomFormat::CycloneDx => {
1438 let v = &sbom.document.spec_version;
1439 !(v.starts_with("1.0")
1440 || v.starts_with("1.1")
1441 || v.starts_with("1.2")
1442 || v.starts_with("1.3"))
1443 }
1444 crate::model::SbomFormat::Spdx => {
1445 let v = &sbom.document.spec_version;
1446 v.starts_with("2.3") || v.starts_with("3.")
1447 }
1448 };
1449 if !format_ok {
1450 violations.push(Violation {
1451 severity: ViolationSeverity::Error,
1452 category: ViolationCategory::FormatSpecific,
1453 message: format!(
1454 "SBOM format {} {} does not meet EO 14028 machine-readable requirements; \
1455 use CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+",
1456 sbom.document.format, sbom.document.spec_version
1457 ),
1458 element: None,
1459 requirement: "EO 14028 Sec 4(e): Machine-readable SBOM format".to_string(),
1460 });
1461 }
1462
1463 let has_tool = sbom
1465 .document
1466 .creators
1467 .iter()
1468 .any(|c| c.creator_type == crate::model::CreatorType::Tool);
1469 if !has_tool {
1470 violations.push(Violation {
1471 severity: ViolationSeverity::Warning,
1472 category: ViolationCategory::DocumentMetadata,
1473 message: "SBOM should be auto-generated by a tool; no tool creator identified"
1474 .to_string(),
1475 element: None,
1476 requirement: "EO 14028 Sec 4(e): Automated SBOM generation".to_string(),
1477 });
1478 }
1479
1480 if sbom.document.creators.is_empty() {
1482 violations.push(Violation {
1483 severity: ViolationSeverity::Error,
1484 category: ViolationCategory::DocumentMetadata,
1485 message: "SBOM must identify its creator (vendor or tool)".to_string(),
1486 element: None,
1487 requirement: "EO 14028 Sec 4(e): SBOM creator identification".to_string(),
1488 });
1489 }
1490
1491 let total = sbom.components.len();
1493 let without_id = sbom
1494 .components
1495 .values()
1496 .filter(|c| {
1497 c.identifiers.purl.is_none()
1498 && c.identifiers.cpe.is_empty()
1499 && c.identifiers.swid.is_none()
1500 })
1501 .count();
1502 if without_id > 0 {
1503 violations.push(Violation {
1504 severity: ViolationSeverity::Error,
1505 category: ViolationCategory::ComponentIdentification,
1506 message: format!(
1507 "{without_id}/{total} components missing unique identifier (PURL/CPE/SWID)"
1508 ),
1509 element: None,
1510 requirement: "EO 14028 Sec 4(e): Component unique identification".to_string(),
1511 });
1512 }
1513
1514 if sbom.components.len() > 1 && sbom.edges.is_empty() {
1516 violations.push(Violation {
1517 severity: ViolationSeverity::Error,
1518 category: ViolationCategory::DependencyInfo,
1519 message: "SBOM with multiple components must include dependency relationships"
1520 .to_string(),
1521 element: None,
1522 requirement: "EO 14028 Sec 4(e): Dependency relationships".to_string(),
1523 });
1524 }
1525
1526 let without_version = sbom
1528 .components
1529 .values()
1530 .filter(|c| c.version.is_none())
1531 .count();
1532 if without_version > 0 {
1533 violations.push(Violation {
1534 severity: ViolationSeverity::Error,
1535 category: ViolationCategory::ComponentIdentification,
1536 message: format!(
1537 "{without_version}/{total} components missing version information"
1538 ),
1539 element: None,
1540 requirement: "EO 14028 Sec 4(e): Component version".to_string(),
1541 });
1542 }
1543
1544 let without_hash = sbom
1546 .components
1547 .values()
1548 .filter(|c| c.hashes.is_empty())
1549 .count();
1550 if without_hash > 0 {
1551 violations.push(Violation {
1552 severity: ViolationSeverity::Warning,
1553 category: ViolationCategory::IntegrityInfo,
1554 message: format!("{without_hash}/{total} components missing cryptographic hashes"),
1555 element: None,
1556 requirement: "EO 14028 Sec 4(e): Component integrity verification".to_string(),
1557 });
1558 }
1559
1560 let has_security_ref = sbom.document.security_contact.is_some()
1562 || sbom.document.vulnerability_disclosure_url.is_some()
1563 || sbom.components.values().any(|comp| {
1564 comp.external_refs.iter().any(|r| {
1565 matches!(
1566 r.ref_type,
1567 ExternalRefType::SecurityContact | ExternalRefType::Advisories
1568 )
1569 })
1570 });
1571 if !has_security_ref {
1572 violations.push(Violation {
1573 severity: ViolationSeverity::Warning,
1574 category: ViolationCategory::SecurityInfo,
1575 message: "No security contact or vulnerability disclosure reference found"
1576 .to_string(),
1577 element: None,
1578 requirement: "EO 14028 Sec 4(g): Vulnerability disclosure process".to_string(),
1579 });
1580 }
1581
1582 let without_supplier = sbom
1584 .components
1585 .values()
1586 .filter(|c| c.supplier.is_none() && c.author.is_none())
1587 .count();
1588 if without_supplier > 0 {
1589 let pct = (without_supplier * 100) / total.max(1);
1590 if pct > 30 {
1591 violations.push(Violation {
1592 severity: ViolationSeverity::Warning,
1593 category: ViolationCategory::SupplierInfo,
1594 message: format!(
1595 "{without_supplier}/{total} components ({pct}%) missing supplier information"
1596 ),
1597 element: None,
1598 requirement: "EO 14028 Sec 4(e): Supplier identification".to_string(),
1599 });
1600 }
1601 }
1602 }
1603
1604 fn check_format_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1605 match sbom.document.format {
1606 SbomFormat::CycloneDx => {
1607 self.check_cyclonedx_specific(sbom, violations);
1608 }
1609 SbomFormat::Spdx => {
1610 self.check_spdx_specific(sbom, violations);
1611 }
1612 }
1613 }
1614
1615 fn check_cyclonedx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1616 let version = &sbom.document.spec_version;
1618
1619 if version.starts_with("1.3") || version.starts_with("1.2") || version.starts_with("1.1") {
1621 violations.push(Violation {
1622 severity: ViolationSeverity::Info,
1623 category: ViolationCategory::FormatSpecific,
1624 message: format!("CycloneDX {version} is outdated, consider upgrading to 1.7+"),
1625 element: None,
1626 requirement: "Current CycloneDX version".to_string(),
1627 });
1628 }
1629
1630 for comp in sbom.components.values() {
1632 if comp.identifiers.format_id == comp.name {
1633 violations.push(Violation {
1635 severity: ViolationSeverity::Info,
1636 category: ViolationCategory::FormatSpecific,
1637 message: format!("Component '{}' may be missing bom-ref", comp.name),
1638 element: Some(comp.name.clone()),
1639 requirement: "CycloneDX: bom-ref for dependency tracking".to_string(),
1640 });
1641 }
1642 }
1643 }
1644
1645 fn check_spdx_specific(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
1646 let version = &sbom.document.spec_version;
1648
1649 if !version.starts_with("2.") && !version.starts_with("3.") {
1651 violations.push(Violation {
1652 severity: ViolationSeverity::Warning,
1653 category: ViolationCategory::FormatSpecific,
1654 message: format!("Unknown SPDX version: {version}"),
1655 element: None,
1656 requirement: "Valid SPDX version".to_string(),
1657 });
1658 }
1659
1660 let is_spdx3 = version.starts_with("3.");
1663 for comp in sbom.components.values() {
1664 let valid_id = if is_spdx3 {
1665 comp.identifiers.format_id.contains(':')
1667 } else {
1668 comp.identifiers.format_id.starts_with("SPDXRef-")
1669 };
1670 if !valid_id {
1671 let expected = if is_spdx3 {
1672 "SPDX 3.0: URN/IRI identifier format"
1673 } else {
1674 "SPDX 2.x: SPDXRef- identifier format"
1675 };
1676 violations.push(Violation {
1677 severity: ViolationSeverity::Info,
1678 category: ViolationCategory::FormatSpecific,
1679 message: format!(
1680 "Component '{}' has non-standard SPDX identifier format",
1681 comp.name
1682 ),
1683 element: Some(comp.name.clone()),
1684 requirement: expected.to_string(),
1685 });
1686 }
1687 }
1688 }
1689}
1690
1691impl Default for ComplianceChecker {
1692 fn default() -> Self {
1693 Self::new(ComplianceLevel::Standard)
1694 }
1695}
1696
1697fn is_valid_email_format(email: &str) -> bool {
1699 if email.contains(' ') || email.is_empty() {
1701 return false;
1702 }
1703
1704 let parts: Vec<&str> = email.split('@').collect();
1705 if parts.len() != 2 {
1706 return false;
1707 }
1708
1709 let local = parts[0];
1710 let domain = parts[1];
1711
1712 if local.is_empty() {
1714 return false;
1715 }
1716
1717 if domain.is_empty()
1719 || !domain.contains('.')
1720 || domain.starts_with('.')
1721 || domain.ends_with('.')
1722 {
1723 return false;
1724 }
1725
1726 true
1727}
1728
1729#[cfg(test)]
1730mod tests {
1731 use super::*;
1732
1733 #[test]
1734 fn test_compliance_level_names() {
1735 assert_eq!(ComplianceLevel::Minimum.name(), "Minimum");
1736 assert_eq!(ComplianceLevel::NtiaMinimum.name(), "NTIA Minimum Elements");
1737 assert_eq!(ComplianceLevel::CraPhase1.name(), "EU CRA Phase 1 (2027)");
1738 assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2029)");
1739 assert_eq!(ComplianceLevel::NistSsdf.name(), "NIST SSDF (SP 800-218)");
1740 assert_eq!(ComplianceLevel::Eo14028.name(), "EO 14028 Section 4");
1741 }
1742
1743 #[test]
1744 fn test_nist_ssdf_empty_sbom() {
1745 let sbom = NormalizedSbom::default();
1746 let checker = ComplianceChecker::new(ComplianceLevel::NistSsdf);
1747 let result = checker.check(&sbom);
1748 assert!(
1750 result
1751 .violations
1752 .iter()
1753 .any(|v| v.requirement.contains("PS.1"))
1754 );
1755 }
1756
1757 #[test]
1758 fn test_eo14028_empty_sbom() {
1759 let sbom = NormalizedSbom::default();
1760 let checker = ComplianceChecker::new(ComplianceLevel::Eo14028);
1761 let result = checker.check(&sbom);
1762 assert!(
1763 result
1764 .violations
1765 .iter()
1766 .any(|v| v.requirement.contains("EO 14028"))
1767 );
1768 }
1769
1770 #[test]
1771 fn test_compliance_result_counts() {
1772 let violations = vec![
1773 Violation {
1774 severity: ViolationSeverity::Error,
1775 category: ViolationCategory::ComponentIdentification,
1776 message: "Error 1".to_string(),
1777 element: None,
1778 requirement: "Test".to_string(),
1779 },
1780 Violation {
1781 severity: ViolationSeverity::Warning,
1782 category: ViolationCategory::LicenseInfo,
1783 message: "Warning 1".to_string(),
1784 element: None,
1785 requirement: "Test".to_string(),
1786 },
1787 Violation {
1788 severity: ViolationSeverity::Info,
1789 category: ViolationCategory::FormatSpecific,
1790 message: "Info 1".to_string(),
1791 element: None,
1792 requirement: "Test".to_string(),
1793 },
1794 ];
1795
1796 let result = ComplianceResult::new(ComplianceLevel::Standard, violations);
1797 assert!(!result.is_compliant);
1798 assert_eq!(result.error_count, 1);
1799 assert_eq!(result.warning_count, 1);
1800 assert_eq!(result.info_count, 1);
1801 }
1802}