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")]
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 #[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 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 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
128fn 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
147fn 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
161fn 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 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 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 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 if config.actionable_only && actionable > 0 {
267 return Ok(exit_codes::CHANGES_DETECTED);
268 }
269
270 Ok(exit_codes::SUCCESS)
271}
272
273fn 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 if config.actionable_only && !filtered.is_empty() {
295 return Ok(exit_codes::CHANGES_DETECTED);
296 }
297
298 Ok(exit_codes::SUCCESS)
299}
300
301#[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
317fn is_actionable(v: &VulnEntry) -> bool {
321 !matches!(
322 v.vex_state,
323 Some(VexState::NotAffected) | Some(VexState::Fixed)
324 )
325}
326
327fn 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
337fn 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
351fn 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
375fn 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 let filtered = filter_entries(&vulns, &config_with(true, Some("fixed"))).unwrap();
497 assert!(filtered.is_empty());
498
499 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}