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_purls, 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_purls
274    };
275    find_packages_for_purls(&partitioned, &crawler_options, args.common.silent).await
276}
277
278fn emit_envelope_error(args: &VexArgs, code: &str, message: &str) {
279    if args.common.json {
280        let mut env = Envelope::new(Command::Vex);
281        env.mark_error(EnvelopeError::new(code, message.to_string()));
282        println!("{}", env.to_pretty_json());
283    } else {
284        eprintln!("Error: {message}");
285    }
286}
287
288/// Async error sink that mirrors `emit_envelope_error` and also fires
289/// the `vex_failed` telemetry event. Centralizes both side effects so
290/// each `return` site in `run` only needs one call.
291async fn emit_envelope_error_and_track(args: &VexArgs, code: &str, message: &str) {
292    track_vex_failed(
293        code,
294        args.common.api_token.as_deref(),
295        args.common.org.as_deref(),
296    )
297    .await;
298    emit_envelope_error(args, code, message);
299}
300
301fn emit_envelope_error_with_failures(
302    args: &VexArgs,
303    code: &str,
304    message: &str,
305    failures: &[FailedPatch],
306) {
307    if args.common.json {
308        let mut env = Envelope::new(Command::Vex);
309        for f in failures {
310            env.record(
311                PatchEvent::new(PatchAction::Skipped, f.purl.clone())
312                    .with_reason(f.reason.clone(), "patch omitted from VEX"),
313            );
314        }
315        env.mark_error(EnvelopeError::new(code, message.to_string()));
316        println!("{}", env.to_pretty_json());
317    } else {
318        eprintln!("Error: {message}");
319        for f in failures {
320            eprintln!("  omitted: {} ({})", f.purl, f.reason);
321        }
322    }
323}
324
325fn emit_envelope_success(
326    _args: &VexArgs,
327    doc: &socket_patch_core::vex::Document,
328    failures: &[FailedPatch],
329) {
330    let mut env = Envelope::new(Command::Vex);
331    for st in &doc.statements {
332        for prod in &st.products {
333            for sub in &prod.subcomponents {
334                env.record(
335                    PatchEvent::new(PatchAction::Verified, sub.id.clone())
336                        .with_details(serde_json::json!({
337                            "vulnerability": st.vulnerability.name,
338                            "aliases": st.vulnerability.aliases,
339                            "status": "not_affected",
340                        })),
341                );
342            }
343        }
344    }
345    for f in failures {
346        env.record(
347            PatchEvent::new(PatchAction::Skipped, f.purl.clone())
348                .with_reason(f.reason.clone(), "patch omitted from VEX"),
349        );
350    }
351    if !failures.is_empty() {
352        env.mark_partial_failure();
353    }
354    println!("{}", env.to_pretty_json());
355}
356
357#[cfg(test)]
358mod tests {
359    //! Lightweight tests at the args/wiring layer. End-to-end behavior
360    //! lives in `tests/e2e_vex*.rs`.
361    use super::*;
362    use clap::Parser;
363
364    #[derive(Parser)]
365    struct Wrap {
366        #[command(subcommand)]
367        cmd: Sub,
368    }
369
370    #[derive(clap::Subcommand)]
371    enum Sub {
372        Vex(VexArgs),
373    }
374
375    #[test]
376    fn parses_with_defaults() {
377        let w = Wrap::parse_from(["test", "vex"]);
378        match w.cmd {
379            Sub::Vex(args) => {
380                assert!(args.output.is_none());
381                assert!(args.product.is_none());
382                assert!(!args.no_verify);
383                assert!(args.doc_id.is_none());
384                assert!(!args.compact);
385            }
386        }
387    }
388
389    #[test]
390    fn parses_all_flags() {
391        let w = Wrap::parse_from([
392            "test",
393            "vex",
394            "--output",
395            "out.vex.json",
396            "--product",
397            "pkg:npm/app@1.0.0",
398            "--no-verify",
399            "--doc-id",
400            "urn:uuid:fixed",
401            "--compact",
402        ]);
403        match w.cmd {
404            Sub::Vex(args) => {
405                assert_eq!(args.output.unwrap().to_str(), Some("out.vex.json"));
406                assert_eq!(args.product.as_deref(), Some("pkg:npm/app@1.0.0"));
407                assert!(args.no_verify);
408                assert_eq!(args.doc_id.as_deref(), Some("urn:uuid:fixed"));
409                assert!(args.compact);
410            }
411        }
412    }
413}