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