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::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    /// 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
73pub async fn run(args: VexArgs) -> i32 {
74    apply_env_toggles(&args.common);
75
76    // --json without --output would race the envelope and the VEX doc
77    // on the same stdout stream. Bail out with a clear error before
78    // doing any work.
79    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    // Resolve product.
121    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    // Partition manifest into applied / failed.
130    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    // Build the document.
150    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    // Serialize.
180    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    // Write.
199    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    // Status reporting.
214    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
241/// Pick the product PURL from `--product` or by filesystem auto-detect.
242async 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
261/// Walk the ecosystem dispatch to build the PURL -> on-disk-path map
262/// used by `vex::verify::applied_patches`.
263async 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, // unused for find_packages_for_rollback
274    };
275    // Use the rollback (qualified-aware) resolver, NOT
276    // `find_packages_for_purls`. Release-variant ecosystems
277    // (PyPI / RubyGems / Maven) key the manifest by *qualified* PURLs
278    // (`?artifact_id=`, `?platform=`, `?classifier=&ext=`), but the
279    // crawler only knows the *base* PURL. `find_packages_for_purls`
280    // would key the result map by the base PURL, so the qualified
281    // lookups in `vex::applied_patches` would all miss and every
282    // PyPI/Gem/Maven patch would be silently dropped from the VEX doc
283    // as `package_not_found`. The rollback variant fans each base path
284    // back out to every qualified manifest PURL — the same mapping the
285    // manifest was written with (`get` uses the same resolver).
286    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
299/// Async error sink that mirrors `emit_envelope_error` and also fires
300/// the `vex_failed` telemetry event. Centralizes both side effects so
301/// each `return` site in `run` only needs one call.
302async 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    //! Lightweight tests at the args/wiring layer. End-to-end behavior
371    //! lives in `tests/e2e_vex*.rs`.
372    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}