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
64    #[cfg(feature = "enrichment")]
65    if !config.vex_paths.is_empty() {
66        let stats = crate::pipeline::enrich_vex(parsed.sbom_mut(), &config.vex_paths, quiet);
67        if stats.is_none() && !quiet {
68            eprintln!("Warning: VEX enrichment failed");
69        }
70    }
71
72    // Warn if enrichment requested but feature not enabled
73    #[cfg(not(feature = "enrichment"))]
74    if config.enrichment.enabled || config.enrichment.enable_eol || !config.vex_paths.is_empty() {
75        eprintln!(
76            "Warning: enrichment requested but the 'enrichment' feature is not enabled. \
77             Rebuild with: cargo build --features enrichment"
78        );
79    }
80
81    match action {
82        VexAction::Apply => run_vex_apply(parsed.sbom(), &config),
83        VexAction::Status => run_vex_status(parsed.sbom(), &config),
84        VexAction::Filter => run_vex_filter(parsed.sbom(), &config),
85        VexAction::Export(format) => run_vex_export(parsed.sbom(), &config, format),
86    }
87}
88
89/// Emit a CSAF v2.0 (or future) advisory document derived from the SBOM's
90/// per-vulnerability VEX state.
91fn run_vex_export(
92    sbom: &NormalizedSbom,
93    config: &VexConfig,
94    format: VexExportFormat,
95) -> Result<i32> {
96    let output = match format {
97        VexExportFormat::Csaf => {
98            let opts = crate::reports::CsafEmitOptions::default();
99            crate::reports::emit_csaf(sbom, &opts)
100                .map_err(|e| anyhow::anyhow!("CSAF emit failed: {e}"))?
101        }
102    };
103    let target = OutputTarget::from_option(config.output_file.clone());
104    write_output(&output, &target, false)?;
105    Ok(exit_codes::SUCCESS)
106}
107
108/// Apply VEX documents and output the enriched SBOM vulnerability data as JSON.
109fn run_vex_apply(sbom: &NormalizedSbom, config: &VexConfig) -> Result<i32> {
110    let vulns = collect_all_vulns(sbom);
111    let output = serde_json::to_string_pretty(&vulns)?;
112    let target = OutputTarget::from_option(config.output_file.clone());
113    write_output(&output, &target, false)?;
114    Ok(exit_codes::SUCCESS)
115}
116
117/// Show VEX coverage summary.
118fn run_vex_status(sbom: &NormalizedSbom, config: &VexConfig) -> Result<i32> {
119    let vulns = collect_all_vulns(sbom);
120    let total = vulns.len();
121    let with_vex = vulns.iter().filter(|v| v.vex_state.is_some()).count();
122    let without_vex = total - with_vex;
123
124    let mut by_state: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
125    let mut actionable = 0;
126
127    for v in &vulns {
128        if let Some(ref state) = v.vex_state {
129            *by_state.entry(state.to_string()).or_insert(0) += 1;
130        }
131        // Consistent with VulnerabilityDetail::is_vex_actionable — excludes NotAffected/Fixed
132        if !matches!(
133            v.vex_state,
134            Some(VexState::NotAffected) | Some(VexState::Fixed)
135        ) {
136            actionable += 1;
137        }
138    }
139
140    let coverage_pct = if total > 0 {
141        (with_vex as f64 / total as f64) * 100.0
142    } else {
143        100.0
144    };
145
146    let output_target = OutputTarget::from_option(config.output_file.clone());
147
148    let use_json = matches!(config.output_format, crate::reports::ReportFormat::Json)
149        || (matches!(config.output_format, crate::reports::ReportFormat::Auto)
150            && matches!(output_target, OutputTarget::File(_)));
151
152    if use_json {
153        // JSON output for piping
154        let summary = serde_json::json!({
155            "total_vulnerabilities": total,
156            "with_vex": with_vex,
157            "without_vex": without_vex,
158            "actionable": actionable,
159            "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
160            "by_state": by_state,
161            "gaps": vulns.iter()
162                .filter(|v| v.vex_state.is_none())
163                .map(|v| serde_json::json!({
164                    "id": v.id,
165                    "severity": v.severity,
166                    "component": v.component_name,
167                    "version": v.version,
168                }))
169                .collect::<Vec<_>>(),
170        });
171        let output = serde_json::to_string_pretty(&summary)?;
172        write_output(&output, &output_target, false)?;
173    } else {
174        // Table output for terminal
175        println!("VEX Coverage Summary");
176        println!("====================");
177        println!();
178        println!("Total vulnerabilities:  {total}");
179        println!("With VEX statement:     {with_vex}");
180        println!("Without VEX statement:  {without_vex}");
181        println!("Actionable:             {actionable}");
182        println!("Coverage:               {coverage_pct:.1}%");
183        println!();
184
185        if !by_state.is_empty() {
186            println!("By VEX State:");
187            for (state, count) in &by_state {
188                println!("  {state:<20} {count}");
189            }
190            println!();
191        }
192
193        if without_vex > 0 {
194            println!("Gaps (vulnerabilities without VEX):");
195            for v in vulns.iter().filter(|v| v.vex_state.is_none()) {
196                println!(
197                    "  {} [{}] — {} {}",
198                    v.id,
199                    v.severity,
200                    v.component_name,
201                    v.version.as_deref().unwrap_or("")
202                );
203            }
204        }
205    }
206
207    // Exit code 1 if actionable-only mode and actionable vulns exist
208    if config.actionable_only && actionable > 0 {
209        return Ok(exit_codes::CHANGES_DETECTED);
210    }
211
212    Ok(exit_codes::SUCCESS)
213}
214
215/// Filter vulnerabilities by VEX state.
216fn run_vex_filter(sbom: &NormalizedSbom, config: &VexConfig) -> Result<i32> {
217    let vulns = collect_all_vulns(sbom);
218
219    let filtered: Vec<&VulnEntry> = if config.actionable_only {
220        vulns
221            .iter()
222            .filter(|v| {
223                !matches!(
224                    v.vex_state,
225                    Some(VexState::NotAffected) | Some(VexState::Fixed)
226                )
227            })
228            .collect()
229    } else if let Some(ref state_filter) = config.filter_state {
230        let target_state = parse_vex_state_filter(state_filter)?;
231        vulns
232            .iter()
233            .filter(|v| v.vex_state.as_ref() == target_state.as_ref())
234            .collect()
235    } else {
236        vulns.iter().collect()
237    };
238
239    let output = serde_json::to_string_pretty(&filtered)?;
240    let target = OutputTarget::from_option(config.output_file.clone());
241    write_output(&output, &target, false)?;
242
243    if !config.quiet {
244        eprintln!(
245            "Filtered: {} of {} vulnerabilities",
246            filtered.len(),
247            vulns.len()
248        );
249    }
250
251    // Exit code 1 if actionable-only and any remain
252    if config.actionable_only && !filtered.is_empty() {
253        return Ok(exit_codes::CHANGES_DETECTED);
254    }
255
256    Ok(exit_codes::SUCCESS)
257}
258
259// ============================================================================
260// Helpers
261// ============================================================================
262
263/// Simplified vulnerability entry for VEX command output.
264#[derive(Debug, serde::Serialize)]
265struct VulnEntry {
266    id: String,
267    severity: String,
268    component_name: String,
269    version: Option<String>,
270    vex_state: Option<VexState>,
271    vex_justification: Option<String>,
272    vex_impact: Option<String>,
273}
274
275/// Collect all vulnerabilities from an SBOM into a flat list.
276fn collect_all_vulns(sbom: &NormalizedSbom) -> Vec<VulnEntry> {
277    let mut entries = Vec::new();
278    for comp in sbom.components.values() {
279        for vuln in &comp.vulnerabilities {
280            let vex_source = vuln.vex_status.as_ref().or(comp.vex_status.as_ref());
281            entries.push(VulnEntry {
282                id: vuln.id.clone(),
283                severity: vuln
284                    .severity
285                    .as_ref()
286                    .map_or_else(|| "Unknown".to_string(), |s| s.to_string()),
287                component_name: comp.name.clone(),
288                version: comp.version.clone(),
289                vex_state: vex_source.map(|v| v.status.clone()),
290                vex_justification: vex_source
291                    .and_then(|v| v.justification.as_ref().map(|j| j.to_string())),
292                vex_impact: vex_source.and_then(|v| v.impact_statement.clone()),
293            });
294        }
295    }
296    entries
297}
298
299/// Parse a VEX state filter string into `Option<VexState>`.
300///
301/// Returns `None` for "none"/"missing" (meaning: match vulns without VEX).
302/// Returns `Err` for unrecognized values to prevent silent wrong results.
303fn parse_vex_state_filter(s: &str) -> Result<Option<VexState>> {
304    match s.to_lowercase().as_str() {
305        "not_affected" | "notaffected" => Ok(Some(VexState::NotAffected)),
306        "affected" => Ok(Some(VexState::Affected)),
307        "fixed" => Ok(Some(VexState::Fixed)),
308        "under_investigation" | "underinvestigation" | "in_triage" => {
309            Ok(Some(VexState::UnderInvestigation))
310        }
311        "none" | "missing" => Ok(None),
312        other => anyhow::bail!(
313            "unknown VEX state filter: '{other}'. Valid values: \
314             not_affected, affected, fixed, under_investigation, none"
315        ),
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_parse_vex_state_filter() {
325        assert_eq!(
326            parse_vex_state_filter("not_affected").unwrap(),
327            Some(VexState::NotAffected)
328        );
329        assert_eq!(
330            parse_vex_state_filter("affected").unwrap(),
331            Some(VexState::Affected)
332        );
333        assert_eq!(
334            parse_vex_state_filter("fixed").unwrap(),
335            Some(VexState::Fixed)
336        );
337        assert_eq!(
338            parse_vex_state_filter("under_investigation").unwrap(),
339            Some(VexState::UnderInvestigation)
340        );
341        assert_eq!(parse_vex_state_filter("none").unwrap(), None);
342    }
343
344    #[test]
345    fn test_parse_vex_state_filter_rejects_unknown() {
346        assert!(parse_vex_state_filter("fixd").is_err());
347        assert!(parse_vex_state_filter("notaffected_typo").is_err());
348    }
349}