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