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}
23
24#[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 #[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 #[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 #[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
78fn 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
87fn 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 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 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 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 if config.actionable_only && actionable > 0 {
179 return Ok(exit_codes::CHANGES_DETECTED);
180 }
181
182 Ok(exit_codes::SUCCESS)
183}
184
185fn 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 if config.actionable_only && !filtered.is_empty() {
223 return Ok(exit_codes::CHANGES_DETECTED);
224 }
225
226 Ok(exit_codes::SUCCESS)
227}
228
229#[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
245fn 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
269fn 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}