Skip to main content

sbom_tools/cli/
diff.rs

1//! Diff command handler.
2//!
3//! Implements the `diff` subcommand for comparing two SBOMs.
4
5use crate::config::DiffConfig;
6use crate::pipeline::{
7    OutputTarget, auto_detect_format, compute_diff, exit_codes, is_stdin_path, output_report,
8    parse_sbom_with_context,
9};
10use crate::reports::ReportFormat;
11use crate::tui::{App, run_tui};
12use anyhow::{Result, bail};
13
14/// Run the diff command, returning the desired exit code.
15///
16/// Enrichment is handled based on the `enrichment` feature flag and the
17/// `config.enrichment.enabled` setting. When the feature is disabled,
18/// enrichment settings are silently ignored.
19///
20/// The caller is responsible for calling `std::process::exit()` with the
21/// returned code when it is non-zero.
22#[allow(clippy::needless_pass_by_value)]
23pub fn run_diff(config: DiffConfig) -> Result<i32> {
24    let quiet = config.behavior.quiet;
25
26    // `diff` renders every format except OSCAL (which is compliance-
27    // assessment output produced by `validate`); reject it up front instead
28    // of silently emitting plain JSON.
29    if config.output.format == ReportFormat::OscalJson {
30        bail!(
31            "output format 'oscal-json' is not supported by `sbom-tools diff`; \
32             use `sbom-tools validate -o oscal-json` for OSCAL assessment results"
33        );
34    }
35    // Same reasoning for the sbomqs comparison view: it is a `quality`
36    // renderer, and falling through to the JSON reporter emitted ordinary
37    // diff JSON under a format the caller did not ask for.
38    if config.output.format == ReportFormat::SbomqsJson {
39        bail!(
40            "output format 'sbomqs-json' is not supported by `sbom-tools diff`; \
41             use `sbom-tools quality -o sbomqs-json` for sbomqs-comparable scores"
42        );
43    }
44
45    // Stdin can only be consumed once, so a diff can read at most one side from "-".
46    if is_stdin_path(&config.paths.old) && is_stdin_path(&config.paths.new) {
47        bail!("Cannot read both SBOMs from stdin ('-'); only one '-' is allowed per diff");
48    }
49
50    // Reject unknown --severity / --graph-impact-threshold values before
51    // parsing or enrichment; previously they silently filtered nothing /
52    // coerced to `low`.
53    crate::pipeline::validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
54
55    // Parse SBOMs
56    let mut old_parsed = parse_sbom_with_context(&config.paths.old, quiet)?;
57    let mut new_parsed = parse_sbom_with_context(&config.paths.new, quiet)?;
58
59    if !quiet {
60        tracing::info!(
61            "Parsed {} components from old SBOM, {} from new SBOM",
62            old_parsed.sbom().component_count(),
63            new_parsed.sbom().component_count()
64        );
65    }
66
67    // Enrich with OSV vulnerability data if enabled (runtime feature check)
68    #[cfg(feature = "enrichment")]
69    let mut enrichment_warnings: Vec<&str> = Vec::new();
70
71    #[cfg(feature = "enrichment")]
72    let enrichment_stats = {
73        if config.enrichment.enabled {
74            let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
75            let stats_old = crate::pipeline::enrich_sbom(old_parsed.sbom_mut(), &osv_config, quiet);
76            let stats_new = crate::pipeline::enrich_sbom(new_parsed.sbom_mut(), &osv_config, quiet);
77            if stats_old.is_none() || stats_new.is_none() {
78                enrichment_warnings.push("OSV vulnerability enrichment failed");
79            }
80            Some((stats_old, stats_new))
81        } else {
82            None
83        }
84    };
85
86    // Enrich with end-of-life data if enabled
87    #[cfg(feature = "enrichment")]
88    {
89        if config.enrichment.enable_eol {
90            let eol_config = crate::enrichment::EolClientConfig {
91                cache_dir: config
92                    .enrichment
93                    .cache_dir
94                    .clone()
95                    .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
96                cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
97                bypass_cache: config.enrichment.bypass_cache,
98                timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
99                ..Default::default()
100            };
101            let eol_old = crate::pipeline::enrich_eol(old_parsed.sbom_mut(), &eol_config, quiet);
102            let eol_new = crate::pipeline::enrich_eol(new_parsed.sbom_mut(), &eol_config, quiet);
103            if eol_old.is_none() || eol_new.is_none() {
104                enrichment_warnings.push("EOL enrichment failed");
105            }
106        }
107    }
108
109    // Enrich with CISA KEV catalog (flags actively exploited vulnerabilities)
110    #[cfg(feature = "enrichment")]
111    if config.enrichment.enable_kev {
112        let kev_config = kev_client_config(&config.enrichment);
113        let kev_old = crate::pipeline::enrich_kev(old_parsed.sbom_mut(), &kev_config, quiet);
114        let kev_new = crate::pipeline::enrich_kev(new_parsed.sbom_mut(), &kev_config, quiet);
115        if kev_old.is_none() || kev_new.is_none() {
116            // The --fail-on-kev gate cannot be evaluated without KEV data:
117            // treating the load failure as a warning would silently pass the
118            // gate (fail-open). Abort as an operational error (exit 3)
119            // instead. `enrich_kev` has already printed the underlying cause
120            // ("Warning: KEV enrichment failed: ...") to stderr.
121            if config.behavior.fail_on_kev {
122                bail!(
123                    "--fail-on-kev requires KEV data: KEV catalog could not be loaded \
124                     (see warning above for the cause; if running offline, pre-populate \
125                     the KEV cache first)"
126                );
127            }
128            enrichment_warnings.push("KEV enrichment failed");
129        }
130    } else if config.behavior.fail_on_kev {
131        // Defensive: the CLI layer force-enables KEV enrichment whenever
132        // --fail-on-kev is set, but if a config path ever reaches here
133        // without it, the gate would be vacuously fail-open.
134        bail!("--fail-on-kev requires KEV data: KEV enrichment is not enabled");
135    }
136
137    // Enrich with FIRST EPSS exploit-probability scores
138    #[cfg(feature = "enrichment")]
139    if config.enrichment.enable_epss {
140        let epss_config = epss_client_config(&config.enrichment);
141        let epss_old = crate::pipeline::enrich_epss(old_parsed.sbom_mut(), &epss_config, quiet);
142        let epss_new = crate::pipeline::enrich_epss(new_parsed.sbom_mut(), &epss_config, quiet);
143        if epss_old.is_none() || epss_new.is_none() {
144            enrichment_warnings.push("EPSS enrichment failed");
145        }
146    }
147
148    // Enrich with dependency staleness data
149    #[cfg(feature = "enrichment")]
150    if config.enrichment.enable_staleness {
151        let staleness_config = crate::enrichment::RegistryConfig {
152            cache_dir: config
153                .enrichment
154                .cache_dir
155                .clone()
156                .unwrap_or_else(crate::pipeline::dirs::staleness_cache_dir),
157            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
158            bypass_cache: config.enrichment.bypass_cache,
159            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
160            ..Default::default()
161        };
162        let stale_old =
163            crate::pipeline::enrich_staleness(old_parsed.sbom_mut(), &staleness_config, quiet);
164        let stale_new =
165            crate::pipeline::enrich_staleness(new_parsed.sbom_mut(), &staleness_config, quiet);
166        if stale_old.is_none() || stale_new.is_none() {
167            enrichment_warnings.push("Staleness enrichment failed");
168        }
169    }
170
171    // Enrich ML-model components with HuggingFace Hub data (weight hashes, task)
172    #[cfg(feature = "enrichment")]
173    if config.enrichment.enable_huggingface {
174        let hf_config = huggingface_client_config(&config.enrichment);
175        let hf_old = crate::pipeline::enrich_huggingface(old_parsed.sbom_mut(), &hf_config, quiet);
176        let hf_new = crate::pipeline::enrich_huggingface(new_parsed.sbom_mut(), &hf_config, quiet);
177        if hf_old.is_none() || hf_new.is_none() {
178            enrichment_warnings.push("HuggingFace enrichment failed");
179        }
180    }
181
182    // Enrich with VEX data if VEX documents provided
183    #[cfg(feature = "enrichment")]
184    if !config.enrichment.vex_paths.is_empty() {
185        let vex_old =
186            crate::pipeline::enrich_vex(old_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
187        let vex_new =
188            crate::pipeline::enrich_vex(new_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
189        if vex_old.is_none() || vex_new.is_none() {
190            enrichment_warnings.push("VEX enrichment failed");
191        }
192    }
193
194    #[cfg(not(feature = "enrichment"))]
195    {
196        // Without the enrichment feature, KEV data can never be loaded, so
197        // the --fail-on-kev gate would be permanently fail-open.
198        if config.behavior.fail_on_kev {
199            bail!(
200                "--fail-on-kev requires KEV data: this binary was built without the \
201                 'enrichment' feature. Rebuild with: cargo build --features enrichment"
202            );
203        }
204        if config.enrichment.enabled {
205            eprintln!(
206                "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
207                 Rebuild with: cargo build --features enrichment"
208            );
209        }
210    }
211
212    // Compute the diff
213    let mut result = compute_diff(&config, &old_parsed.sbom, &new_parsed.sbom)?;
214    result.ml_regressions = find_ml_regressions(&result);
215
216    // Determine exit code before potentially moving result into TUI
217    let exit_code = determine_exit_code(&config, &result);
218
219    // Route output
220    let output_target = OutputTarget::from_option(config.output.file.clone());
221    let effective_output = auto_detect_format(config.output.format, &output_target);
222
223    if effective_output == ReportFormat::Tui {
224        // Resolve the CRA sidecars (auto-discovered next to each SBOM unless
225        // it reads from stdin) so the compliance tab applies the same
226        // sidecar-driven verdicts as the non-TUI reports — most importantly
227        // EU AI Act high-risk escalation, which otherwise renders COMPLIANT
228        // in the TUI. Resolution is per-SBOM: each side of the diff is judged
229        // against its own adjacent metadata, identically to the report stage.
230        let old_sidecar = crate::pipeline::discover_cra_sidecar(&config.paths.old)?;
231        let new_sidecar = crate::pipeline::discover_cra_sidecar(&config.paths.new)?;
232
233        let (old_sbom, old_raw) = old_parsed.into_parts();
234        let (new_sbom, new_raw) = new_parsed.into_parts();
235
236        #[cfg(feature = "enrichment")]
237        let mut app = {
238            let app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
239            if let Some((stats_old, stats_new)) = enrichment_stats {
240                app.with_enrichment_stats(stats_old, stats_new)
241            } else {
242                app
243            }
244        };
245
246        #[cfg(not(feature = "enrichment"))]
247        let mut app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
248
249        app = app.with_cra_sidecars(old_sidecar, new_sidecar);
250
251        // Set export template if configured
252        app.export_template = config.output.export_template.clone();
253
254        // Show enrichment warnings in TUI footer
255        #[cfg(feature = "enrichment")]
256        if !enrichment_warnings.is_empty() {
257            app.set_status_message(format!("Warning: {}", enrichment_warnings.join(", ")));
258            app.status_sticky = true;
259        }
260
261        run_tui(&mut app, config.output.no_color)?;
262    } else {
263        old_parsed.drop_raw_content();
264        new_parsed.drop_raw_content();
265        output_report(&config, &result, &old_parsed.sbom, &new_parsed.sbom)?;
266    }
267
268    Ok(exit_code)
269}
270
271/// Determine the appropriate exit code based on diff results and config flags.
272///
273/// Priority (most specific gate wins): ML regression (7) > VEX gaps (4) >
274/// KEV (6) > vulns introduced (2) > changes (1). VEX gaps are checked before
275/// the generic vuln gate because they are more specific — a user who sets
276/// `--fail-on-vex-gap` wants to know about missing VEX statements, not just
277/// that vulns were introduced.
278fn determine_exit_code(config: &DiffConfig, result: &crate::diff::DiffResult) -> i32 {
279    if config.filtering.fail_on_ml_regression && !result.ml_regressions.is_empty() {
280        for regression in &result.ml_regressions {
281            eprintln!(
282                "ML regression: component={} metric={} previous={} new={}",
283                regression.component,
284                regression.metric,
285                regression.previous_value,
286                regression.new_value
287            );
288        }
289        return exit_codes::ML_REGRESSION;
290    }
291    // Check for VEX gaps first (most specific gate)
292    if config.filtering.fail_on_vex_gap {
293        let vex_summary = result.vulnerabilities.vex_summary();
294        let total_gaps = vex_summary.introduced_without_vex + vex_summary.persistent_without_vex;
295        if total_gaps > 0 {
296            eprintln!(
297                "VEX gap: {} vulnerability(ies) lack VEX statements ({} introduced, {} persistent)",
298                total_gaps, vex_summary.introduced_without_vex, vex_summary.persistent_without_vex,
299            );
300            return exit_codes::VEX_GAPS_FOUND;
301        }
302    }
303    // KEV gate is more specific than the generic vuln gate: an actively
304    // exploited (KEV) finding is the highest-priority signal for CRA Art.14
305    // reporting, so it outranks --fail-on-vuln.
306    if config.behavior.fail_on_kev {
307        let kev_count = result
308            .vulnerabilities
309            .introduced
310            .iter()
311            .filter(|v| v.is_kev)
312            .count();
313        if kev_count > 0 {
314            eprintln!(
315                "KEV gate: {kev_count} introduced vulnerability(ies) are in CISA's Known Exploited Vulnerabilities catalog",
316            );
317            return exit_codes::KEV_INTRODUCED;
318        }
319    }
320    if config.behavior.fail_on_vuln && result.summary.vulnerabilities_introduced > 0 {
321        return exit_codes::VULNS_INTRODUCED;
322    }
323    if config.behavior.fail_on_change && result.summary.total_changes > 0 {
324        return exit_codes::CHANGES_DETECTED;
325    }
326    exit_codes::SUCCESS
327}
328
329fn find_ml_regressions(result: &crate::diff::DiffResult) -> Vec<crate::diff::MlRegression> {
330    result
331        .components
332        .modified
333        .iter()
334        .flat_map(|component| {
335            component.field_changes.iter().filter_map(move |change| {
336                let metric = change.field.strip_prefix("ml_metric:")?;
337                let higher_is_better = crate::diff::ml_metric_higher_is_better(metric)?;
338                let previous_value = change.old_value.as_deref()?.parse::<f64>().ok()?;
339                let new_value = change.new_value.as_deref()?.parse::<f64>().ok()?;
340                let regressed = if higher_is_better {
341                    new_value < previous_value
342                } else {
343                    new_value > previous_value
344                };
345                regressed.then(|| crate::diff::MlRegression {
346                    component: component.name.clone(),
347                    metric: metric.to_string(),
348                    previous_value,
349                    new_value,
350                })
351            })
352        })
353        .collect()
354}
355
356/// Build a `KevClientConfig` from the user-facing `EnrichmentConfig`, honoring
357/// the cache directory, TTL, timeout, and optional URL override.
358#[cfg(feature = "enrichment")]
359fn kev_client_config(
360    enrichment: &crate::config::EnrichmentConfig,
361) -> crate::enrichment::KevClientConfig {
362    let mut cfg = crate::enrichment::KevClientConfig {
363        cache_dir: enrichment
364            .cache_dir
365            .clone()
366            .unwrap_or_else(crate::pipeline::dirs::kev_cache_dir),
367        cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
368        bypass_cache: enrichment.bypass_cache,
369        timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
370        ..Default::default()
371    };
372    if let Some(ref url) = enrichment.kev_url {
373        cfg.kev_url = url.clone();
374    }
375    cfg
376}
377
378/// Build an `EpssClientConfig` from the user-facing `EnrichmentConfig`, honoring
379/// the cache directory, TTL, timeout, and optional URL override.
380#[cfg(feature = "enrichment")]
381fn epss_client_config(
382    enrichment: &crate::config::EnrichmentConfig,
383) -> crate::enrichment::EpssClientConfig {
384    let mut cfg = crate::enrichment::EpssClientConfig {
385        cache_dir: enrichment
386            .cache_dir
387            .clone()
388            .unwrap_or_else(crate::pipeline::dirs::epss_cache_dir),
389        cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
390        bypass_cache: enrichment.bypass_cache,
391        timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
392        ..Default::default()
393    };
394    if let Some(ref url) = enrichment.epss_url {
395        cfg.epss_url = url.clone();
396    }
397    cfg
398}
399
400/// Build a `HuggingFaceConfig` from the user-facing `EnrichmentConfig`, honoring
401/// the cache directory, TTL, timeout, and optional URL override.
402#[cfg(feature = "enrichment")]
403fn huggingface_client_config(
404    enrichment: &crate::config::EnrichmentConfig,
405) -> crate::enrichment::HuggingFaceConfig {
406    let mut cfg = crate::enrichment::HuggingFaceConfig {
407        cache_dir: enrichment
408            .cache_dir
409            .clone()
410            .unwrap_or_else(crate::pipeline::dirs::huggingface_cache_dir),
411        cache_ttl: std::time::Duration::from_secs(enrichment.cache_ttl_hours * 3600),
412        bypass_cache: enrichment.bypass_cache,
413        timeout: std::time::Duration::from_secs(enrichment.timeout_secs),
414        ..Default::default()
415    };
416    if let Some(ref url) = enrichment.huggingface_url {
417        cfg.api_url = url.clone();
418    }
419    cfg
420}
421
422#[cfg(test)]
423mod tests {
424    use super::find_ml_regressions;
425    use crate::diff::{ChangeType, ComponentChange, DiffResult, FieldChange};
426    use crate::pipeline::OutputTarget;
427    use std::path::PathBuf;
428
429    #[test]
430    fn test_output_target_conversion() {
431        let none_target = OutputTarget::from_option(None);
432        assert!(matches!(none_target, OutputTarget::Stdout));
433
434        let some_target = OutputTarget::from_option(Some(PathBuf::from("/tmp/test.json")));
435        assert!(matches!(some_target, OutputTarget::File(_)));
436    }
437
438    fn result_with_metric(metric: &str, old: &str, new: &str) -> DiffResult {
439        let mut result = DiffResult::new();
440        result.components.modified.push(ComponentChange {
441            id: "model".to_string(),
442            canonical_id: None,
443            component_ref: None,
444            old_canonical_id: None,
445            name: "classifier".to_string(),
446            old_version: None,
447            new_version: None,
448            ecosystem: None,
449            component_type: None,
450            change_type: ChangeType::Modified,
451            field_changes: vec![FieldChange {
452                field: format!("ml_metric:{metric}"),
453                old_value: Some(old.to_string()),
454                new_value: Some(new.to_string()),
455            }],
456            cost: 1,
457            match_info: None,
458        });
459        result
460    }
461
462    #[test]
463    fn ml_regression_respects_metric_direction() {
464        assert_eq!(
465            find_ml_regressions(&result_with_metric("accuracy", "0.9", "0.8")).len(),
466            1
467        );
468        assert_eq!(
469            find_ml_regressions(&result_with_metric("loss", "0.2", "0.3")).len(),
470            1
471        );
472        assert!(find_ml_regressions(&result_with_metric("accuracy", "0.8", "0.9")).is_empty());
473        assert!(find_ml_regressions(&result_with_metric("loss", "0.3", "0.2")).is_empty());
474    }
475
476    #[test]
477    fn ml_regression_ignores_unknown_or_non_numeric_metrics() {
478        assert!(find_ml_regressions(&result_with_metric("custom", "1", "0")).is_empty());
479        assert!(find_ml_regressions(&result_with_metric("accuracy", "high", "low")).is_empty());
480    }
481}