1use std::collections::HashMap;
17use std::path::{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, Document, 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
73#[derive(Args, Default, Clone)]
82pub struct VexEmbedArgs {
83 #[arg(long = "vex", env = "SOCKET_VEX")]
87 pub vex: Option<PathBuf>,
88
89 #[arg(long = "vex-product", env = "SOCKET_VEX_PRODUCT")]
92 pub vex_product: Option<String>,
93
94 #[arg(long = "vex-no-verify", env = "SOCKET_VEX_NO_VERIFY", default_value_t = false)]
97 pub vex_no_verify: bool,
98
99 #[arg(long = "vex-doc-id", env = "SOCKET_VEX_DOC_ID")]
101 pub vex_doc_id: Option<String>,
102
103 #[arg(long = "vex-compact", env = "SOCKET_VEX_COMPACT", default_value_t = false)]
105 pub vex_compact: bool,
106}
107
108impl VexEmbedArgs {
109 pub(crate) fn to_build_params(&self) -> VexBuildParams {
113 VexBuildParams {
114 output: self.vex.clone(),
115 product: self.vex_product.clone(),
116 no_verify: self.vex_no_verify,
117 doc_id: self.vex_doc_id.clone(),
118 compact: self.vex_compact,
119 }
120 }
121}
122
123pub(crate) struct VexBuildParams {
126 pub output: Option<PathBuf>,
129 pub product: Option<String>,
130 pub no_verify: bool,
131 pub doc_id: Option<String>,
132 pub compact: bool,
133}
134
135pub(crate) struct VexWriteSummary {
137 pub statements: usize,
138 pub failed: Vec<FailedPatch>,
139 pub wrote_to_file: bool,
140 pub doc: Document,
143}
144
145pub(crate) struct VexGenError {
148 pub code: &'static str,
149 pub message: String,
150 pub failed: Vec<FailedPatch>,
153}
154
155pub async fn run(args: VexArgs) -> i32 {
156 apply_env_toggles(&args.common);
157
158 if args.common.json && args.output.is_none() {
162 emit_envelope_error_and_track(
163 &args,
164 "json_requires_output",
165 "--json requires --output (the VEX document is itself JSON; \
166 route it to a file so the envelope can use stdout)",
167 )
168 .await;
169 return 2;
170 }
171
172 let manifest_path = args.common.resolved_manifest_path();
173
174 let manifest = match read_manifest(&manifest_path).await {
175 Ok(Some(m)) => m,
176 Ok(None) => {
177 emit_envelope_error_and_track(
178 &args,
179 "manifest_not_found",
180 &format!("Manifest not found at {}", manifest_path.display()),
181 )
182 .await;
183 return 2;
184 }
185 Err(e) => {
186 emit_envelope_error_and_track(&args, "manifest_unreadable", &e.to_string()).await;
187 return 2;
188 }
189 };
190
191 if manifest.patches.is_empty() {
192 emit_envelope_error_and_track(
193 &args,
194 "no_patches",
195 "Manifest is empty — nothing to attest. Run `socket-patch get` \
196 or `socket-patch scan --sync` first.",
197 )
198 .await;
199 return 1;
200 }
201
202 let params = VexBuildParams {
203 output: args.output.clone(),
204 product: args.product.clone(),
205 no_verify: args.no_verify,
206 doc_id: args.doc_id.clone(),
207 compact: args.compact,
208 };
209
210 match generate_vex(&args.common, ¶ms, &manifest).await {
211 Ok(summary) => {
212 if args.common.json {
213 emit_envelope_success(&summary.doc, &summary.failed);
214 } else if summary.wrote_to_file {
215 if !args.common.silent {
216 let path = args.output.as_ref().unwrap().display();
217 println!(
218 "Wrote OpenVEX document with {} statement(s) to {path}",
219 summary.statements
220 );
221 }
222 } else if !args.common.silent {
223 eprintln!("Emitted {} VEX statement(s)", summary.statements);
224 }
225 0
226 }
227 Err(e) if e.code == "no_applicable_patches" => {
232 emit_envelope_error_with_failures(&args, e.code, &e.message, &e.failed);
233 1
234 }
235 Err(e) => {
236 emit_envelope_error(&args, e.code, &e.message);
237 2
238 }
239 }
240}
241
242pub(crate) async fn generate_vex(
250 common: &GlobalArgs,
251 params: &VexBuildParams,
252 manifest: &PatchManifest,
253) -> Result<VexWriteSummary, VexGenError> {
254 let product_id = match resolve_product_id(common, params.product.as_deref()).await {
256 Ok(id) => id,
257 Err(reason) => return Err(fail(common, "product_undetected", reason).await),
258 };
259
260 let outcome = if params.no_verify {
262 VerifyOutcome {
263 applied: manifest.patches.keys().cloned().collect(),
264 failed: Vec::new(),
265 }
266 } else {
267 let package_paths = resolve_package_paths(common, manifest).await;
268 socket_patch_core::vex::applied_patches(manifest, &package_paths).await
269 };
270
271 if !outcome.failed.is_empty() && !common.silent && !common.json {
272 for f in &outcome.failed {
273 eprintln!(
274 "Warning: omitting patch for {} from VEX ({})",
275 f.purl, f.reason
276 );
277 }
278 }
279
280 let opts = BuildOptions {
282 product_id,
283 doc_id: params
284 .doc_id
285 .clone()
286 .unwrap_or_else(|| format!("urn:uuid:{}", uuid::Uuid::new_v4())),
287 author: "Socket".to_string(),
288 tooling: Some(format!("socket-patch {}", env!("CARGO_PKG_VERSION"))),
289 };
290
291 let doc = match build_document(manifest, &outcome.applied, &opts) {
292 Some(doc) => doc,
293 None => {
294 track_vex_failed(
295 "no_applicable_patches",
296 common.api_token.as_deref(),
297 common.org.as_deref(),
298 )
299 .await;
300 return Err(VexGenError {
301 code: "no_applicable_patches",
302 message: "No applied patches with vulnerability metadata to attest.".to_string(),
303 failed: outcome.failed,
304 });
305 }
306 };
307
308 let serialized = if params.compact {
310 match serde_json::to_string(&doc) {
311 Ok(s) => s,
312 Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await),
313 }
314 } else {
315 match serde_json::to_string_pretty(&doc) {
316 Ok(s) => s,
317 Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await),
318 }
319 };
320
321 let wrote_to_file = match ¶ms.output {
323 Some(path) => {
324 if let Err(e) = tokio::fs::write(path, &serialized).await {
325 return Err(fail(common, "write_failed", e.to_string()).await);
326 }
327 true
328 }
329 None => {
330 println!("{serialized}");
331 false
332 }
333 };
334
335 track_vex_generated(
336 doc.statements.len(),
337 "openvex-0.2.0",
338 if wrote_to_file { "file" } else { "stdout" },
339 common.api_token.as_deref(),
340 common.org.as_deref(),
341 )
342 .await;
343
344 Ok(VexWriteSummary {
345 statements: doc.statements.len(),
346 failed: outcome.failed,
347 wrote_to_file,
348 doc,
349 })
350}
351
352pub(crate) async fn generate_vex_from_manifest_path(
357 common: &GlobalArgs,
358 params: &VexBuildParams,
359 manifest_path: &Path,
360) -> Result<VexWriteSummary, VexGenError> {
361 let manifest = match read_manifest(manifest_path).await {
362 Ok(Some(m)) => m,
363 Ok(None) => {
364 return Err(fail(
365 common,
366 "manifest_not_found",
367 format!("Manifest not found at {}", manifest_path.display()),
368 )
369 .await)
370 }
371 Err(e) => return Err(fail(common, "manifest_unreadable", e.to_string()).await),
372 };
373 if manifest.patches.is_empty() {
374 return Err(fail(
375 common,
376 "no_patches",
377 "Manifest is empty — nothing to attest.".to_string(),
378 )
379 .await);
380 }
381 generate_vex(common, params, &manifest).await
382}
383
384async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGenError {
387 track_vex_failed(code, common.api_token.as_deref(), common.org.as_deref()).await;
388 VexGenError {
389 code,
390 message,
391 failed: Vec::new(),
392 }
393}
394
395async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Result<String, String> {
398 if let Some(p) = product {
399 return Ok(p.to_string());
400 }
401 let detect = detect_product(&common.cwd).await;
402 for w in &detect.warnings {
403 if !common.silent && !common.json {
404 eprintln!("Warning: {w}");
405 }
406 }
407 detect.purl.ok_or_else(|| {
408 format!(
409 "Could not auto-detect a top-level product PURL in {}. \
410 Provide one with --product <purl> (e.g. pkg:npm/my-app@1.0.0).",
411 common.cwd.display()
412 )
413 })
414}
415
416async fn resolve_package_paths(
419 common: &GlobalArgs,
420 manifest: &PatchManifest,
421) -> HashMap<String, PathBuf> {
422 let purls: Vec<String> = manifest.patches.keys().cloned().collect();
423 let partitioned = partition_purls(&purls, common.ecosystems.as_deref());
424 let crawler_options = CrawlerOptions {
425 cwd: common.cwd.clone(),
426 global: common.global,
427 global_prefix: common.global_prefix.clone(),
428 batch_size: 0, };
430 find_packages_for_rollback(&partitioned, &crawler_options, common.silent).await
442}
443
444fn emit_envelope_error(args: &VexArgs, code: &str, message: &str) {
445 if args.common.json {
446 let mut env = Envelope::new(Command::Vex);
447 env.mark_error(EnvelopeError::new(code, message.to_string()));
448 println!("{}", env.to_pretty_json());
449 } else {
450 eprintln!("Error: {message}");
451 }
452}
453
454async fn emit_envelope_error_and_track(args: &VexArgs, code: &str, message: &str) {
458 track_vex_failed(
459 code,
460 args.common.api_token.as_deref(),
461 args.common.org.as_deref(),
462 )
463 .await;
464 emit_envelope_error(args, code, message);
465}
466
467fn emit_envelope_error_with_failures(
468 args: &VexArgs,
469 code: &str,
470 message: &str,
471 failures: &[FailedPatch],
472) {
473 if args.common.json {
474 let mut env = Envelope::new(Command::Vex);
475 for f in failures {
476 env.record(
477 PatchEvent::new(PatchAction::Skipped, f.purl.clone())
478 .with_reason(f.reason.clone(), "patch omitted from VEX"),
479 );
480 }
481 env.mark_error(EnvelopeError::new(code, message.to_string()));
482 println!("{}", env.to_pretty_json());
483 } else {
484 eprintln!("Error: {message}");
485 for f in failures {
486 eprintln!(" omitted: {} ({})", f.purl, f.reason);
487 }
488 }
489}
490
491fn emit_envelope_success(doc: &Document, failures: &[FailedPatch]) {
492 let mut env = Envelope::new(Command::Vex);
493 for st in &doc.statements {
494 for prod in &st.products {
495 for sub in &prod.subcomponents {
496 env.record(
497 PatchEvent::new(PatchAction::Verified, sub.id.clone())
498 .with_details(serde_json::json!({
499 "vulnerability": st.vulnerability.name,
500 "aliases": st.vulnerability.aliases,
501 "status": "not_affected",
502 })),
503 );
504 }
505 }
506 }
507 for f in failures {
508 env.record(
509 PatchEvent::new(PatchAction::Skipped, f.purl.clone())
510 .with_reason(f.reason.clone(), "patch omitted from VEX"),
511 );
512 }
513 if !failures.is_empty() {
514 env.mark_partial_failure();
515 }
516 println!("{}", env.to_pretty_json());
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
524 use clap::Parser;
525
526 #[derive(Parser)]
527 struct Wrap {
528 #[command(subcommand)]
529 cmd: Sub,
530 }
531
532 #[derive(clap::Subcommand)]
533 enum Sub {
534 Vex(VexArgs),
535 }
536
537 #[test]
538 fn parses_with_defaults() {
539 let w = Wrap::parse_from(["test", "vex"]);
540 match w.cmd {
541 Sub::Vex(args) => {
542 assert!(args.output.is_none());
543 assert!(args.product.is_none());
544 assert!(!args.no_verify);
545 assert!(args.doc_id.is_none());
546 assert!(!args.compact);
547 }
548 }
549 }
550
551 #[test]
552 fn parses_all_flags() {
553 let w = Wrap::parse_from([
554 "test",
555 "vex",
556 "--output",
557 "out.vex.json",
558 "--product",
559 "pkg:npm/app@1.0.0",
560 "--no-verify",
561 "--doc-id",
562 "urn:uuid:fixed",
563 "--compact",
564 ]);
565 match w.cmd {
566 Sub::Vex(args) => {
567 assert_eq!(args.output.unwrap().to_str(), Some("out.vex.json"));
568 assert_eq!(args.product.as_deref(), Some("pkg:npm/app@1.0.0"));
569 assert!(args.no_verify);
570 assert_eq!(args.doc_id.as_deref(), Some("urn:uuid:fixed"));
571 assert!(args.compact);
572 }
573 }
574 }
575}