1use crate::config::VexConfig;
9use crate::model::{NormalizedSbom, VexState};
10use crate::pipeline::{OutputTarget, exit_codes, write_output};
11use anyhow::Result;
12
13#[derive(Debug, Clone)]
15pub enum VexAction {
16 Apply,
18 Status,
20 Filter,
22 Export(VexExportFormat),
25}
26
27#[derive(Debug, Clone, Copy)]
30pub enum VexExportFormat {
31 Csaf,
32}
33
34#[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 #[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 #[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 #[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
89fn 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
108fn 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
117fn 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 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 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 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 if config.actionable_only && actionable > 0 {
209 return Ok(exit_codes::CHANGES_DETECTED);
210 }
211
212 Ok(exit_codes::SUCCESS)
213}
214
215fn 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 if config.actionable_only && !filtered.is_empty() {
253 return Ok(exit_codes::CHANGES_DETECTED);
254 }
255
256 Ok(exit_codes::SUCCESS)
257}
258
259#[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
275fn 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
299fn 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}