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::NormalizedSbom;
6use crate::pipeline::{OutputTarget, exit_codes, parse_sbom_with_context, write_output};
7use crate::quality::{
8    ComplianceChecker, ComplianceLevel, ComplianceResult, StandardSelector, ViolationSeverity,
9};
10use crate::reports::{ReportFormat, generate_compliance_sarif};
11use anyhow::Result;
12use std::path::PathBuf;
13
14/// Output formats the `validate` command has a real renderer for.
15/// `auto`/`summary` render the plain-text report; everything else here is a
16/// dedicated machine-readable emitter. All other [`ReportFormat`] values are
17/// rejected up front instead of silently falling back to text.
18pub const VALIDATE_OUTPUT_FORMATS: &[ReportFormat] = &[
19    ReportFormat::Auto,
20    ReportFormat::Summary,
21    ReportFormat::Json,
22    ReportFormat::Sarif,
23    ReportFormat::OscalJson,
24];
25
26/// Run the validate command, returning the desired exit code.
27///
28/// # Exit codes
29/// - [`exit_codes::SUCCESS`] (0): compliant (no errors; no warnings when
30///   `--fail-on-warning` is set)
31/// - [`exit_codes::COMPLIANCE_ERRORS`] (1): one or more compliance errors found
32/// - [`exit_codes::COMPLIANCE_WARNINGS`] (2): warnings found with
33///   `--fail-on-warning`
34///
35/// These gate codes only apply to runs that completed a validation. A
36/// usage/configuration error surfaced from this function (unsupported output
37/// format, invalid `--as-of` or `--cra-product-class`, broken explicit
38/// sidecar) propagates as an `Err`, which the binary's `main()` maps to
39/// process exit code 1 (and clap parse errors exit 2) — the same numbers as
40/// the gate codes above. CI pipelines must therefore not interpret a nonzero
41/// exit as a compliance verdict unless the expected report was produced.
42///
43/// The caller is responsible for calling `std::process::exit()` with the
44/// returned code when it is non-zero.
45#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
46pub fn run_validate(
47    sbom_path: PathBuf,
48    standards: Vec<StandardSelector>,
49    output: ReportFormat,
50    output_file: Option<PathBuf>,
51    fail_on_warning: bool,
52    summary: bool,
53    cra_sidecar_path: Option<PathBuf>,
54    cra_product_class: Option<String>,
55    as_of: Option<&str>,
56) -> Result<i32> {
57    // `--summary` overrides `--output` (documented on the flag), so the
58    // requested format is never rendered and must not be gated.
59    if !summary {
60        super::ensure_output_format_supported("validate", output, VALIDATE_OUTPUT_FORMATS)?;
61    }
62    // Pinned evaluation clock for deadline-sensitive checks (shared parser
63    // with `quality --as-of`).
64    let as_of: Option<chrono::DateTime<chrono::Utc>> = as_of.map(super::parse_as_of).transpose()?;
65    anyhow::ensure!(
66        !standards.is_empty(),
67        "no compliance standard selected; pass --standard (valid values: {})",
68        StandardSelector::valid_values()
69    );
70
71    let parsed = parse_sbom_with_context(&sbom_path, false)?;
72
73    // Load CRA sidecar — an explicit path is a hard error when broken,
74    // otherwise auto-discover next to the SBOM (best-effort).
75    let cra_sidecar = super::load_cra_sidecar(cra_sidecar_path.as_deref(), &sbom_path)?;
76
77    // Resolve effective product class: sidecar wins; otherwise CLI flag.
78    // An explicitly passed unrecognized class is a hard error (strict parse).
79    // Mismatch between explicit CLI flag and sidecar is reported as a Warning
80    // on stderr (not turned into a Violation — sidecar is authoritative).
81    let cli_class = super::parse_cra_product_class(cra_product_class.as_deref())?;
82    let sidecar_class = cra_sidecar.as_ref().and_then(|s| s.product_class);
83    if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
84        && cli != side
85    {
86        tracing::warn!(
87            "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
88            cli.label(),
89            side.label()
90        );
91    }
92    let effective_class = sidecar_class.or(cli_class);
93
94    let mut results = Vec::new();
95
96    for selector in &standards {
97        let level = selector.level();
98        let mut checker = ComplianceChecker::new(level);
99        if let Some(now) = as_of {
100            checker = checker.with_as_of(now);
101        }
102        // Sidecar metadata feeds the CRA family (manufacturer/disclosure/
103        // lifecycle fields), EUCC (certificate references), and the AI Act
104        // profile (the is_high_risk_ai flag escalates Annex IV findings).
105        if matches!(
106            level,
107            ComplianceLevel::CraPhase1
108                | ComplianceLevel::CraPhase2
109                | ComplianceLevel::CraOssSteward
110                | ComplianceLevel::EuccSubstantial
111                | ComplianceLevel::EuAiAct
112        ) && let Some(sc) = cra_sidecar.clone()
113        {
114            checker = checker.with_sidecar(sc);
115        }
116        // Product class drives severity calibration for the CRA phase checks.
117        if matches!(
118            level,
119            ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2
120        ) && let Some(c) = effective_class
121        {
122            checker = checker.with_product_class(c);
123        }
124        results.push(checker.check(parsed.sbom()));
125    }
126
127    if results.len() == 1 {
128        let result = &results[0];
129        if summary {
130            write_compliance_summary(result, output_file)?;
131        } else {
132            write_compliance_output(result, output, output_file)?;
133        }
134
135        if result.error_count > 0 {
136            return Ok(exit_codes::COMPLIANCE_ERRORS);
137        }
138        if fail_on_warning && result.warning_count > 0 {
139            return Ok(exit_codes::COMPLIANCE_WARNINGS);
140        }
141    } else {
142        // Multi-standard: merge results for output
143        if summary {
144            write_multi_compliance_summary(&results, output_file)?;
145        } else {
146            write_multi_compliance_output(&results, output, output_file)?;
147        }
148
149        let has_errors = results.iter().any(|r| r.error_count > 0);
150        let has_warnings = results.iter().any(|r| r.warning_count > 0);
151        if has_errors {
152            return Ok(exit_codes::COMPLIANCE_ERRORS);
153        }
154        if fail_on_warning && has_warnings {
155            return Ok(exit_codes::COMPLIANCE_WARNINGS);
156        }
157    }
158
159    Ok(exit_codes::SUCCESS)
160}
161
162fn write_compliance_output(
163    result: &ComplianceResult,
164    output: ReportFormat,
165    output_file: Option<PathBuf>,
166) -> Result<()> {
167    let target = OutputTarget::from_option(output_file);
168
169    let content = match output {
170        ReportFormat::Json => serde_json::to_string_pretty(result)
171            .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
172        ReportFormat::Sarif => generate_compliance_sarif(result)?,
173        ReportFormat::OscalJson => {
174            crate::reports::oscal::generate_assessment_results(std::slice::from_ref(result))?
175        }
176        _ => format_compliance_text(result),
177    };
178
179    write_output(&content, &target, false)?;
180    Ok(())
181}
182
183/// Compact summary for CI badge generation
184#[derive(serde::Serialize)]
185struct ComplianceSummary {
186    standard: String,
187    /// Whether the standard actually evaluated the SBOM. When false,
188    /// `compliant` is the documented always-true N/A contract and `score`
189    /// is null — dashboards must not read either as a pass.
190    applicable: bool,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    not_applicable_reason: Option<String>,
193    compliant: bool,
194    score: Option<u8>,
195    errors: usize,
196    warnings: usize,
197    info: usize,
198}
199
200fn compliance_summary(result: &ComplianceResult) -> ComplianceSummary {
201    let not_applicable_reason = match &result.applicability {
202        crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
203        crate::quality::Applicability::Applicable => None,
204    };
205    ComplianceSummary {
206        standard: result.level.name().to_string(),
207        applicable: result.is_applicable(),
208        not_applicable_reason,
209        compliant: result.is_compliant,
210        score: result.score(),
211        errors: result.error_count,
212        warnings: result.warning_count,
213        info: result.info_count,
214    }
215}
216
217fn write_compliance_summary(result: &ComplianceResult, output_file: Option<PathBuf>) -> Result<()> {
218    let target = OutputTarget::from_option(output_file);
219    let summary = compliance_summary(result);
220    let content = serde_json::to_string(&summary)
221        .map_err(|e| anyhow::anyhow!("Failed to serialize summary: {e}"))?;
222    write_output(&content, &target, false)?;
223    Ok(())
224}
225
226fn write_multi_compliance_output(
227    results: &[ComplianceResult],
228    output: ReportFormat,
229    output_file: Option<PathBuf>,
230) -> Result<()> {
231    let target = OutputTarget::from_option(output_file);
232
233    let content = match output {
234        ReportFormat::Json => serde_json::to_string_pretty(results)
235            .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
236        ReportFormat::Sarif => crate::reports::generate_multi_compliance_sarif(results)?,
237        ReportFormat::OscalJson => crate::reports::oscal::generate_assessment_results(results)?,
238        _ => {
239            let mut parts = Vec::new();
240            for result in results {
241                parts.push(format_compliance_text(result));
242            }
243            parts.join("\n---\n\n")
244        }
245    };
246
247    write_output(&content, &target, false)?;
248    Ok(())
249}
250
251fn write_multi_compliance_summary(
252    results: &[ComplianceResult],
253    output_file: Option<PathBuf>,
254) -> Result<()> {
255    let target = OutputTarget::from_option(output_file);
256    let summaries: Vec<ComplianceSummary> = results.iter().map(compliance_summary).collect();
257
258    let content = serde_json::to_string(&summaries)
259        .map_err(|e| anyhow::anyhow!("Failed to serialize multi-standard summary: {e}"))?;
260    write_output(&content, &target, false)?;
261    Ok(())
262}
263
264fn format_compliance_text(result: &ComplianceResult) -> String {
265    let mut lines = Vec::new();
266    lines.push(format!("Compliance ({})", result.level.name()));
267    if let crate::quality::Applicability::NotApplicable(reason) = &result.applicability {
268        lines.push(format!("Status: NOT APPLICABLE — {reason}"));
269    } else {
270        lines.push(format!(
271            "Status: {} ({} errors, {} warnings, {} info)",
272            if result.is_compliant {
273                "COMPLIANT"
274            } else {
275                "NON-COMPLIANT"
276            },
277            result.error_count,
278            result.warning_count,
279            result.info_count
280        ));
281    }
282    lines.push(String::new());
283
284    if result.violations.is_empty() {
285        lines.push("No violations found.".to_string());
286        return lines.join("\n");
287    }
288
289    for v in &result.violations {
290        let severity = match v.severity {
291            ViolationSeverity::Error => "ERROR",
292            ViolationSeverity::Warning => "WARN",
293            ViolationSeverity::Info => "INFO",
294        };
295        let element = v.element.as_deref().unwrap_or("-");
296        lines.push(format!(
297            "[{}] {} | {} | {}",
298            severity,
299            v.category.name(),
300            v.requirement,
301            element
302        ));
303        lines.push(format!("  {}", v.message));
304    }
305
306    lines.join("\n")
307}
308
309/// Run the engine's NtiaMinimum check and print a compact PASSED/FAILED
310/// summary. Used by `view --validate-ntia`; gating errors are listed,
311/// warnings go to the log.
312pub fn print_ntia_validation(sbom: &NormalizedSbom) {
313    let result = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(sbom);
314    for v in result.violations_by_severity(ViolationSeverity::Warning) {
315        tracing::warn!("{}", v.message);
316    }
317    if result.is_compliant {
318        tracing::info!("SBOM passes NTIA minimum elements validation");
319        println!("NTIA Validation: PASSED");
320    } else {
321        tracing::warn!("SBOM has {} NTIA validation errors", result.error_count);
322        println!("NTIA Validation: FAILED");
323        for v in result.violations_by_severity(ViolationSeverity::Error) {
324            println!("  - {}", v.message);
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn print_ntia_validation_does_not_panic_on_empty_sbom() {
335        print_ntia_validation(&NormalizedSbom::default());
336    }
337
338    #[test]
339    fn rejects_unsupported_output_format_before_reading_sbom() {
340        // html/markdown/etc. used to silently fall through to the text
341        // renderer; they must now fail fast (before the SBOM is even read —
342        // the path here does not exist).
343        for format in [
344            ReportFormat::Html,
345            ReportFormat::Markdown,
346            ReportFormat::Csv,
347            ReportFormat::Ndjson,
348            ReportFormat::Table,
349            ReportFormat::SideBySide,
350            ReportFormat::Tui,
351        ] {
352            let err = run_validate(
353                PathBuf::from("/nonexistent/never-read.cdx.json"),
354                vec![StandardSelector::Ntia],
355                format,
356                None,
357                false,
358                false,
359                None,
360                None,
361                None,
362            )
363            .expect_err("unsupported format must be rejected");
364            let msg = err.to_string();
365            assert!(
366                msg.contains("not supported by `sbom-tools validate`"),
367                "unexpected error for {format}: {msg}"
368            );
369            assert!(
370                msg.contains("oscal-json") && msg.contains("sarif"),
371                "error must list the supported formats: {msg}"
372            );
373        }
374    }
375
376    #[test]
377    fn rejects_empty_standard_list() {
378        let err = run_validate(
379            PathBuf::from("/nonexistent/never-read.cdx.json"),
380            Vec::new(),
381            ReportFormat::Summary,
382            None,
383            false,
384            false,
385            None,
386            None,
387            None,
388        )
389        .expect_err("empty standard list must be rejected");
390        assert!(err.to_string().contains("no compliance standard selected"));
391    }
392
393    #[test]
394    fn summary_overrides_output_and_skips_the_format_gate() {
395        // `--summary` is documented as "(overrides --output)", so a stray
396        // `-o html` must not hard-error; the compact JSON summary is written.
397        let dir = tempfile::tempdir().unwrap();
398        let sbom_path = dir.path().join("app.cdx.json");
399        std::fs::write(
400            &sbom_path,
401            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
402        )
403        .unwrap();
404        let out = dir.path().join("summary.json");
405        run_validate(
406            sbom_path,
407            vec![StandardSelector::Ntia],
408            ReportFormat::Html, // rejected without --summary; ignored with it
409            Some(out.clone()),
410            false,
411            true,
412            None,
413            None,
414            None,
415        )
416        .expect("--summary must override -o html instead of hard-erroring");
417        let content = std::fs::read_to_string(out).unwrap();
418        let json: serde_json::Value =
419            serde_json::from_str(content.trim()).expect("compact summary JSON");
420        assert!(json["standard"].is_string(), "summary JSON shape: {json}");
421    }
422
423    #[test]
424    fn typod_cra_product_class_is_a_hard_error() {
425        // Regression: `--cra-product-class critcal` used to be silently
426        // dropped (scored as Default class), flipping the CRA verdict.
427        let dir = tempfile::tempdir().unwrap();
428        let sbom_path = dir.path().join("app.cdx.json");
429        std::fs::write(
430            &sbom_path,
431            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
432        )
433        .unwrap();
434        let err = run_validate(
435            sbom_path,
436            vec![StandardSelector::Cra],
437            ReportFormat::Summary,
438            None,
439            false,
440            true,
441            None,
442            Some("critcal".to_string()),
443            None,
444        )
445        .expect_err("typo'd --cra-product-class must be a hard error");
446        let msg = err.to_string();
447        assert!(msg.contains("critcal"), "must name the bad value: {msg}");
448        assert!(
449            msg.contains("important-class-2") && msg.contains("critical"),
450            "must list the valid values: {msg}"
451        );
452    }
453
454    #[test]
455    fn as_of_accepts_offsetless_datetime() {
456        // Regression: "2027-01-01T00:00:00" used to fail with a misleading
457        // "trailing input" error; it now parses as UTC.
458        let dir = tempfile::tempdir().unwrap();
459        let sbom_path = dir.path().join("app.cdx.json");
460        std::fs::write(
461            &sbom_path,
462            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
463        )
464        .unwrap();
465        run_validate(
466            sbom_path,
467            vec![StandardSelector::Cra],
468            ReportFormat::Summary,
469            Some(dir.path().join("out.json")),
470            false,
471            true,
472            None,
473            None,
474            Some("2027-01-01T00:00:00"),
475        )
476        .expect("offset-less --as-of datetime must parse (assumed UTC)");
477    }
478
479    #[test]
480    fn explicit_missing_sidecar_is_a_hard_error() {
481        // Regression guard for the loader contract: an explicitly passed
482        // sidecar path that cannot be loaded must abort validation.
483        let dir = tempfile::tempdir().unwrap();
484        let sbom_path = dir.path().join("app.cdx.json");
485        std::fs::write(
486            &sbom_path,
487            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
488        )
489        .unwrap();
490        let err = run_validate(
491            sbom_path,
492            vec![StandardSelector::Cra],
493            ReportFormat::Summary,
494            None,
495            false,
496            true,
497            Some(dir.path().join("missing.cra.json")),
498            None,
499            None,
500        )
501        .expect_err("broken explicit sidecar must be a hard error");
502        assert!(err.to_string().contains("Failed to load CRA sidecar"));
503    }
504}