Skip to main content

sbom_tools/cli/
view.rs

1//! View command handler.
2//!
3//! Implements the `view` subcommand for viewing a single SBOM.
4
5use crate::config::ViewConfig;
6use crate::model::{BomProfile, NormalizedSbom, Severity};
7use crate::pipeline::{
8    OutputTarget, auto_detect_format, parse_sbom_with_context, should_use_color, write_output,
9};
10use crate::reports::{ReportConfig, ReportFormat, create_reporter_with_options};
11use crate::tui::{ViewApp, run_view_tui};
12use anyhow::Result;
13
14/// Run the view command
15#[allow(clippy::needless_pass_by_value)]
16pub fn run_view(config: ViewConfig) -> Result<i32> {
17    // `view` renders every format except OSCAL (which is compliance-
18    // assessment output produced by `validate`); reject it up front instead
19    // of silently emitting plain JSON.
20    if config.output.format == ReportFormat::OscalJson {
21        anyhow::bail!(
22            "output format 'oscal-json' is not supported by `sbom-tools view`; \
23             use `sbom-tools validate -o oscal-json` for OSCAL assessment results"
24        );
25    }
26    // Same reasoning for the sbomqs comparison view: it is a `quality`
27    // renderer, and falling through to the JSON reporter emitted ordinary
28    // view JSON under a format the caller did not ask for.
29    if config.output.format == ReportFormat::SbomqsJson {
30        anyhow::bail!(
31            "output format 'sbomqs-json' is not supported by `sbom-tools view`; \
32             use `sbom-tools quality -o sbomqs-json` for sbomqs-comparable scores"
33        );
34    }
35
36    // Validate --severity before any parsing or (network) enrichment work so
37    // a typo fails fast instead of after expensive I/O.
38    if let Some(s) = config.min_severity.as_deref() {
39        parse_severity(s)?;
40    }
41
42    // Resolve the CRA sidecar once for both the TUI and report paths: an
43    // explicit --cra-sidecar that fails to load is a hard error; auto-
44    // discovery next to the SBOM stays best-effort.
45    let cra_sidecar =
46        super::load_cra_sidecar(config.cra_sidecar_path.as_deref(), &config.sbom_path)?;
47
48    // Resolve the effective CRA product class once for both paths as well
49    // (the TUI branch used to silently drop the flag while the report path
50    // applied it): sidecar wins; an explicitly passed unrecognized value is
51    // a hard error.
52    let cli_class = super::parse_cra_product_class(config.cra_product_class.as_deref())?;
53    let sidecar_class = cra_sidecar.as_ref().and_then(|s| s.product_class);
54    if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
55        && cli != side
56    {
57        tracing::warn!(
58            "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
59            cli.label(),
60            side.label()
61        );
62    }
63    let effective_class = sidecar_class.or(cli_class);
64
65    let mut parsed = parse_sbom_with_context(&config.sbom_path, false)?;
66
67    // Enrich with OSV vulnerability data if enabled
68    #[cfg(feature = "enrichment")]
69    let mut enrichment_warnings: Vec<&str> = Vec::new();
70
71    #[cfg(feature = "enrichment")]
72    if config.enrichment.enabled {
73        let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
74        if crate::pipeline::enrich_sbom(parsed.sbom_mut(), &osv_config, false).is_none() {
75            enrichment_warnings.push("OSV vulnerability enrichment failed");
76        }
77    }
78
79    // Enrich with end-of-life data if enabled
80    #[cfg(feature = "enrichment")]
81    if config.enrichment.enable_eol {
82        let eol_config = crate::enrichment::EolClientConfig {
83            cache_dir: config
84                .enrichment
85                .cache_dir
86                .clone()
87                .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
88            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
89            bypass_cache: config.enrichment.bypass_cache,
90            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
91            ..Default::default()
92        };
93        if crate::pipeline::enrich_eol(parsed.sbom_mut(), &eol_config, false).is_none() {
94            enrichment_warnings.push("EOL enrichment failed");
95        }
96    }
97
98    // Enrich with CISA KEV catalog (flags actively exploited vulnerabilities)
99    #[cfg(feature = "enrichment")]
100    if config.enrichment.enable_kev {
101        let mut kev_config = crate::enrichment::KevClientConfig {
102            cache_dir: config
103                .enrichment
104                .cache_dir
105                .clone()
106                .unwrap_or_else(crate::pipeline::dirs::kev_cache_dir),
107            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
108            bypass_cache: config.enrichment.bypass_cache,
109            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
110            ..Default::default()
111        };
112        if let Some(ref url) = config.enrichment.kev_url {
113            kev_config.kev_url = url.clone();
114        }
115        if crate::pipeline::enrich_kev(parsed.sbom_mut(), &kev_config, false).is_none() {
116            enrichment_warnings.push("KEV enrichment failed");
117        }
118    }
119
120    // Enrich with FIRST EPSS exploit-probability scores
121    #[cfg(feature = "enrichment")]
122    if config.enrichment.enable_epss {
123        let mut epss_config = crate::enrichment::EpssClientConfig {
124            cache_dir: config
125                .enrichment
126                .cache_dir
127                .clone()
128                .unwrap_or_else(crate::pipeline::dirs::epss_cache_dir),
129            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
130            bypass_cache: config.enrichment.bypass_cache,
131            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
132            ..Default::default()
133        };
134        if let Some(ref url) = config.enrichment.epss_url {
135            epss_config.epss_url = url.clone();
136        }
137        if crate::pipeline::enrich_epss(parsed.sbom_mut(), &epss_config, false).is_none() {
138            enrichment_warnings.push("EPSS enrichment failed");
139        }
140    }
141
142    // Enrich with dependency staleness data
143    #[cfg(feature = "enrichment")]
144    if config.enrichment.enable_staleness {
145        let staleness_config = crate::enrichment::RegistryConfig {
146            cache_dir: config
147                .enrichment
148                .cache_dir
149                .clone()
150                .unwrap_or_else(crate::pipeline::dirs::staleness_cache_dir),
151            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
152            bypass_cache: config.enrichment.bypass_cache,
153            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
154            ..Default::default()
155        };
156        if crate::pipeline::enrich_staleness(parsed.sbom_mut(), &staleness_config, false).is_none()
157        {
158            enrichment_warnings.push("Staleness enrichment failed");
159        }
160    }
161
162    // Enrich ML-model components with HuggingFace Hub data (weight hashes, task)
163    #[cfg(feature = "enrichment")]
164    if config.enrichment.enable_huggingface {
165        let mut hf_config = crate::enrichment::HuggingFaceConfig {
166            cache_dir: config
167                .enrichment
168                .cache_dir
169                .clone()
170                .unwrap_or_else(crate::pipeline::dirs::huggingface_cache_dir),
171            cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
172            bypass_cache: config.enrichment.bypass_cache,
173            timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
174            ..Default::default()
175        };
176        if let Some(ref url) = config.enrichment.huggingface_url {
177            hf_config.api_url = url.clone();
178        }
179        if crate::pipeline::enrich_huggingface(parsed.sbom_mut(), &hf_config, false).is_none() {
180            enrichment_warnings.push("HuggingFace enrichment failed");
181        }
182    }
183
184    // Enrich with VEX data if VEX documents provided
185    #[cfg(feature = "enrichment")]
186    if !config.enrichment.vex_paths.is_empty()
187        && crate::pipeline::enrich_vex(parsed.sbom_mut(), &config.enrichment.vex_paths, false)
188            .is_none()
189    {
190        enrichment_warnings.push("VEX enrichment failed");
191    }
192
193    // Warn if enrichment requested but feature not enabled
194    #[cfg(not(feature = "enrichment"))]
195    if config.enrichment.enabled
196        || config.enrichment.enable_eol
197        || config.enrichment.enable_kev
198        || config.enrichment.enable_epss
199        || config.enrichment.enable_staleness
200    {
201        eprintln!(
202            "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
203             Rebuild with: cargo build --features enrichment"
204        );
205    }
206
207    // Count vulnerabilities BEFORE display filters are applied: the
208    // --fail-on-vuln gate is documented as "if any vulnerabilities are
209    // present in the SBOM", so display filters (--severity / --ecosystem /
210    // --vulnerable-only) must not mask the exit code.
211    let vuln_count: usize = parsed
212        .sbom()
213        .components
214        .values()
215        .map(|c| c.vulnerabilities.len())
216        .sum();
217
218    // Apply filters to SBOM
219    let filtered_count = apply_view_filters(parsed.sbom_mut(), &config)?;
220    if filtered_count > 0 {
221        tracing::info!(
222            "Filtered to {} components (removed {})",
223            parsed.sbom().component_count(),
224            filtered_count
225        );
226    }
227
228    // Run NTIA validation if requested
229    if config.validate_ntia {
230        super::validate::print_ntia_validation(parsed.sbom());
231    }
232
233    // Output the result
234    let output_target = OutputTarget::from_option(config.output.file.clone());
235    let effective_output = auto_detect_format(config.output.format, &output_target);
236
237    // Resolve BOM profile (CLI override or auto-detect)
238    let bom_profile = config
239        .bom_profile
240        .unwrap_or_else(|| BomProfile::detect(parsed.sbom()));
241    tracing::info!("BOM profile: {bom_profile}");
242
243    if effective_output == ReportFormat::Tui {
244        // The compliance tab's OSS-Steward / EUCC / Article 14 /
245        // product-class checks render against the same sidecar metadata and
246        // effective product class the CLI report path uses (both resolved
247        // once at the top of `run_view`).
248        let (sbom, raw_content) = parsed.into_parts();
249        let mut app = ViewApp::new(sbom, &raw_content, bom_profile);
250        if let Some(sc) = cra_sidecar.clone() {
251            app = app.with_cra_sidecar(sc);
252        }
253        if let Some(c) = effective_class {
254            app = app.with_cra_product_class(c);
255        }
256        app.export_template = config.output.export_template.clone();
257
258        // Show enrichment warnings in TUI footer
259        #[cfg(feature = "enrichment")]
260        if !enrichment_warnings.is_empty() {
261            app.set_status_message(format!("Warning: {}", enrichment_warnings.join(", ")));
262            app.status_sticky = true;
263        }
264
265        run_view_tui(&mut app, config.output.no_color)?;
266    } else {
267        parsed.drop_raw_content();
268        output_view_report(
269            &config,
270            cra_sidecar,
271            effective_class,
272            parsed.sbom(),
273            &output_target,
274        )?;
275    }
276
277    if config.fail_on_vuln && vuln_count > 0 {
278        return Ok(crate::pipeline::exit_codes::VULNS_INTRODUCED);
279    }
280
281    Ok(crate::pipeline::exit_codes::SUCCESS)
282}
283
284/// Apply view filters to the SBOM, returns number of components removed.
285///
286/// Errors if `--severity` carries an unrecognized value (which previously
287/// disabled severity filtering silently).
288pub fn apply_view_filters(sbom: &mut NormalizedSbom, config: &ViewConfig) -> Result<usize> {
289    let original_count = sbom.component_count();
290
291    // Parse minimum severity if provided (strict: unknown values hard-error)
292    let min_severity = config
293        .min_severity
294        .as_deref()
295        .map(parse_severity)
296        .transpose()?;
297
298    // Parse ecosystem filter if provided
299    let ecosystem_filter = config.ecosystem_filter.as_ref().map(|e| e.to_lowercase());
300
301    // Collect keys to remove
302    let keys_to_remove: Vec<_> = sbom
303        .components
304        .iter()
305        .filter_map(|(key, comp)| {
306            // Check vulnerable_only filter
307            if config.vulnerable_only && comp.vulnerabilities.is_empty() {
308                return Some(key.clone());
309            }
310
311            // Check severity filter
312            if let Some(min_sev) = &min_severity {
313                let has_matching_vuln = comp.vulnerabilities.iter().any(|v| {
314                    v.severity
315                        .as_ref()
316                        .is_some_and(|s| severity_meets_minimum(s, min_sev))
317                });
318                if !has_matching_vuln && !comp.vulnerabilities.is_empty() {
319                    return Some(key.clone());
320                }
321                // If vulnerable_only is set and min_severity is set, only keep vulns meeting threshold
322                if config.vulnerable_only && !has_matching_vuln {
323                    return Some(key.clone());
324                }
325            }
326
327            // Check ecosystem filter
328            if let Some(eco_filter) = &ecosystem_filter {
329                let comp_eco = comp
330                    .ecosystem
331                    .as_ref()
332                    .map(|e| format!("{e:?}").to_lowercase())
333                    .unwrap_or_default();
334                if !comp_eco.contains(eco_filter) {
335                    return Some(key.clone());
336                }
337            }
338
339            None
340        })
341        .collect();
342
343    // Remove filtered components
344    for key in &keys_to_remove {
345        sbom.components.shift_remove(key);
346    }
347
348    Ok(original_count - sbom.component_count())
349}
350
351/// Parse a `--severity` filter value strictly.
352///
353/// An unrecognized value used to map to [`Severity::Unknown`] (threshold
354/// order 0), which silently disabled the filter entirely — `--severity
355/// banana` behaved like no filter at all. It is now a hard error listing the
356/// valid values.
357fn parse_severity(s: &str) -> Result<Severity> {
358    match s.to_lowercase().as_str() {
359        "critical" => Ok(Severity::Critical),
360        "high" => Ok(Severity::High),
361        "medium" => Ok(Severity::Medium),
362        "low" => Ok(Severity::Low),
363        _ => anyhow::bail!("invalid --severity '{s}'; valid values: critical, high, medium, low"),
364    }
365}
366
367/// Check if a severity meets the minimum threshold
368pub fn severity_meets_minimum(severity: &Severity, minimum: &Severity) -> bool {
369    let severity_order = |s: &Severity| match s {
370        Severity::Critical => 4,
371        Severity::High => 3,
372        Severity::Medium => 2,
373        Severity::Low => 1,
374        Severity::Info | Severity::None | Severity::Unknown => 0,
375    };
376
377    severity_order(severity) >= severity_order(minimum)
378}
379
380/// Output view report to file or stdout
381fn output_view_report(
382    config: &ViewConfig,
383    sidecar: Option<crate::model::CraSidecarMetadata>,
384    effective_class: Option<crate::model::CraProductClass>,
385    sbom: &NormalizedSbom,
386    output_target: &OutputTarget,
387) -> Result<()> {
388    let effective_output = auto_detect_format(config.output.format, output_target);
389
390    // Pre-compute CRA compliance once for reporters, using the sidecar and
391    // effective product class resolved in `run_view` (explicit values
392    // hard-error there; sidecar discovery is best-effort).
393    let mut checker =
394        crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2);
395    if let Some(sc) = sidecar {
396        checker = checker.with_sidecar(sc);
397    }
398    if let Some(c) = effective_class {
399        checker = checker.with_product_class(c);
400    }
401    let cra_result = checker.check(sbom);
402
403    let report_config = ReportConfig {
404        report_types: vec![config.output.report_types],
405        metadata: crate::reports::ReportMetadata {
406            old_sbom_path: Some(config.sbom_path.to_string_lossy().to_string()),
407            ..Default::default()
408        },
409        view_cra_compliance: Some(cra_result),
410        ..Default::default()
411    };
412
413    let use_color = should_use_color(config.output.no_color);
414    let reporter = create_reporter_with_options(effective_output, use_color);
415    let report = reporter.generate_view_report(sbom, &report_config)?;
416
417    write_output(&report, output_target, false)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_parse_severity() {
426        assert!(matches!(parse_severity("critical"), Ok(Severity::Critical)));
427        assert!(matches!(parse_severity("HIGH"), Ok(Severity::High)));
428        assert!(matches!(parse_severity("Medium"), Ok(Severity::Medium)));
429        assert!(matches!(parse_severity("low"), Ok(Severity::Low)));
430    }
431
432    #[test]
433    fn test_parse_severity_rejects_unknown() {
434        // Regression: unknown values mapped to Severity::Unknown (order 0),
435        // which passed every vulnerability and silently disabled the filter.
436        for bad in ["banana", "unknown", "info", "none", ""] {
437            let err = parse_severity(bad).expect_err("should reject");
438            let msg = err.to_string();
439            assert!(msg.contains("invalid --severity"), "message: {msg}");
440            assert!(
441                msg.contains("critical, high, medium, low"),
442                "message should list valid values: {msg}"
443            );
444        }
445    }
446
447    #[test]
448    fn test_severity_meets_minimum() {
449        assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
450        assert!(severity_meets_minimum(&Severity::High, &Severity::High));
451        assert!(!severity_meets_minimum(&Severity::Medium, &Severity::High));
452        assert!(!severity_meets_minimum(&Severity::Low, &Severity::High));
453    }
454
455    #[test]
456    fn test_severity_order() {
457        assert!(severity_meets_minimum(&Severity::Critical, &Severity::Low));
458        assert!(severity_meets_minimum(
459            &Severity::Critical,
460            &Severity::Medium
461        ));
462        assert!(severity_meets_minimum(&Severity::Critical, &Severity::High));
463        assert!(severity_meets_minimum(
464            &Severity::Critical,
465            &Severity::Critical
466        ));
467    }
468
469    fn test_view_config(min_severity: Option<&str>) -> ViewConfig {
470        ViewConfig {
471            sbom_path: std::path::PathBuf::from("test.json"),
472            output: crate::config::OutputConfig {
473                format: ReportFormat::Summary,
474                file: None,
475                report_types: crate::reports::ReportType::All,
476                no_color: false,
477                streaming: crate::config::StreamingConfig::default(),
478                export_template: None,
479            },
480            validate_ntia: false,
481            min_severity: min_severity.map(str::to_string),
482            vulnerable_only: false,
483            ecosystem_filter: None,
484            fail_on_vuln: false,
485            bom_profile: None,
486            enrichment: crate::config::EnrichmentConfig::default(),
487            cra_sidecar_path: None,
488            cra_product_class: None,
489        }
490    }
491
492    #[test]
493    fn test_apply_view_filters_no_filters() {
494        let mut sbom = NormalizedSbom::default();
495        let config = test_view_config(None);
496
497        let removed = apply_view_filters(&mut sbom, &config).expect("no filters should succeed");
498        assert_eq!(removed, 0);
499    }
500
501    #[test]
502    fn test_apply_view_filters_invalid_severity_errors() {
503        let mut sbom = NormalizedSbom::default();
504        let config = test_view_config(Some("banana"));
505
506        let err = apply_view_filters(&mut sbom, &config).expect_err("should reject");
507        assert!(err.to_string().contains("invalid --severity"));
508    }
509}