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