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