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