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