Skip to main content

sbom_tools/cli/
validate.rs

1//! Validate command handler.
2//!
3//! Implements the `validate` subcommand for validating SBOMs against compliance standards.
4
5use crate::model::{CreatorType, ExternalRefType, HashAlgorithm, NormalizedSbom, Severity};
6use crate::pipeline::{OutputTarget, parse_sbom_with_context, write_output};
7use crate::quality::{
8    ComplianceChecker, ComplianceLevel, ComplianceResult, Violation, ViolationCategory,
9    ViolationSeverity,
10};
11use crate::reports::{ReportFormat, generate_compliance_sarif};
12use anyhow::{Result, bail};
13use std::collections::HashSet;
14use std::path::PathBuf;
15
16/// Run the validate command
17#[allow(clippy::needless_pass_by_value)]
18pub fn run_validate(
19    sbom_path: PathBuf,
20    standard: String,
21    output: ReportFormat,
22    output_file: Option<PathBuf>,
23    fail_on_warning: bool,
24    summary: bool,
25) -> Result<()> {
26    let parsed = parse_sbom_with_context(&sbom_path, false)?;
27
28    let standards: Vec<&str> = standard.split(',').map(str::trim).collect();
29    let mut results = Vec::new();
30
31    for std_name in &standards {
32        let result = match std_name.to_lowercase().as_str() {
33            "ntia" => check_ntia_compliance(parsed.sbom()),
34            "fda" => check_fda_compliance(parsed.sbom()),
35            "cra" => ComplianceChecker::new(ComplianceLevel::CraPhase2).check(parsed.sbom()),
36            "ssdf" | "nist-ssdf" | "nist_ssdf" => {
37                ComplianceChecker::new(ComplianceLevel::NistSsdf).check(parsed.sbom())
38            }
39            "eo14028" | "eo-14028" | "eo_14028" => {
40                ComplianceChecker::new(ComplianceLevel::Eo14028).check(parsed.sbom())
41            }
42            "cnsa2" | "cnsa-2" | "cnsa_2" | "cnsa2.0" => {
43                ComplianceChecker::new(ComplianceLevel::Cnsa2).check(parsed.sbom())
44            }
45            "pqc" | "nist-pqc" | "nist_pqc" => {
46                ComplianceChecker::new(ComplianceLevel::NistPqc).check(parsed.sbom())
47            }
48            _ => {
49                bail!(
50                    "Unknown validation standard: {std_name}. \
51                    Valid options: ntia, fda, cra, ssdf, eo14028, cnsa2, pqc"
52                );
53            }
54        };
55        results.push(result);
56    }
57
58    if results.len() == 1 {
59        let result = &results[0];
60        if summary {
61            write_compliance_summary(result, output_file)?;
62        } else {
63            write_compliance_output(result, output, output_file)?;
64        }
65
66        if result.error_count > 0 {
67            std::process::exit(1);
68        }
69        if fail_on_warning && result.warning_count > 0 {
70            std::process::exit(2);
71        }
72    } else {
73        // Multi-standard: merge results for output
74        if summary {
75            write_multi_compliance_summary(&results, output_file)?;
76        } else {
77            write_multi_compliance_output(&results, output, output_file)?;
78        }
79
80        let has_errors = results.iter().any(|r| r.error_count > 0);
81        let has_warnings = results.iter().any(|r| r.warning_count > 0);
82        if has_errors {
83            std::process::exit(1);
84        }
85        if fail_on_warning && has_warnings {
86            std::process::exit(2);
87        }
88    }
89
90    Ok(())
91}
92
93fn write_compliance_output(
94    result: &ComplianceResult,
95    output: ReportFormat,
96    output_file: Option<PathBuf>,
97) -> Result<()> {
98    let target = OutputTarget::from_option(output_file);
99
100    let content = match output {
101        ReportFormat::Json => serde_json::to_string_pretty(result)
102            .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
103        ReportFormat::Sarif => generate_compliance_sarif(result)?,
104        _ => format_compliance_text(result),
105    };
106
107    write_output(&content, &target, false)?;
108    Ok(())
109}
110
111/// Compact summary for CI badge generation
112#[derive(serde::Serialize)]
113struct ComplianceSummary {
114    standard: String,
115    compliant: bool,
116    score: u8,
117    errors: usize,
118    warnings: usize,
119    info: usize,
120}
121
122fn write_compliance_summary(result: &ComplianceResult, output_file: Option<PathBuf>) -> Result<()> {
123    let target = OutputTarget::from_option(output_file);
124    let total = result.violations.len() + 1;
125    let issues = result.error_count + result.warning_count;
126    let score = if issues >= total {
127        0
128    } else {
129        ((total - issues) * 100) / total
130    }
131    .min(100) as u8;
132
133    let summary = ComplianceSummary {
134        standard: result.level.name().to_string(),
135        compliant: result.is_compliant,
136        score,
137        errors: result.error_count,
138        warnings: result.warning_count,
139        info: result.info_count,
140    };
141    let content = serde_json::to_string(&summary)
142        .map_err(|e| anyhow::anyhow!("Failed to serialize summary: {e}"))?;
143    write_output(&content, &target, false)?;
144    Ok(())
145}
146
147fn write_multi_compliance_output(
148    results: &[ComplianceResult],
149    output: ReportFormat,
150    output_file: Option<PathBuf>,
151) -> Result<()> {
152    let target = OutputTarget::from_option(output_file);
153
154    let content = match output {
155        ReportFormat::Json => serde_json::to_string_pretty(results)
156            .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
157        ReportFormat::Sarif => crate::reports::generate_multi_compliance_sarif(results)?,
158        _ => {
159            let mut parts = Vec::new();
160            for result in results {
161                parts.push(format_compliance_text(result));
162            }
163            parts.join("\n---\n\n")
164        }
165    };
166
167    write_output(&content, &target, false)?;
168    Ok(())
169}
170
171fn write_multi_compliance_summary(
172    results: &[ComplianceResult],
173    output_file: Option<PathBuf>,
174) -> Result<()> {
175    let target = OutputTarget::from_option(output_file);
176    let summaries: Vec<ComplianceSummary> = results
177        .iter()
178        .map(|result| {
179            let total = result.violations.len() + 1;
180            let issues = result.error_count + result.warning_count;
181            let score = if issues >= total {
182                0
183            } else {
184                ((total - issues) * 100) / total
185            }
186            .min(100) as u8;
187
188            ComplianceSummary {
189                standard: result.level.name().to_string(),
190                compliant: result.is_compliant,
191                score,
192                errors: result.error_count,
193                warnings: result.warning_count,
194                info: result.info_count,
195            }
196        })
197        .collect();
198
199    let content = serde_json::to_string(&summaries)
200        .map_err(|e| anyhow::anyhow!("Failed to serialize multi-standard summary: {e}"))?;
201    write_output(&content, &target, false)?;
202    Ok(())
203}
204
205fn format_compliance_text(result: &ComplianceResult) -> String {
206    let mut lines = Vec::new();
207    lines.push(format!("Compliance ({})", result.level.name()));
208    lines.push(format!(
209        "Status: {} ({} errors, {} warnings, {} info)",
210        if result.is_compliant {
211            "COMPLIANT"
212        } else {
213            "NON-COMPLIANT"
214        },
215        result.error_count,
216        result.warning_count,
217        result.info_count
218    ));
219    lines.push(String::new());
220
221    if result.violations.is_empty() {
222        lines.push("No violations found.".to_string());
223        return lines.join("\n");
224    }
225
226    for v in &result.violations {
227        let severity = match v.severity {
228            ViolationSeverity::Error => "ERROR",
229            ViolationSeverity::Warning => "WARN",
230            ViolationSeverity::Info => "INFO",
231        };
232        let element = v.element.as_deref().unwrap_or("-");
233        lines.push(format!(
234            "[{}] {} | {} | {}",
235            severity,
236            v.category.name(),
237            v.requirement,
238            element
239        ));
240        lines.push(format!("  {}", v.message));
241    }
242
243    lines.join("\n")
244}
245
246/// Check SBOM against NTIA minimum elements, returning a structured result.
247fn check_ntia_compliance(sbom: &NormalizedSbom) -> ComplianceResult {
248    let mut violations = Vec::new();
249
250    if sbom.document.creators.is_empty() {
251        violations.push(Violation {
252            severity: ViolationSeverity::Error,
253            category: ViolationCategory::DocumentMetadata,
254            message: "Missing author/creator information".to_string(),
255            element: None,
256            requirement: "NTIA Minimum Elements: Author".to_string(),
257        });
258    }
259
260    for (_id, comp) in &sbom.components {
261        if comp.name.is_empty() {
262            violations.push(Violation {
263                severity: ViolationSeverity::Error,
264                category: ViolationCategory::ComponentIdentification,
265                message: "Component missing name".to_string(),
266                element: None,
267                requirement: "NTIA Minimum Elements: Component Name".to_string(),
268            });
269        }
270        if comp.version.is_none() {
271            violations.push(Violation {
272                severity: ViolationSeverity::Warning,
273                category: ViolationCategory::ComponentIdentification,
274                message: format!("Component '{}' missing version", comp.name),
275                element: Some(comp.name.clone()),
276                requirement: "NTIA Minimum Elements: Version".to_string(),
277            });
278        }
279        if comp.supplier.is_none() {
280            violations.push(Violation {
281                severity: ViolationSeverity::Warning,
282                category: ViolationCategory::SupplierInfo,
283                message: format!("Component '{}' missing supplier", comp.name),
284                element: Some(comp.name.clone()),
285                requirement: "NTIA Minimum Elements: Supplier Name".to_string(),
286            });
287        }
288        if comp.identifiers.purl.is_none()
289            && comp.identifiers.cpe.is_empty()
290            && comp.identifiers.swid.is_none()
291        {
292            violations.push(Violation {
293                severity: ViolationSeverity::Warning,
294                category: ViolationCategory::ComponentIdentification,
295                message: format!(
296                    "Component '{}' missing unique identifier (PURL/CPE/SWID)",
297                    comp.name
298                ),
299                element: Some(comp.name.clone()),
300                requirement: "NTIA Minimum Elements: Unique Identifier".to_string(),
301            });
302        }
303    }
304
305    if sbom.edges.is_empty() && sbom.component_count() > 1 {
306        violations.push(Violation {
307            severity: ViolationSeverity::Error,
308            category: ViolationCategory::DependencyInfo,
309            message: "Missing dependency relationships".to_string(),
310            element: None,
311            requirement: "NTIA Minimum Elements: Dependency Relationship".to_string(),
312        });
313    }
314
315    ComplianceResult::new(ComplianceLevel::NtiaMinimum, violations)
316}
317
318/// Check SBOM against FDA medical device requirements, returning a structured result.
319fn check_fda_compliance(sbom: &NormalizedSbom) -> ComplianceResult {
320    let mut fda_issues: Vec<FdaIssue> = Vec::new();
321
322    validate_fda_document(sbom, &mut fda_issues);
323    validate_fda_components(sbom, &mut fda_issues);
324    validate_fda_relationships(sbom, &mut fda_issues);
325    validate_fda_vulnerabilities(sbom, &mut fda_issues);
326
327    let violations = fda_issues
328        .into_iter()
329        .map(|issue| Violation {
330            severity: match issue.severity {
331                FdaSeverity::Error => ViolationSeverity::Error,
332                FdaSeverity::Warning => ViolationSeverity::Warning,
333                FdaSeverity::Info => ViolationSeverity::Info,
334            },
335            category: match issue.category {
336                "Document" => ViolationCategory::DocumentMetadata,
337                "Component" => ViolationCategory::ComponentIdentification,
338                "Dependency" => ViolationCategory::DependencyInfo,
339                "Security" => ViolationCategory::SecurityInfo,
340                _ => ViolationCategory::DocumentMetadata,
341            },
342            requirement: format!("FDA Medical Device: {}", issue.category),
343            message: issue.message,
344            element: None,
345        })
346        .collect();
347
348    ComplianceResult::new(ComplianceLevel::FdaMedicalDevice, violations)
349}
350
351/// Validate SBOM against NTIA minimum elements
352#[allow(clippy::unnecessary_wraps)]
353pub fn validate_ntia_elements(sbom: &NormalizedSbom) -> Result<()> {
354    let mut issues = Vec::new();
355
356    // Check document-level requirements
357    if sbom.document.creators.is_empty() {
358        issues.push("Missing author/creator information");
359    }
360
361    // Check component-level requirements
362    for (_id, comp) in &sbom.components {
363        if comp.name.is_empty() {
364            issues.push("Component missing name");
365        }
366        if comp.version.is_none() {
367            tracing::warn!("Component '{}' missing version", comp.name);
368        }
369        if comp.supplier.is_none() {
370            tracing::warn!("Component '{}' missing supplier", comp.name);
371        }
372        if comp.identifiers.purl.is_none()
373            && comp.identifiers.cpe.is_empty()
374            && comp.identifiers.swid.is_none()
375        {
376            tracing::warn!(
377                "Component '{}' missing unique identifier (PURL/CPE/SWID)",
378                comp.name
379            );
380        }
381    }
382
383    if sbom.edges.is_empty() && sbom.component_count() > 1 {
384        issues.push("Missing dependency relationships");
385    }
386
387    if issues.is_empty() {
388        tracing::info!("SBOM passes NTIA minimum elements validation");
389        println!("NTIA Validation: PASSED");
390    } else {
391        tracing::warn!("SBOM has {} NTIA validation issues", issues.len());
392        println!("NTIA Validation: FAILED");
393        for issue in &issues {
394            println!("  - {issue}");
395        }
396    }
397
398    Ok(())
399}
400
401/// FDA validation issue severity
402#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
403enum FdaSeverity {
404    Error,   // Must fix - will likely cause FDA rejection
405    Warning, // Should fix - may cause FDA questions
406    Info,    // Recommendation for improvement
407}
408
409impl std::fmt::Display for FdaSeverity {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        match self {
412            Self::Error => write!(f, "ERROR"),
413            Self::Warning => write!(f, "WARNING"),
414            Self::Info => write!(f, "INFO"),
415        }
416    }
417}
418
419/// FDA validation issue
420struct FdaIssue {
421    severity: FdaSeverity,
422    category: &'static str,
423    message: String,
424}
425
426/// Component validation statistics
427struct ComponentStats {
428    total: usize,
429    without_version: usize,
430    without_supplier: usize,
431    without_hash: usize,
432    without_strong_hash: usize,
433    without_identifier: usize,
434    without_support_info: usize,
435}
436
437fn validate_fda_document(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
438    // Manufacturer/Author Information
439    if sbom.document.creators.is_empty() {
440        issues.push(FdaIssue {
441            severity: FdaSeverity::Error,
442            category: "Document",
443            message: "Missing SBOM author/manufacturer information".to_string(),
444        });
445    } else {
446        let has_org = sbom
447            .document
448            .creators
449            .iter()
450            .any(|c| c.creator_type == CreatorType::Organization);
451        if !has_org {
452            issues.push(FdaIssue {
453                severity: FdaSeverity::Warning,
454                category: "Document",
455                message: "No organization/manufacturer listed as SBOM creator".to_string(),
456            });
457        }
458
459        let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
460        if !has_contact {
461            issues.push(FdaIssue {
462                severity: FdaSeverity::Warning,
463                category: "Document",
464                message: "No contact email provided for SBOM creators".to_string(),
465            });
466        }
467    }
468
469    // SBOM Name/Title
470    if sbom.document.name.is_none() {
471        issues.push(FdaIssue {
472            severity: FdaSeverity::Warning,
473            category: "Document",
474            message: "Missing SBOM document name/title".to_string(),
475        });
476    }
477
478    // Serial Number/Namespace
479    if sbom.document.serial_number.is_none() {
480        issues.push(FdaIssue {
481            severity: FdaSeverity::Warning,
482            category: "Document",
483            message: "Missing SBOM serial number or document namespace".to_string(),
484        });
485    }
486}
487
488fn validate_fda_components(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) -> ComponentStats {
489    let mut stats = ComponentStats {
490        total: sbom.component_count(),
491        without_version: 0,
492        without_supplier: 0,
493        without_hash: 0,
494        without_strong_hash: 0,
495        without_identifier: 0,
496        without_support_info: 0,
497    };
498
499    for (_id, comp) in &sbom.components {
500        if comp.name.is_empty() {
501            issues.push(FdaIssue {
502                severity: FdaSeverity::Error,
503                category: "Component",
504                message: "Component has empty name".to_string(),
505            });
506        }
507
508        if comp.version.is_none() {
509            stats.without_version += 1;
510        }
511
512        if comp.supplier.is_none() {
513            stats.without_supplier += 1;
514        }
515
516        if comp.hashes.is_empty() {
517            stats.without_hash += 1;
518        } else {
519            let has_strong_hash = comp.hashes.iter().any(|h| {
520                matches!(
521                    h.algorithm,
522                    HashAlgorithm::Sha256
523                        | HashAlgorithm::Sha384
524                        | HashAlgorithm::Sha512
525                        | HashAlgorithm::Sha3_256
526                        | HashAlgorithm::Sha3_384
527                        | HashAlgorithm::Sha3_512
528                        | HashAlgorithm::Blake2b256
529                        | HashAlgorithm::Blake2b384
530                        | HashAlgorithm::Blake2b512
531                        | HashAlgorithm::Blake3
532                )
533            });
534            if !has_strong_hash {
535                stats.without_strong_hash += 1;
536            }
537        }
538
539        if comp.identifiers.purl.is_none()
540            && comp.identifiers.cpe.is_empty()
541            && comp.identifiers.swid.is_none()
542        {
543            stats.without_identifier += 1;
544        }
545
546        let has_support_info = comp.external_refs.iter().any(|r| {
547            matches!(
548                r.ref_type,
549                ExternalRefType::Support
550                    | ExternalRefType::Website
551                    | ExternalRefType::SecurityContact
552                    | ExternalRefType::Advisories
553            )
554        });
555        if !has_support_info {
556            stats.without_support_info += 1;
557        }
558    }
559
560    // Add component issues
561    if stats.without_version > 0 {
562        issues.push(FdaIssue {
563            severity: FdaSeverity::Error,
564            category: "Component",
565            message: format!(
566                "{}/{} components missing version information",
567                stats.without_version, stats.total
568            ),
569        });
570    }
571
572    if stats.without_supplier > 0 {
573        issues.push(FdaIssue {
574            severity: FdaSeverity::Error,
575            category: "Component",
576            message: format!(
577                "{}/{} components missing supplier/manufacturer information",
578                stats.without_supplier, stats.total
579            ),
580        });
581    }
582
583    if stats.without_hash > 0 {
584        issues.push(FdaIssue {
585            severity: FdaSeverity::Error,
586            category: "Component",
587            message: format!(
588                "{}/{} components missing cryptographic hash",
589                stats.without_hash, stats.total
590            ),
591        });
592    }
593
594    if stats.without_strong_hash > 0 {
595        issues.push(FdaIssue {
596            severity: FdaSeverity::Warning,
597            category: "Component",
598            message: format!(
599                "{}/{} components have only weak hash algorithms (MD5/SHA-1). FDA recommends SHA-256 or stronger",
600                stats.without_strong_hash, stats.total
601            ),
602        });
603    }
604
605    if stats.without_identifier > 0 {
606        issues.push(FdaIssue {
607            severity: FdaSeverity::Error,
608            category: "Component",
609            message: format!(
610                "{}/{} components missing unique identifier (PURL/CPE/SWID)",
611                stats.without_identifier, stats.total
612            ),
613        });
614    }
615
616    if stats.without_support_info > 0 && stats.total > 0 {
617        let percentage = (stats.without_support_info as f64 / stats.total as f64) * 100.0;
618        if percentage > 50.0 {
619            issues.push(FdaIssue {
620                severity: FdaSeverity::Info,
621                category: "Component",
622                message: format!(
623                    "{}/{} components ({:.0}%) lack support/contact information",
624                    stats.without_support_info, stats.total, percentage
625                ),
626            });
627        }
628    }
629
630    stats
631}
632
633fn validate_fda_relationships(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
634    let total = sbom.component_count();
635
636    if sbom.edges.is_empty() && total > 1 {
637        issues.push(FdaIssue {
638            severity: FdaSeverity::Error,
639            category: "Dependency",
640            message: format!("No dependency relationships defined for {total} components"),
641        });
642    }
643
644    // Check for orphan components
645    if !sbom.edges.is_empty() {
646        let mut connected: HashSet<String> = HashSet::new();
647        for edge in &sbom.edges {
648            connected.insert(edge.from.value().to_string());
649            connected.insert(edge.to.value().to_string());
650        }
651        let orphan_count = sbom
652            .components
653            .keys()
654            .filter(|id| !connected.contains(id.value()))
655            .count();
656
657        if orphan_count > 0 && orphan_count < total {
658            issues.push(FdaIssue {
659                severity: FdaSeverity::Warning,
660                category: "Dependency",
661                message: format!(
662                    "{orphan_count}/{total} components have no dependency relationships (orphaned)"
663                ),
664            });
665        }
666    }
667}
668
669fn validate_fda_vulnerabilities(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
670    let vuln_info = sbom.all_vulnerabilities();
671    if !vuln_info.is_empty() {
672        let critical_vulns = vuln_info
673            .iter()
674            .filter(|(_, v)| matches!(v.severity, Some(Severity::Critical)))
675            .count();
676        let high_vulns = vuln_info
677            .iter()
678            .filter(|(_, v)| matches!(v.severity, Some(Severity::High)))
679            .count();
680
681        if critical_vulns > 0 || high_vulns > 0 {
682            issues.push(FdaIssue {
683                severity: FdaSeverity::Warning,
684                category: "Security",
685                message: format!(
686                    "SBOM contains {critical_vulns} critical and {high_vulns} high severity vulnerabilities"
687                ),
688            });
689        }
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn test_fda_severity_order() {
699        assert!(FdaSeverity::Error < FdaSeverity::Warning);
700        assert!(FdaSeverity::Warning < FdaSeverity::Info);
701    }
702
703    #[test]
704    fn test_fda_severity_display() {
705        assert_eq!(format!("{}", FdaSeverity::Error), "ERROR");
706        assert_eq!(format!("{}", FdaSeverity::Warning), "WARNING");
707        assert_eq!(format!("{}", FdaSeverity::Info), "INFO");
708    }
709
710    #[test]
711    fn test_validate_empty_sbom() {
712        let sbom = NormalizedSbom::default();
713        // Should not panic
714        let _ = validate_ntia_elements(&sbom);
715    }
716
717    #[test]
718    fn test_fda_document_validation() {
719        let sbom = NormalizedSbom::default();
720        let mut issues = Vec::new();
721        validate_fda_document(&sbom, &mut issues);
722        // Should find missing creator issue
723        assert!(!issues.is_empty());
724    }
725}