socket_patch_cli/commands/
vex.rs1use std::collections::HashMap;
17use std::path::PathBuf;
18
19use clap::Args;
20use socket_patch_core::crawlers::CrawlerOptions;
21use socket_patch_core::manifest::operations::read_manifest;
22use socket_patch_core::manifest::schema::PatchManifest;
23use socket_patch_core::utils::telemetry::{track_vex_failed, track_vex_generated};
24use socket_patch_core::vex::{
25 build_document, detect_product, BuildOptions, FailedPatch, VerifyOutcome,
26};
27
28use crate::args::{apply_env_toggles, GlobalArgs};
29use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls};
30use crate::json_envelope::{
31 Command, Envelope, EnvelopeError, PatchAction, PatchEvent,
32};
33
34#[derive(Args)]
35pub struct VexArgs {
36 #[command(flatten)]
37 pub common: GlobalArgs,
38
39 #[arg(long = "output", short = 'O', env = "SOCKET_VEX_OUTPUT")]
41 pub output: Option<PathBuf>,
42
43 #[arg(long = "product", env = "SOCKET_VEX_PRODUCT")]
52 pub product: Option<String>,
53
54 #[arg(long = "no-verify", env = "SOCKET_VEX_NO_VERIFY", default_value_t = false)]
60 pub no_verify: bool,
61
62 #[arg(long = "doc-id", env = "SOCKET_VEX_DOC_ID")]
66 pub doc_id: Option<String>,
67
68 #[arg(long = "compact", env = "SOCKET_VEX_COMPACT", default_value_t = false)]
70 pub compact: bool,
71}
72
73pub async fn run(args: VexArgs) -> i32 {
74 apply_env_toggles(&args.common);
75
76 if args.common.json && args.output.is_none() {
80 emit_envelope_error_and_track(
81 &args,
82 "json_requires_output",
83 "--json requires --output (the VEX document is itself JSON; \
84 route it to a file so the envelope can use stdout)",
85 )
86 .await;
87 return 2;
88 }
89
90 let manifest_path = args.common.resolved_manifest_path();
91
92 let manifest = match read_manifest(&manifest_path).await {
93 Ok(Some(m)) => m,
94 Ok(None) => {
95 emit_envelope_error_and_track(
96 &args,
97 "manifest_not_found",
98 &format!("Manifest not found at {}", manifest_path.display()),
99 )
100 .await;
101 return 2;
102 }
103 Err(e) => {
104 emit_envelope_error_and_track(&args, "manifest_unreadable", &e.to_string()).await;
105 return 2;
106 }
107 };
108
109 if manifest.patches.is_empty() {
110 emit_envelope_error_and_track(
111 &args,
112 "no_patches",
113 "Manifest is empty — nothing to attest. Run `socket-patch get` \
114 or `socket-patch scan --sync` first.",
115 )
116 .await;
117 return 1;
118 }
119
120 let product_id = match resolve_product_id(&args).await {
122 Ok(id) => id,
123 Err(reason) => {
124 emit_envelope_error_and_track(&args, "product_undetected", &reason).await;
125 return 2;
126 }
127 };
128
129 let outcome = if args.no_verify {
131 VerifyOutcome {
132 applied: manifest.patches.keys().cloned().collect(),
133 failed: Vec::new(),
134 }
135 } else {
136 let package_paths = resolve_package_paths(&args, &manifest).await;
137 socket_patch_core::vex::applied_patches(&manifest, &package_paths).await
138 };
139
140 if !outcome.failed.is_empty() && !args.common.silent && !args.common.json {
141 for f in &outcome.failed {
142 eprintln!(
143 "Warning: omitting patch for {} from VEX ({})",
144 f.purl, f.reason
145 );
146 }
147 }
148
149 let opts = BuildOptions {
151 product_id,
152 doc_id: args
153 .doc_id
154 .clone()
155 .unwrap_or_else(|| format!("urn:uuid:{}", uuid::Uuid::new_v4())),
156 author: "Socket".to_string(),
157 tooling: Some(format!("socket-patch {}", env!("CARGO_PKG_VERSION"))),
158 };
159
160 let doc = match build_document(&manifest, &outcome.applied, &opts) {
161 Some(doc) => doc,
162 None => {
163 track_vex_failed(
164 "no_applicable_patches",
165 args.common.api_token.as_deref(),
166 args.common.org.as_deref(),
167 )
168 .await;
169 emit_envelope_error_with_failures(
170 &args,
171 "no_applicable_patches",
172 "No applied patches with vulnerability metadata to attest.",
173 &outcome.failed,
174 );
175 return 1;
176 }
177 };
178
179 let serialized = if args.compact {
181 match serde_json::to_string(&doc) {
182 Ok(s) => s,
183 Err(e) => {
184 emit_envelope_error_and_track(&args, "serialize_failed", &e.to_string()).await;
185 return 2;
186 }
187 }
188 } else {
189 match serde_json::to_string_pretty(&doc) {
190 Ok(s) => s,
191 Err(e) => {
192 emit_envelope_error_and_track(&args, "serialize_failed", &e.to_string()).await;
193 return 2;
194 }
195 }
196 };
197
198 let wrote_to_file = match &args.output {
200 Some(path) => {
201 if let Err(e) = tokio::fs::write(path, &serialized).await {
202 emit_envelope_error_and_track(&args, "write_failed", &e.to_string()).await;
203 return 2;
204 }
205 true
206 }
207 None => {
208 println!("{serialized}");
209 false
210 }
211 };
212
213 if args.common.json {
215 emit_envelope_success(&args, &doc, &outcome.failed);
216 } else if wrote_to_file {
217 let path = args.output.as_ref().unwrap().display();
218 let stmt_count = doc.statements.len();
219 if !args.common.silent {
220 println!(
221 "Wrote OpenVEX document with {stmt_count} statement(s) to {path}"
222 );
223 }
224 } else if !args.common.silent && !args.common.json {
225 let stmt_count = doc.statements.len();
226 eprintln!("Emitted {stmt_count} VEX statement(s)");
227 }
228
229 track_vex_generated(
230 doc.statements.len(),
231 "openvex-0.2.0",
232 if wrote_to_file { "file" } else { "stdout" },
233 args.common.api_token.as_deref(),
234 args.common.org.as_deref(),
235 )
236 .await;
237
238 0
239}
240
241async fn resolve_product_id(args: &VexArgs) -> Result<String, String> {
243 if let Some(p) = &args.product {
244 return Ok(p.clone());
245 }
246 let detect = detect_product(&args.common.cwd).await;
247 for w in &detect.warnings {
248 if !args.common.silent && !args.common.json {
249 eprintln!("Warning: {w}");
250 }
251 }
252 detect.purl.ok_or_else(|| {
253 format!(
254 "Could not auto-detect a top-level product PURL in {}. \
255 Provide one with --product <purl> (e.g. pkg:npm/my-app@1.0.0).",
256 args.common.cwd.display()
257 )
258 })
259}
260
261async fn resolve_package_paths(
264 args: &VexArgs,
265 manifest: &PatchManifest,
266) -> HashMap<String, PathBuf> {
267 let purls: Vec<String> = manifest.patches.keys().cloned().collect();
268 let partitioned = partition_purls(&purls, args.common.ecosystems.as_deref());
269 let crawler_options = CrawlerOptions {
270 cwd: args.common.cwd.clone(),
271 global: args.common.global,
272 global_prefix: args.common.global_prefix.clone(),
273 batch_size: 0, };
275 find_packages_for_rollback(&partitioned, &crawler_options, args.common.silent).await
287}
288
289fn emit_envelope_error(args: &VexArgs, code: &str, message: &str) {
290 if args.common.json {
291 let mut env = Envelope::new(Command::Vex);
292 env.mark_error(EnvelopeError::new(code, message.to_string()));
293 println!("{}", env.to_pretty_json());
294 } else {
295 eprintln!("Error: {message}");
296 }
297}
298
299async fn emit_envelope_error_and_track(args: &VexArgs, code: &str, message: &str) {
303 track_vex_failed(
304 code,
305 args.common.api_token.as_deref(),
306 args.common.org.as_deref(),
307 )
308 .await;
309 emit_envelope_error(args, code, message);
310}
311
312fn emit_envelope_error_with_failures(
313 args: &VexArgs,
314 code: &str,
315 message: &str,
316 failures: &[FailedPatch],
317) {
318 if args.common.json {
319 let mut env = Envelope::new(Command::Vex);
320 for f in failures {
321 env.record(
322 PatchEvent::new(PatchAction::Skipped, f.purl.clone())
323 .with_reason(f.reason.clone(), "patch omitted from VEX"),
324 );
325 }
326 env.mark_error(EnvelopeError::new(code, message.to_string()));
327 println!("{}", env.to_pretty_json());
328 } else {
329 eprintln!("Error: {message}");
330 for f in failures {
331 eprintln!(" omitted: {} ({})", f.purl, f.reason);
332 }
333 }
334}
335
336fn emit_envelope_success(
337 _args: &VexArgs,
338 doc: &socket_patch_core::vex::Document,
339 failures: &[FailedPatch],
340) {
341 let mut env = Envelope::new(Command::Vex);
342 for st in &doc.statements {
343 for prod in &st.products {
344 for sub in &prod.subcomponents {
345 env.record(
346 PatchEvent::new(PatchAction::Verified, sub.id.clone())
347 .with_details(serde_json::json!({
348 "vulnerability": st.vulnerability.name,
349 "aliases": st.vulnerability.aliases,
350 "status": "not_affected",
351 })),
352 );
353 }
354 }
355 }
356 for f in failures {
357 env.record(
358 PatchEvent::new(PatchAction::Skipped, f.purl.clone())
359 .with_reason(f.reason.clone(), "patch omitted from VEX"),
360 );
361 }
362 if !failures.is_empty() {
363 env.mark_partial_failure();
364 }
365 println!("{}", env.to_pretty_json());
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
373 use clap::Parser;
374
375 #[derive(Parser)]
376 struct Wrap {
377 #[command(subcommand)]
378 cmd: Sub,
379 }
380
381 #[derive(clap::Subcommand)]
382 enum Sub {
383 Vex(VexArgs),
384 }
385
386 #[test]
387 fn parses_with_defaults() {
388 let w = Wrap::parse_from(["test", "vex"]);
389 match w.cmd {
390 Sub::Vex(args) => {
391 assert!(args.output.is_none());
392 assert!(args.product.is_none());
393 assert!(!args.no_verify);
394 assert!(args.doc_id.is_none());
395 assert!(!args.compact);
396 }
397 }
398 }
399
400 #[test]
401 fn parses_all_flags() {
402 let w = Wrap::parse_from([
403 "test",
404 "vex",
405 "--output",
406 "out.vex.json",
407 "--product",
408 "pkg:npm/app@1.0.0",
409 "--no-verify",
410 "--doc-id",
411 "urn:uuid:fixed",
412 "--compact",
413 ]);
414 match w.cmd {
415 Sub::Vex(args) => {
416 assert_eq!(args.output.unwrap().to_str(), Some("out.vex.json"));
417 assert_eq!(args.product.as_deref(), Some("pkg:npm/app@1.0.0"));
418 assert!(args.no_verify);
419 assert_eq!(args.doc_id.as_deref(), Some("urn:uuid:fixed"));
420 assert!(args.compact);
421 }
422 }
423 }
424}