Skip to main content

sbom_tools/quality/
compliance.rs

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