Skip to main content

sbom_tools/cli/
vex.rs

1//! VEX command handler.
2//!
3//! Implements the `vex` subcommand for standalone VEX operations:
4//! - `vex apply` — Apply VEX documents to an SBOM
5//! - `vex status` — Show VEX coverage summary
6//! - `vex filter` — Filter vulnerabilities by VEX state
7
8use crate::config::VexConfig;
9use crate::model::{NormalizedSbom, VexState};
10use crate::pipeline::{OutputTarget, exit_codes, write_output};
11use anyhow::Result;
12
13/// VEX action to perform.
14#[derive(Debug, Clone)]
15pub enum VexAction {
16    /// Apply VEX documents to an SBOM and output enriched result
17    Apply,
18    /// Show VEX coverage summary for an SBOM
19    Status,
20    /// Filter vulnerabilities by VEX state
21    Filter,
22    /// Export the SBOM's VEX state as a CSAF v2.0 advisory
23    /// (or other advisory format).
24    Export(VexExportFormat),
25}
26
27/// Export format for `vex export`. CSAF v2.0 is the only one wired up
28/// today; OpenVEX / CycloneDX VEX emit can be added later.
29#[derive(Debug, Clone, Copy)]
30pub enum VexExportFormat {
31    Csaf,
32}
33
34/// Run the vex subcommand.
35#[allow(clippy::needless_pass_by_value)]
36pub fn run_vex(config: VexConfig, action: VexAction) -> Result<i32> {
37    let quiet = config.quiet;
38    let mut parsed = crate::pipeline::parse_sbom_with_context(&config.sbom_path, quiet)?;
39
40    // Apply enrichment if configured
41    #[cfg(feature = "enrichment")]
42    {
43        if config.enrichment.enabled {
44            let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
45            crate::pipeline::enrich_sbom(parsed.sbom_mut(), &osv_config, quiet);
46        }
47        if config.enrichment.enable_eol {
48            let eol_config = crate::enrichment::EolClientConfig {
49                cache_dir: config
50                    .enrichment
51                    .cache_dir
52                    .clone()
53                    .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
54                cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
55                bypass_cache: config.enrichment.bypass_cache,
56                timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
57                ..Default::default()
58            };
59            crate::pipeline::enrich_eol(parsed.sbom_mut(), &eol_config, quiet);
60        }
61    }
62
63    // Apply external VEX documents. A missing or malformed --vex document is
64    // a hard error (exit 3): silently continuing would let CI believe the VEX
65    // statements were applied when they were not.
66    #[cfg(feature = "enrichment")]
67    if !config.vex_paths.is_empty() {
68        if !quiet {
69            eprintln!(
70                "Enriching SBOM with VEX data from {} document(s)...",
71                config.vex_paths.len()
72            );
73        }
74        let mut enricher = crate::enrichment::VexEnricher::from_files(&config.vex_paths)
75            .map_err(|e| anyhow::anyhow!("failed to load VEX documents: {e}"))?;
76        let stats = enricher.enrich_sbom(parsed.sbom_mut());
77        if !quiet {
78            eprintln!(
79                "VEX enrichment: {} documents, {} statements, {} vulns matched, {} components",
80                stats.documents_loaded,
81                stats.statements_parsed,
82                stats.vulns_matched,
83                stats.components_with_vex,
84            );
85        }
86    }
87
88    // This flow calls the step-level enrichers directly (not
89    // enrich_sbom_full), so refresh content hashes here too: enrichment
90    // mutates vulnerability/VEX content after parse-time hashes were
91    // computed, and Component.content_hash is a serialized field.
92    #[cfg(feature = "enrichment")]
93    if config.enrichment.enabled || config.enrichment.enable_eol || !config.vex_paths.is_empty() {
94        let sbom = parsed.sbom_mut();
95        for comp in sbom.components.values_mut() {
96            comp.calculate_content_hash();
97        }
98        sbom.calculate_content_hash();
99    }
100
101    #[cfg(not(feature = "enrichment"))]
102    {
103        // --vex must not silently no-op: CI would believe the VEX documents
104        // were applied. Hard error instead.
105        if !config.vex_paths.is_empty() {
106            anyhow::bail!(
107                "--vex requires the 'enrichment' feature, which is not enabled in this build. \
108                 Rebuild with: cargo build --features enrichment"
109            );
110        }
111        // Warn if other enrichment was requested but the feature is not enabled
112        if config.enrichment.enabled || config.enrichment.enable_eol {
113            eprintln!(
114                "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
115                 Rebuild with: cargo build --features enrichment"
116            );
117        }
118    }
119
120    match action {
121        VexAction::Apply => run_vex_apply(parsed.sbom(), &config),
122        VexAction::Status => run_vex_status(parsed.sbom(), &config),
123        VexAction::Filter => run_vex_filter(parsed.sbom(), &config),
124        VexAction::Export(format) => run_vex_export(parsed.sbom(), &config, format),
125    }
126}
127
128/// Emit a CSAF v2.0 (or future) advisory document derived from the SBOM's
129/// per-vulnerability VEX state.
130fn run_vex_export(
131    sbom: &NormalizedSbom,
132    config: &VexConfig,
133    format: VexExportFormat,
134) -> Result<i32> {
135    let output = match format {
136        VexExportFormat::Csaf => {
137            let opts = crate::reports::CsafEmitOptions::default();
138            crate::reports::emit_csaf(sbom, &opts)
139                .map_err(|e| anyhow::anyhow!("CSAF emit failed: {e}"))?
140        }
141    };
142    let target = OutputTarget::from_option(config.output_file.clone());
143    write_output(&output, &target, false)?;
144    Ok(exit_codes::SUCCESS)
145}
146
147/// Apply VEX documents and output the enriched SBOM vulnerability data as JSON.
148///
149/// `--state` and `--actionable-only` restrict the emitted vulnerability list
150/// (AND-composed); they do not affect the exit code here — `apply` is a
151/// transformation, gating belongs to `filter`/`status`.
152fn run_vex_apply(sbom: &NormalizedSbom, config: &VexConfig) -> Result<i32> {
153    let vulns = collect_all_vulns(sbom);
154    let filtered = filter_entries(&vulns, config)?;
155    let output = serde_json::to_string_pretty(&filtered)?;
156    let target = OutputTarget::from_option(config.output_file.clone());
157    write_output(&output, &target, false)?;
158    Ok(exit_codes::SUCCESS)
159}
160
161/// Show VEX coverage summary.
162///
163/// `--state` restricts the report to vulnerabilities in that state ("none" =
164/// vulnerabilities without a VEX statement). `--actionable-only` keeps its
165/// documented gate semantics: exit code 1 when actionable vulnerabilities
166/// exist (in the possibly state-filtered set).
167fn run_vex_status(sbom: &NormalizedSbom, config: &VexConfig) -> Result<i32> {
168    let mut vulns = collect_all_vulns(sbom);
169    if let Some(target_state) = parsed_state_filter(config)? {
170        vulns.retain(|v| v.vex_state.as_ref() == target_state.as_ref());
171    }
172    let total = vulns.len();
173    let with_vex = vulns.iter().filter(|v| v.vex_state.is_some()).count();
174    let without_vex = total - with_vex;
175
176    let mut by_state: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
177    let mut actionable = 0;
178
179    for v in &vulns {
180        if let Some(ref state) = v.vex_state {
181            *by_state.entry(state.to_string()).or_insert(0) += 1;
182        }
183        // Consistent with VulnerabilityDetail::is_vex_actionable — excludes NotAffected/Fixed
184        if !matches!(
185            v.vex_state,
186            Some(VexState::NotAffected) | Some(VexState::Fixed)
187        ) {
188            actionable += 1;
189        }
190    }
191
192    let coverage_pct = if total > 0 {
193        (with_vex as f64 / total as f64) * 100.0
194    } else {
195        100.0
196    };
197
198    let output_target = OutputTarget::from_option(config.output_file.clone());
199
200    let use_json = matches!(config.output_format, crate::reports::ReportFormat::Json)
201        || (matches!(config.output_format, crate::reports::ReportFormat::Auto)
202            && matches!(output_target, OutputTarget::File(_)));
203
204    if use_json {
205        // JSON output for piping
206        let summary = serde_json::json!({
207            "total_vulnerabilities": total,
208            "with_vex": with_vex,
209            "without_vex": without_vex,
210            "actionable": actionable,
211            "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
212            "by_state": by_state,
213            "gaps": vulns.iter()
214                .filter(|v| v.vex_state.is_none())
215                .map(|v| serde_json::json!({
216                    "id": v.id,
217                    "severity": v.severity,
218                    "component": v.component_name,
219                    "version": v.version,
220                }))
221                .collect::<Vec<_>>(),
222        });
223        let output = serde_json::to_string_pretty(&summary)?;
224        write_output(&output, &output_target, false)?;
225    } else {
226        // Table output — rendered into a buffer so `-O <file>` writes the
227        // table to the file instead of losing it to stdout.
228        use std::fmt::Write as _;
229        let mut out = String::new();
230        writeln!(out, "VEX Coverage Summary")?;
231        writeln!(out, "====================")?;
232        writeln!(out)?;
233        writeln!(out, "Total vulnerabilities:  {total}")?;
234        writeln!(out, "With VEX statement:     {with_vex}")?;
235        writeln!(out, "Without VEX statement:  {without_vex}")?;
236        writeln!(out, "Actionable:             {actionable}")?;
237        writeln!(out, "Coverage:               {coverage_pct:.1}%")?;
238        writeln!(out)?;
239
240        if !by_state.is_empty() {
241            writeln!(out, "By VEX State:")?;
242            for (state, count) in &by_state {
243                writeln!(out, "  {state:<20} {count}")?;
244            }
245            writeln!(out)?;
246        }
247
248        if without_vex > 0 {
249            writeln!(out, "Gaps (vulnerabilities without VEX):")?;
250            for v in vulns.iter().filter(|v| v.vex_state.is_none()) {
251                writeln!(
252                    out,
253                    "  {} [{}] — {} {}",
254                    v.id,
255                    v.severity,
256                    v.component_name,
257                    v.version.as_deref().unwrap_or("")
258                )?;
259            }
260        }
261
262        write_output(out.trim_end(), &output_target, config.quiet)?;
263    }
264
265    // Exit code 1 if actionable-only mode and actionable vulns exist
266    if config.actionable_only && actionable > 0 {
267        return Ok(exit_codes::CHANGES_DETECTED);
268    }
269
270    Ok(exit_codes::SUCCESS)
271}
272
273/// Filter vulnerabilities by VEX state.
274///
275/// `--actionable-only` and `--state` compose (AND): a vulnerability must
276/// satisfy both filters to be kept.
277fn run_vex_filter(sbom: &NormalizedSbom, config: &VexConfig) -> Result<i32> {
278    let vulns = collect_all_vulns(sbom);
279    let filtered = filter_entries(&vulns, config)?;
280
281    let output = serde_json::to_string_pretty(&filtered)?;
282    let target = OutputTarget::from_option(config.output_file.clone());
283    write_output(&output, &target, false)?;
284
285    if !config.quiet {
286        eprintln!(
287            "Filtered: {} of {} vulnerabilities",
288            filtered.len(),
289            vulns.len()
290        );
291    }
292
293    // Exit code 1 if actionable-only and any remain
294    if config.actionable_only && !filtered.is_empty() {
295        return Ok(exit_codes::CHANGES_DETECTED);
296    }
297
298    Ok(exit_codes::SUCCESS)
299}
300
301// ============================================================================
302// Helpers
303// ============================================================================
304
305/// Simplified vulnerability entry for VEX command output.
306#[derive(Debug, serde::Serialize)]
307struct VulnEntry {
308    id: String,
309    severity: String,
310    component_name: String,
311    version: Option<String>,
312    vex_state: Option<VexState>,
313    vex_justification: Option<String>,
314    vex_impact: Option<String>,
315}
316
317/// True when the vulnerability is actionable (not suppressed by a
318/// `NotAffected`/`Fixed` VEX statement). Consistent with
319/// `VulnerabilityDetail::is_vex_actionable`.
320fn is_actionable(v: &VulnEntry) -> bool {
321    !matches!(
322        v.vex_state,
323        Some(VexState::NotAffected) | Some(VexState::Fixed)
324    )
325}
326
327/// Parse the optional `--state` flag. Outer `None` = flag absent; inner
328/// `None` = "none"/"missing" (vulnerabilities without any VEX statement).
329fn parsed_state_filter(config: &VexConfig) -> Result<Option<Option<VexState>>> {
330    config
331        .filter_state
332        .as_deref()
333        .map(parse_vex_state_filter)
334        .transpose()
335}
336
337/// Apply the shared `--state` / `--actionable-only` filters (AND-composed).
338fn filter_entries<'a>(vulns: &'a [VulnEntry], config: &VexConfig) -> Result<Vec<&'a VulnEntry>> {
339    let state = parsed_state_filter(config)?;
340    Ok(vulns
341        .iter()
342        .filter(|v| {
343            (!config.actionable_only || is_actionable(v))
344                && state
345                    .as_ref()
346                    .is_none_or(|target| v.vex_state.as_ref() == target.as_ref())
347        })
348        .collect())
349}
350
351/// Collect all vulnerabilities from an SBOM into a flat list.
352fn collect_all_vulns(sbom: &NormalizedSbom) -> Vec<VulnEntry> {
353    let mut entries = Vec::new();
354    for comp in sbom.components.values() {
355        for vuln in &comp.vulnerabilities {
356            let vex_source = vuln.vex_status.as_ref().or(comp.vex_status.as_ref());
357            entries.push(VulnEntry {
358                id: vuln.id.clone(),
359                severity: vuln
360                    .severity
361                    .as_ref()
362                    .map_or_else(|| "Unknown".to_string(), |s| s.to_string()),
363                component_name: comp.name.clone(),
364                version: comp.version.clone(),
365                vex_state: vex_source.map(|v| v.status.clone()),
366                vex_justification: vex_source
367                    .and_then(|v| v.justification.as_ref().map(|j| j.to_string())),
368                vex_impact: vex_source.and_then(|v| v.impact_statement.clone()),
369            });
370        }
371    }
372    entries
373}
374
375/// Parse a VEX state filter string into `Option<VexState>`.
376///
377/// Returns `None` for "none"/"missing" (meaning: match vulns without VEX).
378/// Returns `Err` for unrecognized values to prevent silent wrong results.
379fn parse_vex_state_filter(s: &str) -> Result<Option<VexState>> {
380    match s.to_lowercase().as_str() {
381        "not_affected" | "notaffected" => Ok(Some(VexState::NotAffected)),
382        "affected" => Ok(Some(VexState::Affected)),
383        "fixed" => Ok(Some(VexState::Fixed)),
384        "under_investigation" | "underinvestigation" | "in_triage" => {
385            Ok(Some(VexState::UnderInvestigation))
386        }
387        "none" | "missing" => Ok(None),
388        other => anyhow::bail!(
389            "unknown VEX state filter: '{other}'. Valid values: \
390             not_affected, affected, fixed, under_investigation, none"
391        ),
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_parse_vex_state_filter() {
401        assert_eq!(
402            parse_vex_state_filter("not_affected").unwrap(),
403            Some(VexState::NotAffected)
404        );
405        assert_eq!(
406            parse_vex_state_filter("affected").unwrap(),
407            Some(VexState::Affected)
408        );
409        assert_eq!(
410            parse_vex_state_filter("fixed").unwrap(),
411            Some(VexState::Fixed)
412        );
413        assert_eq!(
414            parse_vex_state_filter("under_investigation").unwrap(),
415            Some(VexState::UnderInvestigation)
416        );
417        assert_eq!(parse_vex_state_filter("none").unwrap(), None);
418    }
419
420    #[test]
421    fn test_parse_vex_state_filter_rejects_unknown() {
422        assert!(parse_vex_state_filter("fixd").is_err());
423        assert!(parse_vex_state_filter("notaffected_typo").is_err());
424    }
425
426    fn entry(id: &str, state: Option<VexState>) -> VulnEntry {
427        VulnEntry {
428            id: id.to_string(),
429            severity: "High".to_string(),
430            component_name: "comp".to_string(),
431            version: Some("1.0".to_string()),
432            vex_state: state,
433            vex_justification: None,
434            vex_impact: None,
435        }
436    }
437
438    fn config_with(actionable_only: bool, state: Option<&str>) -> VexConfig {
439        VexConfig {
440            sbom_path: std::path::PathBuf::from("sbom.json"),
441            vex_paths: Vec::new(),
442            output_format: crate::reports::ReportFormat::Auto,
443            output_file: None,
444            quiet: true,
445            actionable_only,
446            filter_state: state.map(str::to_string),
447            enrichment: crate::config::EnrichmentConfig::default(),
448        }
449    }
450
451    fn sample_vulns() -> Vec<VulnEntry> {
452        vec![
453            entry("CVE-1", None),
454            entry("CVE-2", Some(VexState::NotAffected)),
455            entry("CVE-3", Some(VexState::Affected)),
456            entry("CVE-4", Some(VexState::Fixed)),
457            entry("CVE-5", Some(VexState::UnderInvestigation)),
458        ]
459    }
460
461    #[test]
462    fn filter_entries_no_flags_keeps_all() {
463        let vulns = sample_vulns();
464        let filtered = filter_entries(&vulns, &config_with(false, None)).unwrap();
465        assert_eq!(filtered.len(), 5);
466    }
467
468    #[test]
469    fn filter_entries_actionable_only_excludes_not_affected_and_fixed() {
470        let vulns = sample_vulns();
471        let filtered = filter_entries(&vulns, &config_with(true, None)).unwrap();
472        let ids: Vec<&str> = filtered.iter().map(|v| v.id.as_str()).collect();
473        assert_eq!(ids, vec!["CVE-1", "CVE-3", "CVE-5"]);
474    }
475
476    #[test]
477    fn filter_entries_state_filter_matches_state() {
478        let vulns = sample_vulns();
479        let filtered = filter_entries(&vulns, &config_with(false, Some("affected"))).unwrap();
480        let ids: Vec<&str> = filtered.iter().map(|v| v.id.as_str()).collect();
481        assert_eq!(ids, vec!["CVE-3"]);
482    }
483
484    #[test]
485    fn filter_entries_state_none_matches_missing_vex() {
486        let vulns = sample_vulns();
487        let filtered = filter_entries(&vulns, &config_with(false, Some("none"))).unwrap();
488        let ids: Vec<&str> = filtered.iter().map(|v| v.id.as_str()).collect();
489        assert_eq!(ids, vec!["CVE-1"]);
490    }
491
492    #[test]
493    fn filter_entries_actionable_and_state_compose_with_and() {
494        let vulns = sample_vulns();
495        // fixed is excluded by --actionable-only even though --state matches it
496        let filtered = filter_entries(&vulns, &config_with(true, Some("fixed"))).unwrap();
497        assert!(filtered.is_empty());
498
499        // affected satisfies both filters
500        let filtered = filter_entries(&vulns, &config_with(true, Some("affected"))).unwrap();
501        let ids: Vec<&str> = filtered.iter().map(|v| v.id.as_str()).collect();
502        assert_eq!(ids, vec!["CVE-3"]);
503    }
504
505    #[test]
506    fn filter_entries_rejects_invalid_state() {
507        let vulns = sample_vulns();
508        assert!(filter_entries(&vulns, &config_with(false, Some("bogus"))).is_err());
509    }
510}