Skip to main content

socket_patch_cli/commands/
vex.rs

1//! `socket-patch vex` — generate an OpenVEX 0.2.0 document.
2//!
3//! Reads the local manifest, optionally verifies each patch's on-disk
4//! state, and emits a VEX document describing the vulnerabilities that
5//! have been mitigated. Designed to be piped into vexctl, Grype, Trivy,
6//! and the like.
7//!
8//! Output channels:
9//! * Default (`--output` unset, `--json` unset): VEX JSON to stdout,
10//!   human-readable status to stderr.
11//! * `--output <path>` (no `--json`): VEX JSON to file, one-line
12//!   summary to stdout.
13//! * `--json` (requires `--output`): VEX JSON to file, envelope JSON
14//!   to stdout. This is the CI integration shape.
15
16use 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    /// Write the VEX document to this path instead of stdout.
40    #[arg(long = "output", short = 'O', env = "SOCKET_VEX_OUTPUT")]
41    pub output: Option<PathBuf>,
42
43    /// Override the auto-detected top-level product PURL/identifier.
44    /// Auto-detection probes (in order):
45    /// 1. `.git/config` `[remote "origin"]` — converted to
46    ///    `pkg:github/<owner>/<repo>` for github.com, similar for
47    ///    gitlab.com/bitbucket.org, raw URL otherwise.
48    /// 2. `package.json` → `pkg:npm/<name>@<version>`
49    /// 3. `pyproject.toml` → `pkg:pypi/<name>@<version>`
50    /// 4. `Cargo.toml` → `pkg:cargo/<name>@<version>`
51    #[arg(long = "product", env = "SOCKET_VEX_PRODUCT")]
52    pub product: Option<String>,
53
54    /// Skip the on-disk file-hash check and trust the manifest.
55    /// By default every manifest entry is verified before being
56    /// emitted; this flag flips that off — useful when generating a
57    /// VEX doc on a build machine that doesn't have the patched files
58    /// laid out yet.
59    #[arg(long = "no-verify", env = "SOCKET_VEX_NO_VERIFY", default_value_t = false)]
60    pub no_verify: bool,
61
62    /// Override the document `@id`. Default is `urn:uuid:<random v4>`,
63    /// regenerated on every invocation. Pin this to get a reproducible
64    /// doc identifier across runs.
65    #[arg(long = "doc-id", env = "SOCKET_VEX_DOC_ID")]
66    pub doc_id: Option<String>,
67
68    /// Emit compact JSON instead of pretty-printed.
69    #[arg(long = "compact", env = "SOCKET_VEX_COMPACT", default_value_t = false)]
70    pub compact: bool,
71}
72
73/// VEX-generation knobs embedded into `apply` and `scan` via `--vex`.
74///
75/// `--vex <path>` is the trigger: when set, the host command generates an
76/// OpenVEX document at that path after a successful run. The remaining
77/// `--vex-*` flags mirror the standalone `vex` command's knobs but are
78/// namespaced so they don't collide with the host command's own
79/// vocabulary (e.g. apply's `--force`). They are inert unless `--vex` is
80/// set.
81#[derive(Args, Default, Clone)]
82pub struct VexEmbedArgs {
83    /// Generate an OpenVEX 0.2.0 document at this path after a successful
84    /// run. The document is always written to the file (never stdout), so
85    /// it never races the command's own `--json` output.
86    #[arg(long = "vex", env = "SOCKET_VEX")]
87    pub vex: Option<PathBuf>,
88
89    /// Override the auto-detected top-level product PURL for the VEX
90    /// document. See `socket-patch vex --product`.
91    #[arg(long = "vex-product", env = "SOCKET_VEX_PRODUCT")]
92    pub vex_product: Option<String>,
93
94    /// Skip the on-disk file-hash check when building the VEX document and
95    /// trust the manifest. See `socket-patch vex --no-verify`.
96    #[arg(long = "vex-no-verify", env = "SOCKET_VEX_NO_VERIFY", default_value_t = false)]
97    pub vex_no_verify: bool,
98
99    /// Pin the VEX document `@id`. See `socket-patch vex --doc-id`.
100    #[arg(long = "vex-doc-id", env = "SOCKET_VEX_DOC_ID")]
101    pub vex_doc_id: Option<String>,
102
103    /// Emit compact (non-pretty) JSON for the VEX document.
104    #[arg(long = "vex-compact", env = "SOCKET_VEX_COMPACT", default_value_t = false)]
105    pub vex_compact: bool,
106}
107
108impl VexEmbedArgs {
109    /// Build the core [`VexBuildParams`] from the embedded flags. The
110    /// output is always the `--vex` path (embedded VEX never writes to
111    /// stdout). Caller must have checked `self.vex.is_some()`.
112    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
123/// Plain (non-clap) inputs to [`generate_vex`] so the standalone `vex`
124/// command and the embedded `apply`/`scan` paths feed one code path.
125pub(crate) struct VexBuildParams {
126    /// Where to write the document. `None` => stdout (standalone `vex`
127    /// only); embedded callers always pass `Some(path)`.
128    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
135/// Successful result of [`generate_vex`].
136pub(crate) struct VexWriteSummary {
137    pub statements: usize,
138    pub failed: Vec<FailedPatch>,
139    pub wrote_to_file: bool,
140    /// The built document — returned so the standalone `vex` command can
141    /// emit its per-subcomponent envelope without rebuilding.
142    pub doc: Document,
143}
144
145/// Failure from [`generate_vex`], carrying a stable code + message the
146/// caller surfaces in its own output channel.
147pub(crate) struct VexGenError {
148    pub code: &'static str,
149    pub message: String,
150    /// Patches omitted by verification, populated only for the
151    /// `no_applicable_patches` case (so callers can list them).
152    pub failed: Vec<FailedPatch>,
153}
154
155pub async fn run(args: VexArgs) -> i32 {
156    apply_env_toggles(&args.common);
157
158    // --json without --output would race the envelope and the VEX doc
159    // on the same stdout stream. Bail out with a clear error before
160    // doing any work.
161    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, &params, &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        // `no_applicable_patches` is a soft "nothing to attest" (exit 1)
228        // and lists the omitted patches; every other error is a hard
229        // failure (exit 2). `generate_vex` already fired telemetry, so
230        // these emit-only sinks must not re-track.
231        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
242/// Core VEX pipeline shared by the standalone `vex` command and the
243/// embedded `apply`/`scan` `--vex` paths: resolve the product, verify the
244/// manifest against disk (unless `no_verify`), build the OpenVEX document,
245/// serialize, write (or print to stdout when `output` is `None`), and fire
246/// telemetry. Returns a [`VexWriteSummary`] on success or a structured
247/// [`VexGenError`] (with a stable code) on failure. All `track_vex_*`
248/// telemetry is fired here so every caller reports consistently.
249pub(crate) async fn generate_vex(
250    common: &GlobalArgs,
251    params: &VexBuildParams,
252    manifest: &PatchManifest,
253) -> Result<VexWriteSummary, VexGenError> {
254    // Resolve product.
255    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    // Partition manifest into applied / failed.
261    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    // Build the document.
281    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    // Serialize.
309    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    // Write.
322    let wrote_to_file = match &params.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
352/// Read the manifest at `manifest_path`, then [`generate_vex`]. Manifest
353/// read failures are wrapped as [`VexGenError`] so embedded callers
354/// (`apply`/`scan`) get a single error channel. Used by the embedded
355/// `--vex` paths, which always write to a file.
356pub(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
384/// Fire `vex_failed` telemetry and build the matching [`VexGenError`].
385/// Centralizes the "track then return error" pattern in [`generate_vex`].
386async 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
395/// Pick the product PURL from an explicit override or by filesystem
396/// auto-detect.
397async 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
416/// Walk the ecosystem dispatch to build the PURL -> on-disk-path map
417/// used by `vex::verify::applied_patches`.
418async 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, // unused for find_packages_for_rollback
429    };
430    // Use the rollback (qualified-aware) resolver, NOT
431    // `find_packages_for_purls`. Release-variant ecosystems
432    // (PyPI / RubyGems / Maven) key the manifest by *qualified* PURLs
433    // (`?artifact_id=`, `?platform=`, `?classifier=&ext=`), but the
434    // crawler only knows the *base* PURL. `find_packages_for_purls`
435    // would key the result map by the base PURL, so the qualified
436    // lookups in `vex::applied_patches` would all miss and every
437    // PyPI/Gem/Maven patch would be silently dropped from the VEX doc
438    // as `package_not_found`. The rollback variant fans each base path
439    // back out to every qualified manifest PURL — the same mapping the
440    // manifest was written with (`get` uses the same resolver).
441    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
454/// Async error sink that mirrors `emit_envelope_error` and also fires
455/// the `vex_failed` telemetry event. Centralizes both side effects so
456/// each `return` site in `run` only needs one call.
457async 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    //! Lightweight tests at the args/wiring layer. End-to-end behavior
522    //! lives in `tests/e2e_vex*.rs`.
523    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}