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