From f0650b1e447d8c004819e25ee2f067a02e7e7ede Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 29 May 2026 16:03:18 -0400 Subject: [PATCH 1/5] feat(apply,scan): generate OpenVEX document inline via --vex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `--vex ` argument to `apply` and `scan`. On a successful run, the command writes an OpenVEX 0.2.0 document to that path using the same engine as the standalone `vex` command, so a single invocation can both apply/scan patches and emit the attestation — the natural shape for CI and bot workflows. Core refactor: extract the product-resolve -> verify -> build -> serialize -> write -> telemetry pipeline out of `vex::run` into reusable `generate_vex` / `generate_vex_from_manifest_path` helpers (plain VexBuildParams / VexWriteSummary / VexGenError types). The standalone `vex` command now calls this helper with no behavior change. Embedded contract: - `--vex` is the trigger; `--vex-product` / `--vex-no-verify` / `--vex-doc-id` / `--vex-compact` mirror the standalone knobs (namespaced to avoid colliding with apply's --force vocabulary; reuse SOCKET_VEX_*). - Always written to the file, never stdout, so it never races --json. - Fail-the-command: a requested-but-failed VEX flips the exit code even when the apply/scan itself succeeded, surfacing the error in the JSON envelope (apply) / result (scan) with a stable code. - Built from the post-run manifest, verified against on-disk state; generated for real applies, --dry-run, and read-only scans alike. - JSON success adds a top-level `vex` summary { path, statements, format }. Tests: new e2e_embedded_vex.rs (apply parity, envelope field, fail path, scan no-verify success, scan verify-failure error); parse-test coverage in cli_parse_{apply,scan}; update CLI_CONTRACT.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 15 + crates/socket-patch-cli/src/commands/apply.rs | 65 +++- crates/socket-patch-cli/src/commands/get.rs | 6 + crates/socket-patch-cli/src/commands/scan.rs | 166 +++++++-- crates/socket-patch-cli/src/commands/vex.rs | 293 ++++++++++++---- crates/socket-patch-cli/src/json_envelope.rs | 22 ++ .../socket-patch-cli/tests/cli_parse_apply.rs | 38 ++ .../socket-patch-cli/tests/cli_parse_scan.rs | 33 ++ .../tests/e2e_embedded_vex.rs | 331 ++++++++++++++++++ .../tests/in_process_alternate_installers.rs | 1 + .../tests/in_process_cargo_apply.rs | 2 + .../tests/in_process_edge_cases.rs | 1 + .../tests/in_process_gem_apply.rs | 2 + .../tests/in_process_gem_multi_platform.rs | 1 + .../tests/in_process_pypi_apply.rs | 5 + .../tests/in_process_pypi_multi_release.rs | 1 + .../tests/in_process_python_envs.rs | 1 + .../in_process_remote_ecosystems_apply.rs | 1 + .../socket-patch-cli/tests/in_process_scan.rs | 1 + 19 files changed, 876 insertions(+), 109 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_embedded_vex.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 02db1f2c..41f7329c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -54,6 +54,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | Subcommand | Local arg | Env var | Purpose | |---|---|---|---| | `apply` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check | +| `apply`, `scan` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | +| `apply`, `scan` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | | `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune) | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + save-vs-apply mode | @@ -73,6 +75,18 @@ Beyond the globals above, each subcommand defines a small set of local arguments The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. +### Embedded VEX (`apply --vex` / `scan --vex`) + +`--vex ` folds OpenVEX 0.2.0 generation into `apply` and `scan`: on a successful run the command writes the document to `` using the same engine as the standalone `vex` command. The `--vex-*` flags mirror `vex`'s `--product` / `--no-verify` / `--doc-id` / `--compact` knobs (namespaced to avoid colliding with the host command), and reuse the standalone env vars (`SOCKET_VEX_PRODUCT`, etc.). They are inert unless `--vex` is set. + +Contract details: + +* **Always written to the file** — never stdout — so the document never races the command's own `--json` output. +* **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, empty/missing manifest, all patches unverified, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). +* **Built from the post-run manifest**, verified against on-disk state (unless `--vex-no-verify`). Generated for real applies, `--dry-run`, and read-only `scan` alike. +* **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. +* `apply`'s no-manifest early exit (the "No .socket folder found" success no-op) does **not** trigger VEX generation — there is nothing to attest. + `repair` keeps its `gc` visible alias. ## Environment variables @@ -105,6 +119,7 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names | `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. | | `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. | | `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. | +| `SOCKET_VEX` | `apply --vex` / `scan --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on `apply`/`scan` they bind to `--vex-product` etc. | ### Deprecated env vars diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 32d79af6..ca523598 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -21,8 +21,10 @@ use std::time::Duration; use tempfile::TempDir; use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::json_envelope::{ AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status, + VexSummary, }; /// Overlay every regular file from `src` into `dst` via hard link (falling @@ -68,6 +70,13 @@ pub struct ApplyArgs { /// Skip pre-application hash verification (apply even if package version differs). #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)] pub force: bool, + + /// On a successful apply, also generate an OpenVEX 0.2.0 document. + /// `--vex ` is the trigger; the `--vex-*` knobs mirror the + /// standalone `vex` command. A requested-but-failed VEX makes the + /// whole command exit non-zero even when patches applied cleanly. + #[command(flatten)] + pub vex: VexEmbedArgs, } /// True when every file the engine verified for this package is already @@ -282,6 +291,23 @@ pub async fn run(args: ApplyArgs) -> i32 { .filter(|r| r.success && !r.files_patched.is_empty()) .count(); + // Embedded VEX: only on a successful apply and only when + // `--vex ` was passed. Re-read the manifest fresh so + // verification observes the just-applied on-disk state. The + // result is folded into the JSON envelope / human output + // below and flips the exit code on failure (per the + // fail-the-command contract). `None` => not requested. + let vex_result = if success && args.vex.vex.is_some() { + let params = args.vex.to_build_params(); + Some( + generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path) + .await, + ) + } else { + None + }; + let vex_failed = matches!(vex_result, Some(Err(_))); + if args.common.json { let mut env = Envelope::new(Command::Apply); env.dry_run = args.common.dry_run; @@ -312,6 +338,19 @@ pub async fn run(args: ApplyArgs) -> i32 { if !success { env.mark_partial_failure(); } + match &vex_result { + Some(Ok(summary)) => { + env.vex = Some(VexSummary { + path: args.vex.vex.as_ref().unwrap().display().to_string(), + statements: summary.statements, + format: "openvex-0.2.0".to_string(), + }); + } + Some(Err(e)) => { + env.mark_error(EnvelopeError::new(e.code, e.message.clone())); + } + None => {} + } println!("{}", env.to_pretty_json()); } else if !args.common.silent && !results.is_empty() { let patched: Vec<_> = results.iter().filter(|r| r.success).collect(); @@ -389,6 +428,24 @@ pub async fn run(args: ApplyArgs) -> i32 { } } + // Human-readable VEX status (JSON mode already folded the + // outcome into the envelope above). + if !args.common.json && !args.common.silent { + match &vex_result { + Some(Ok(summary)) => { + println!( + "Wrote OpenVEX document with {} statement(s) to {}", + summary.statements, + args.vex.vex.as_ref().unwrap().display(), + ); + } + Some(Err(e)) => { + eprintln!("Error: VEX generation failed: {}", e.message); + } + None => {} + } + } + // Track telemetry if success { track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await; @@ -396,7 +453,13 @@ pub async fn run(args: ApplyArgs) -> i32 { track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await; } - if success { 0 } else { 1 } + // A requested-but-failed VEX flips an otherwise-successful + // apply to a non-zero exit (fail-the-command contract). + if success && !vex_failed { + 0 + } else { + 1 + } } Err(e) => { track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await; diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 25f3a5a3..fe8a7085 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -949,6 +949,9 @@ pub async fn download_and_apply_patches( ..crate::args::GlobalArgs::default() }, force: false, + // get drives apply internally; embedded VEX is opt-in on the + // top-level command, never on this internal invocation. + vex: Default::default(), }; let code = super::apply::run(apply_args).await; apply_succeeded = code == 0; @@ -1530,6 +1533,9 @@ async fn save_and_apply_patch( ..crate::args::GlobalArgs::default() }, force: false, + // get drives apply internally; embedded VEX is opt-in on the + // top-level command, never on this internal invocation. + vex: Default::default(), }; let code = super::apply::run(apply_args).await; apply_succeeded = code == 0; diff --git a/crates/socket-patch-cli/src/commands/scan.rs b/crates/socket-patch-cli/src/commands/scan.rs index 9279c83b..bacfd2f5 100644 --- a/crates/socket-patch-cli/src/commands/scan.rs +++ b/crates/socket-patch-cli/src/commands/scan.rs @@ -15,6 +15,7 @@ use std::collections::HashSet; use std::path::Path; use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::crawl_all_ecosystems; use crate::output::{color, confirm, format_severity, stderr_is_tty, stdout_is_tty}; @@ -289,6 +290,87 @@ pub struct ScanArgs { value_parser = clap::builder::BoolishValueParser::new(), )] pub all_releases: bool, + + /// On a successful scan, also generate an OpenVEX 0.2.0 document. + /// `--vex ` is the trigger; the `--vex-*` knobs mirror the + /// standalone `vex` command. The document is built from the manifest + /// as it stands after the scan (including any `--apply`/`--sync` + /// writes) and verified against on-disk state. A requested-but-failed + /// VEX makes the command exit non-zero. + #[command(flatten)] + pub vex: VexEmbedArgs, +} + +/// Embedded-VEX side-effect for `scan`'s JSON terminal returns. When +/// `--vex` was requested and `base_code` is 0, generate the OpenVEX +/// document from the post-scan manifest and fold the outcome into +/// `result` — a `vex` object on success, or `status: "error"` + `error` +/// on failure (per the fail-the-command contract). Returns the final exit +/// code: `base_code` when not requested / skipped / on VEX success, `1` +/// when VEX generation failed. Caller prints `result` after this returns. +async fn embed_vex_into_json( + common: &GlobalArgs, + vex_args: &VexEmbedArgs, + manifest_path: &Path, + base_code: i32, + result: &mut serde_json::Value, +) -> i32 { + if vex_args.vex.is_none() || base_code != 0 { + return base_code; + } + let params = vex_args.to_build_params(); + match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { + Ok(summary) => { + result["vex"] = serde_json::json!({ + "path": vex_args.vex.as_ref().unwrap().display().to_string(), + "statements": summary.statements, + "format": "openvex-0.2.0", + }); + 0 + } + Err(e) => { + result["status"] = serde_json::json!("error"); + result["error"] = serde_json::json!({ + "code": e.code, + "message": e.message, + }); + 1 + } + } +} + +/// Embedded-VEX side-effect for `scan`'s human-readable terminal returns. +/// Prints a one-line note (or error) and returns the final exit code: +/// `base_code` when not requested / skipped / on VEX success, `1` on VEX +/// failure. No-op unless `--vex` was set and `base_code` is 0. +async fn embed_vex_human( + common: &GlobalArgs, + vex_args: &VexEmbedArgs, + manifest_path: &Path, + base_code: i32, +) -> i32 { + if vex_args.vex.is_none() || base_code != 0 { + return base_code; + } + let params = vex_args.to_build_params(); + match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { + Ok(summary) => { + if !common.silent { + println!( + "Wrote OpenVEX document with {} statement(s) to {}", + summary.statements, + vex_args.vex.as_ref().unwrap().display(), + ); + } + 0 + } + Err(e) => { + if !common.silent { + eprintln!("Error: VEX generation failed: {}", e.message); + } + 1 + } + } } pub async fn run(args: ScanArgs) -> i32 { @@ -301,6 +383,12 @@ pub async fn run(args: ScanArgs) -> i32 { let apply = args.apply || args.sync; let prune = args.prune || args.sync; + // Resolved up-front (rather than at the GC site) because the embedded + // `--vex` side-effect reads the manifest at several terminal returns, + // including the early "no packages" exit before the GC block. + let manifest_path = args.common.resolved_manifest_path(); + let socket_dir = manifest_path.parent().unwrap().to_path_buf(); + let overrides = args.common.api_client_overrides(); let (mut api_client, mut use_public_proxy) = get_api_client_with_overrides(overrides.clone()).await; @@ -360,27 +448,39 @@ pub async fn run(args: ScanArgs) -> i32 { if show_progress { eprintln!(); } + // Telemetry: empty-scan still counts as a successful scan. + track_patch_scanned( + 0, + 0, + 0, + false, + args.common.ecosystems.clone().unwrap_or_default().as_slice(), + false, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { // When the crawler finds nothing, GC is intentionally skipped // — pruning every manifest entry on the assumption that the // user "uninstalled everything" is too destructive. Bots // that need full cleanup can call `repair` explicitly. No // `gc` field emitted because the user didn't request one. - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "success", - "scannedPackages": 0, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - })) - .unwrap() - ); + let mut result = serde_json::json!({ + "status": "success", + "scannedPackages": 0, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + "updates": [], + }); + let code = + embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + return code; } else if args.common.global || args.common.global_prefix.is_some() { println!("No global packages found."); } else { @@ -396,19 +496,7 @@ pub async fn run(args: ScanArgs) -> i32 { install_cmds.push_str("/composer"); println!("No packages found. Run {install_cmds} install first."); } - // Telemetry: empty-scan still counts as a successful scan. - track_patch_scanned( - 0, - 0, - 0, - false, - args.common.ecosystems.clone().unwrap_or_default().as_slice(), - false, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ) - .await; - return 0; + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } // Build ecosystem summary @@ -580,8 +668,7 @@ pub async fn run(args: ScanArgs) -> i32 { // Read existing manifest once for update detection. Used by both the // JSON-mode emission (always includes an `updates` array) and the // non-JSON table-print path (counts `updates_available`). - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path.parent().unwrap().to_path_buf(); + // (`manifest_path`/`socket_dir` are resolved at the top of `run`.) let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); let updates = detect_updates(existing_manifest.as_ref(), &all_packages_with_patches); @@ -731,8 +818,11 @@ pub async fn run(args: ScanArgs) -> i32 { }; } + let final_code = + embed_vex_into_json(&args.common, &args.vex, &manifest_path, apply_code, &mut result) + .await; println!("{}", serde_json::to_string_pretty(&result).unwrap()); - return apply_code; + return final_code; } // --- GC-only path (no --apply, just --prune) -------------------- @@ -749,15 +839,17 @@ pub async fn run(args: ScanArgs) -> i32 { }; } + let final_code = + embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; println!("{}", serde_json::to_string_pretty(&result).unwrap()); - return 0; + return final_code; } let use_color = stdout_is_tty(); if all_packages_with_patches.is_empty() { println!("\nNo patches available for installed packages."); - return 0; + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } let mut updates_available = 0usize; @@ -898,7 +990,7 @@ pub async fn run(args: ScanArgs) -> i32 { if downloadable_count == 0 { println!("\nNo downloadable patches (paid subscription required)."); - return 0; + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } // Fetch full PatchSearchResult for each package that has patches @@ -946,7 +1038,7 @@ pub async fn run(args: ScanArgs) -> i32 { if selected.is_empty() { println!("No patches selected."); - return 0; + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } // Display detailed summary of selected patches before confirming @@ -1013,7 +1105,7 @@ pub async fn run(args: ScanArgs) -> i32 { println!("\nTo apply a patch, run:"); println!(" socket-patch get "); println!(" socket-patch get "); - return 0; + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } // Download and apply @@ -1052,7 +1144,7 @@ pub async fn run(args: ScanArgs) -> i32 { } } - code + embed_vex_human(&args.common, &args.vex, &manifest_path, code).await } pub(crate) fn severity_order(s: &str) -> u8 { diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index f3c38ce3..df8df2ee 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -14,7 +14,7 @@ //! to stdout. This is the CI integration shape. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::Args; use socket_patch_core::crawlers::CrawlerOptions; @@ -22,7 +22,7 @@ use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::telemetry::{track_vex_failed, track_vex_generated}; use socket_patch_core::vex::{ - build_document, detect_product, BuildOptions, FailedPatch, VerifyOutcome, + build_document, detect_product, BuildOptions, Document, FailedPatch, VerifyOutcome, }; use crate::args::{apply_env_toggles, GlobalArgs}; @@ -70,6 +70,88 @@ pub struct VexArgs { pub compact: bool, } +/// VEX-generation knobs embedded into `apply` and `scan` via `--vex`. +/// +/// `--vex ` is the trigger: when set, the host command generates an +/// OpenVEX document at that path after a successful run. The remaining +/// `--vex-*` flags mirror the standalone `vex` command's knobs but are +/// namespaced so they don't collide with the host command's own +/// vocabulary (e.g. apply's `--force`). They are inert unless `--vex` is +/// set. +#[derive(Args, Default, Clone)] +pub struct VexEmbedArgs { + /// Generate an OpenVEX 0.2.0 document at this path after a successful + /// run. The document is always written to the file (never stdout), so + /// it never races the command's own `--json` output. + #[arg(long = "vex", env = "SOCKET_VEX")] + pub vex: Option, + + /// Override the auto-detected top-level product PURL for the VEX + /// document. See `socket-patch vex --product`. + #[arg(long = "vex-product", env = "SOCKET_VEX_PRODUCT")] + pub vex_product: Option, + + /// Skip the on-disk file-hash check when building the VEX document and + /// trust the manifest. See `socket-patch vex --no-verify`. + #[arg(long = "vex-no-verify", env = "SOCKET_VEX_NO_VERIFY", default_value_t = false)] + pub vex_no_verify: bool, + + /// Pin the VEX document `@id`. See `socket-patch vex --doc-id`. + #[arg(long = "vex-doc-id", env = "SOCKET_VEX_DOC_ID")] + pub vex_doc_id: Option, + + /// Emit compact (non-pretty) JSON for the VEX document. + #[arg(long = "vex-compact", env = "SOCKET_VEX_COMPACT", default_value_t = false)] + pub vex_compact: bool, +} + +impl VexEmbedArgs { + /// Build the core [`VexBuildParams`] from the embedded flags. The + /// output is always the `--vex` path (embedded VEX never writes to + /// stdout). Caller must have checked `self.vex.is_some()`. + pub(crate) fn to_build_params(&self) -> VexBuildParams { + VexBuildParams { + output: self.vex.clone(), + product: self.vex_product.clone(), + no_verify: self.vex_no_verify, + doc_id: self.vex_doc_id.clone(), + compact: self.vex_compact, + } + } +} + +/// Plain (non-clap) inputs to [`generate_vex`] so the standalone `vex` +/// command and the embedded `apply`/`scan` paths feed one code path. +pub(crate) struct VexBuildParams { + /// Where to write the document. `None` => stdout (standalone `vex` + /// only); embedded callers always pass `Some(path)`. + pub output: Option, + pub product: Option, + pub no_verify: bool, + pub doc_id: Option, + pub compact: bool, +} + +/// Successful result of [`generate_vex`]. +pub(crate) struct VexWriteSummary { + pub statements: usize, + pub failed: Vec, + pub wrote_to_file: bool, + /// The built document — returned so the standalone `vex` command can + /// emit its per-subcomponent envelope without rebuilding. + pub doc: Document, +} + +/// Failure from [`generate_vex`], carrying a stable code + message the +/// caller surfaces in its own output channel. +pub(crate) struct VexGenError { + pub code: &'static str, + pub message: String, + /// Patches omitted by verification, populated only for the + /// `no_applicable_patches` case (so callers can list them). + pub failed: Vec, +} + pub async fn run(args: VexArgs) -> i32 { apply_env_toggles(&args.common); @@ -117,27 +199,76 @@ pub async fn run(args: VexArgs) -> i32 { return 1; } + let params = VexBuildParams { + output: args.output.clone(), + product: args.product.clone(), + no_verify: args.no_verify, + doc_id: args.doc_id.clone(), + compact: args.compact, + }; + + match generate_vex(&args.common, ¶ms, &manifest).await { + Ok(summary) => { + if args.common.json { + emit_envelope_success(&summary.doc, &summary.failed); + } else if summary.wrote_to_file { + if !args.common.silent { + let path = args.output.as_ref().unwrap().display(); + println!( + "Wrote OpenVEX document with {} statement(s) to {path}", + summary.statements + ); + } + } else if !args.common.silent { + eprintln!("Emitted {} VEX statement(s)", summary.statements); + } + 0 + } + // `no_applicable_patches` is a soft "nothing to attest" (exit 1) + // and lists the omitted patches; every other error is a hard + // failure (exit 2). `generate_vex` already fired telemetry, so + // these emit-only sinks must not re-track. + Err(e) if e.code == "no_applicable_patches" => { + emit_envelope_error_with_failures(&args, e.code, &e.message, &e.failed); + 1 + } + Err(e) => { + emit_envelope_error(&args, e.code, &e.message); + 2 + } + } +} + +/// Core VEX pipeline shared by the standalone `vex` command and the +/// embedded `apply`/`scan` `--vex` paths: resolve the product, verify the +/// manifest against disk (unless `no_verify`), build the OpenVEX document, +/// serialize, write (or print to stdout when `output` is `None`), and fire +/// telemetry. Returns a [`VexWriteSummary`] on success or a structured +/// [`VexGenError`] (with a stable code) on failure. All `track_vex_*` +/// telemetry is fired here so every caller reports consistently. +pub(crate) async fn generate_vex( + common: &GlobalArgs, + params: &VexBuildParams, + manifest: &PatchManifest, +) -> Result { // Resolve product. - let product_id = match resolve_product_id(&args).await { + let product_id = match resolve_product_id(common, params.product.as_deref()).await { Ok(id) => id, - Err(reason) => { - emit_envelope_error_and_track(&args, "product_undetected", &reason).await; - return 2; - } + Err(reason) => return Err(fail(common, "product_undetected", reason).await), }; // Partition manifest into applied / failed. - let outcome = if args.no_verify { + let outcome = if params.no_verify { VerifyOutcome { applied: manifest.patches.keys().cloned().collect(), failed: Vec::new(), } } else { - let package_paths = resolve_package_paths(&args, &manifest).await; - socket_patch_core::vex::applied_patches(&manifest, &package_paths).await + let package_paths = resolve_package_paths(common, manifest).await; + socket_patch_core::vex::applied_patches(manifest, &package_paths).await }; - if !outcome.failed.is_empty() && !args.common.silent && !args.common.json { + if !outcome.failed.is_empty() && !common.silent && !common.json { for f in &outcome.failed { eprintln!( "Warning: omitting patch for {} from VEX ({})", @@ -149,7 +280,7 @@ pub async fn run(args: VexArgs) -> i32 { // Build the document. let opts = BuildOptions { product_id, - doc_id: args + doc_id: params .doc_id .clone() .unwrap_or_else(|| format!("urn:uuid:{}", uuid::Uuid::new_v4())), @@ -157,50 +288,41 @@ pub async fn run(args: VexArgs) -> i32 { tooling: Some(format!("socket-patch {}", env!("CARGO_PKG_VERSION"))), }; - let doc = match build_document(&manifest, &outcome.applied, &opts) { + let doc = match build_document(manifest, &outcome.applied, &opts) { Some(doc) => doc, None => { track_vex_failed( "no_applicable_patches", - args.common.api_token.as_deref(), - args.common.org.as_deref(), + common.api_token.as_deref(), + common.org.as_deref(), ) .await; - emit_envelope_error_with_failures( - &args, - "no_applicable_patches", - "No applied patches with vulnerability metadata to attest.", - &outcome.failed, - ); - return 1; + return Err(VexGenError { + code: "no_applicable_patches", + message: "No applied patches with vulnerability metadata to attest.".to_string(), + failed: outcome.failed, + }); } }; // Serialize. - let serialized = if args.compact { + let serialized = if params.compact { match serde_json::to_string(&doc) { Ok(s) => s, - Err(e) => { - emit_envelope_error_and_track(&args, "serialize_failed", &e.to_string()).await; - return 2; - } + Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await), } } else { match serde_json::to_string_pretty(&doc) { Ok(s) => s, - Err(e) => { - emit_envelope_error_and_track(&args, "serialize_failed", &e.to_string()).await; - return 2; - } + Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await), } }; // Write. - let wrote_to_file = match &args.output { + let wrote_to_file = match ¶ms.output { Some(path) => { if let Err(e) = tokio::fs::write(path, &serialized).await { - emit_envelope_error_and_track(&args, "write_failed", &e.to_string()).await; - return 2; + return Err(fail(common, "write_failed", e.to_string()).await); } true } @@ -210,42 +332,75 @@ pub async fn run(args: VexArgs) -> i32 { } }; - // Status reporting. - if args.common.json { - emit_envelope_success(&args, &doc, &outcome.failed); - } else if wrote_to_file { - let path = args.output.as_ref().unwrap().display(); - let stmt_count = doc.statements.len(); - if !args.common.silent { - println!( - "Wrote OpenVEX document with {stmt_count} statement(s) to {path}" - ); - } - } else if !args.common.silent && !args.common.json { - let stmt_count = doc.statements.len(); - eprintln!("Emitted {stmt_count} VEX statement(s)"); - } - track_vex_generated( doc.statements.len(), "openvex-0.2.0", if wrote_to_file { "file" } else { "stdout" }, - args.common.api_token.as_deref(), - args.common.org.as_deref(), + common.api_token.as_deref(), + common.org.as_deref(), ) .await; - 0 + Ok(VexWriteSummary { + statements: doc.statements.len(), + failed: outcome.failed, + wrote_to_file, + doc, + }) } -/// Pick the product PURL from `--product` or by filesystem auto-detect. -async fn resolve_product_id(args: &VexArgs) -> Result { - if let Some(p) = &args.product { - return Ok(p.clone()); +/// Read the manifest at `manifest_path`, then [`generate_vex`]. Manifest +/// read failures are wrapped as [`VexGenError`] so embedded callers +/// (`apply`/`scan`) get a single error channel. Used by the embedded +/// `--vex` paths, which always write to a file. +pub(crate) async fn generate_vex_from_manifest_path( + common: &GlobalArgs, + params: &VexBuildParams, + manifest_path: &Path, +) -> Result { + let manifest = match read_manifest(manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => { + return Err(fail( + common, + "manifest_not_found", + format!("Manifest not found at {}", manifest_path.display()), + ) + .await) + } + Err(e) => return Err(fail(common, "manifest_unreadable", e.to_string()).await), + }; + if manifest.patches.is_empty() { + return Err(fail( + common, + "no_patches", + "Manifest is empty — nothing to attest.".to_string(), + ) + .await); } - let detect = detect_product(&args.common.cwd).await; + generate_vex(common, params, &manifest).await +} + +/// Fire `vex_failed` telemetry and build the matching [`VexGenError`]. +/// Centralizes the "track then return error" pattern in [`generate_vex`]. +async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGenError { + track_vex_failed(code, common.api_token.as_deref(), common.org.as_deref()).await; + VexGenError { + code, + message, + failed: Vec::new(), + } +} + +/// Pick the product PURL from an explicit override or by filesystem +/// auto-detect. +async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Result { + if let Some(p) = product { + return Ok(p.to_string()); + } + let detect = detect_product(&common.cwd).await; for w in &detect.warnings { - if !args.common.silent && !args.common.json { + if !common.silent && !common.json { eprintln!("Warning: {w}"); } } @@ -253,7 +408,7 @@ async fn resolve_product_id(args: &VexArgs) -> Result { format!( "Could not auto-detect a top-level product PURL in {}. \ Provide one with --product (e.g. pkg:npm/my-app@1.0.0).", - args.common.cwd.display() + common.cwd.display() ) }) } @@ -261,15 +416,15 @@ async fn resolve_product_id(args: &VexArgs) -> Result { /// Walk the ecosystem dispatch to build the PURL -> on-disk-path map /// used by `vex::verify::applied_patches`. async fn resolve_package_paths( - args: &VexArgs, + common: &GlobalArgs, manifest: &PatchManifest, ) -> HashMap { let purls: Vec = manifest.patches.keys().cloned().collect(); - let partitioned = partition_purls(&purls, args.common.ecosystems.as_deref()); + let partitioned = partition_purls(&purls, common.ecosystems.as_deref()); let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), + cwd: common.cwd.clone(), + global: common.global, + global_prefix: common.global_prefix.clone(), batch_size: 0, // unused for find_packages_for_rollback }; // Use the rollback (qualified-aware) resolver, NOT @@ -283,7 +438,7 @@ async fn resolve_package_paths( // as `package_not_found`. The rollback variant fans each base path // back out to every qualified manifest PURL — the same mapping the // manifest was written with (`get` uses the same resolver). - find_packages_for_rollback(&partitioned, &crawler_options, args.common.silent).await + find_packages_for_rollback(&partitioned, &crawler_options, common.silent).await } fn emit_envelope_error(args: &VexArgs, code: &str, message: &str) { @@ -333,11 +488,7 @@ fn emit_envelope_error_with_failures( } } -fn emit_envelope_success( - _args: &VexArgs, - doc: &socket_patch_core::vex::Document, - failures: &[FailedPatch], -) { +fn emit_envelope_success(doc: &Document, failures: &[FailedPatch]) { let mut env = Envelope::new(Command::Vex); for st in &doc.statements { for prod in &st.products { diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 2db2ef26..e22bdd90 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -74,6 +74,27 @@ pub struct Envelope { /// with no sidecar contract (e.g. npm). #[serde(skip_serializing_if = "Vec::is_empty")] pub sidecars: Vec, + /// Present only when `--vex ` was passed to `apply`/`scan` and + /// an OpenVEX document was successfully generated as a side-effect of + /// the run. Describes where it landed and how many statements it + /// carries. A *failed* embedded VEX generation surfaces via `error` + /// (and flips the exit code), not here. + #[serde(skip_serializing_if = "Option::is_none")] + pub vex: Option, +} + +/// Summary of an OpenVEX document emitted as a side-effect of an +/// `apply`/`scan` run via `--vex`. The full document is written to +/// `path`; this is just the pointer + headline count for JSON consumers. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VexSummary { + /// Filesystem path the OpenVEX document was written to. + pub path: String, + /// Number of OpenVEX statements in the document. + pub statements: usize, + /// Document format tag, e.g. `"openvex-0.2.0"`. + pub format: String, } impl Envelope { @@ -89,6 +110,7 @@ impl Envelope { summary: Summary::default(), error: None, sidecars: Vec::new(), + vex: None, } } diff --git a/crates/socket-patch-cli/tests/cli_parse_apply.rs b/crates/socket-patch-cli/tests/cli_parse_apply.rs index 0d37d5b2..b4f831c3 100644 --- a/crates/socket-patch-cli/tests/cli_parse_apply.rs +++ b/crates/socket-patch-cli/tests/cli_parse_apply.rs @@ -44,6 +44,44 @@ fn defaults_match_contract() { assert!(!a.common.json); assert!(!a.common.verbose); assert_eq!(a.common.download_mode, "diff"); + // Embedded VEX is opt-in: off / unset by default. + assert_eq!(a.vex.vex, None); + assert_eq!(a.vex.vex_product, None); + assert!(!a.vex.vex_no_verify); + assert_eq!(a.vex.vex_doc_id, None); + assert!(!a.vex.vex_compact); +} + +// --------------------------------------------------------------------------- +// Embedded VEX flags (`--vex` + `--vex-*` passthrough). `--vex ` is +// the trigger; the rest mirror the standalone `vex` command's knobs. +// --------------------------------------------------------------------------- + +#[test] +fn vex_path_sets_output() { + assert_eq!( + parse_apply(&["--vex", "out.vex.json"]).vex.vex, + Some(PathBuf::from("out.vex.json")) + ); +} + +#[test] +fn vex_passthrough_flags() { + let a = parse_apply(&[ + "--vex", + "out.vex.json", + "--vex-product", + "pkg:npm/app@1.0.0", + "--vex-no-verify", + "--vex-doc-id", + "urn:uuid:fixed", + "--vex-compact", + ]); + assert_eq!(a.vex.vex, Some(PathBuf::from("out.vex.json"))); + assert_eq!(a.vex.vex_product.as_deref(), Some("pkg:npm/app@1.0.0")); + assert!(a.vex.vex_no_verify); + assert_eq!(a.vex.vex_doc_id.as_deref(), Some("urn:uuid:fixed")); + assert!(a.vex.vex_compact); } /// The `download_mode` default is pinned separately — it's the one diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 2eecd1e2..46266db1 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -63,6 +63,39 @@ fn defaults_match_contract() { !args.all_releases, "--all-releases default is false (narrow — installed-dist variant only)" ); + // Embedded VEX is opt-in: off / unset by default. + assert_eq!(args.vex.vex, None); + assert_eq!(args.vex.vex_product, None); + assert!(!args.vex.vex_no_verify); + assert_eq!(args.vex.vex_doc_id, None); + assert!(!args.vex.vex_compact); +} + +#[test] +fn vex_path_sets_output() { + assert_eq!( + parse_scan(&["--vex", "out.vex.json"]).vex.vex, + Some(std::path::PathBuf::from("out.vex.json")) + ); +} + +#[test] +fn vex_passthrough_flags() { + let args = parse_scan(&[ + "--vex", + "out.vex.json", + "--vex-product", + "pkg:npm/app@1.0.0", + "--vex-no-verify", + "--vex-doc-id", + "urn:uuid:fixed", + "--vex-compact", + ]); + assert_eq!(args.vex.vex, Some(std::path::PathBuf::from("out.vex.json"))); + assert_eq!(args.vex.vex_product.as_deref(), Some("pkg:npm/app@1.0.0")); + assert!(args.vex.vex_no_verify); + assert_eq!(args.vex.vex_doc_id.as_deref(), Some("urn:uuid:fixed")); + assert!(args.vex.vex_compact); } #[test] diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs new file mode 100644 index 00000000..504ff930 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -0,0 +1,331 @@ +//! End-to-end tests for embedded OpenVEX generation via `--vex` on the +//! `apply` and `scan` subcommands. +//! +//! These exercise the *integration* added on top of the core `vex` +//! pipeline (which `e2e_vex.rs` already covers): that a successful +//! `apply`/`scan` writes the VEX document, folds a `vex` summary into the +//! JSON envelope, and — per the fail-the-command contract — flips the +//! exit code (and surfaces an `error`) when VEX generation fails. +//! +//! All offline: `apply` runs against a pre-seeded `.socket/blobs/` cache, +//! and the `scan` cases find zero installed packages so no API call fires. + +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; + +use serde_json::Value; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use socket_patch_core::manifest::schema::{ + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, +}; + +fn binary() -> &'static str { + env!("CARGO_BIN_EXE_socket-patch") +} + +fn write_manifest(cwd: &Path, manifest: &PatchManifest) { + let dir = cwd.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("manifest.json"), + serde_json::to_string_pretty(manifest).unwrap(), + ) + .unwrap(); +} + +/// One-file, one-vuln patch record. +fn make_record( + uuid: &str, + file_name: &str, + before_hash: &str, + after_hash: &str, + vuln_id: &str, + cves: &[&str], +) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + file_name.to_string(), + PatchFileInfo { + before_hash: before_hash.to_string(), + after_hash: after_hash.to_string(), + }, + ); + let mut vulns = HashMap::new(); + vulns.insert( + vuln_id.to_string(), + VulnerabilityInfo { + cves: cves.iter().map(|s| s.to_string()).collect(), + summary: "test summary".to_string(), + severity: "high".to_string(), + description: "test description".to_string(), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: vulns, + description: format!("Patch {uuid}"), + license: "MIT".to_string(), + tier: "free".to_string(), + } +} + +/// Lay down a synthetic npm package with a single file at `before` +/// content, plus the matching `after` blob in `.socket/blobs/`, and a +/// manifest entry so an offline `apply` can patch it in place. +/// +/// Returns the `after_hash` (the on-disk hash once patched) so callers can +/// assert post-apply state. +fn seed_offline_apply(cwd: &Path) -> String { + let before = b"before contents\n"; + let after = b"after contents\n"; + let before_hash = compute_git_sha256_from_bytes(before); + let after_hash = compute_git_sha256_from_bytes(after); + + let pkg = cwd.join("node_modules").join("vuln-pkg"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"vuln-pkg","version":"1.0.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), before).unwrap(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/vuln-pkg@1.0.0".to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + &before_hash, + &after_hash, + "GHSA-aaaa-bbbb-cccc", + &["CVE-2024-0001"], + ), + ); + write_manifest(cwd, &manifest); + + let blobs = cwd.join(".socket").join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(&after_hash), after).unwrap(); + + after_hash +} + +// ────────────────────────────────────────────────────────────────────── +// apply --vex +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn apply_vex_writes_document_on_success() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let after_hash = seed_offline_apply(cwd); + let vex_path = cwd.join("apply.vex.json"); + + let out = Command::new(binary()) + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke apply"); + assert!( + out.status.success(), + "apply --vex should exit 0. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + // The patch was actually applied. + let on_disk = std::fs::read(cwd.join("node_modules/vuln-pkg/index.js")).unwrap(); + assert_eq!(compute_git_sha256_from_bytes(&on_disk), after_hash); + + // The VEX doc landed at --vex with a statement for our GHSA. + let doc: Value = + serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + assert_eq!(doc["@context"], "https://openvex.dev/ns/v0.2.0"); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-aaaa-bbbb-cccc"); + assert_eq!( + stmts[0]["products"][0]["@id"], "pkg:npm/my-app@1.0.0", + "product comes from --vex-product" + ); +} + +#[test] +fn apply_json_envelope_carries_vex_summary() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let vex_path = cwd.join("apply.vex.json"); + + let out = Command::new(binary()) + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--json", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke apply"); + assert!(out.status.success()); + + let env: Value = serde_json::from_slice(&out.stdout).expect("apply envelope JSON"); + assert_eq!(env["command"], "apply"); + assert_eq!(env["status"], "success"); + assert_eq!(env["vex"]["statements"], 1); + assert_eq!(env["vex"]["format"], "openvex-0.2.0"); + assert_eq!(env["vex"]["path"], vex_path.to_str().unwrap()); + assert!(vex_path.exists()); +} + +#[test] +fn apply_vex_failure_flips_exit_code() { + // Apply succeeds, but no product PURL can be detected (no root + // package.json / git remote) and none was supplied → VEX generation + // fails → the whole command exits non-zero and writes no file. + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let vex_path = cwd.join("apply.vex.json"); + + let out = Command::new(binary()) + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--json", + "--vex", + vex_path.to_str().unwrap(), + ]) + .output() + .expect("invoke apply"); + assert!( + !out.status.success(), + "a requested-but-failed VEX must flip the exit code" + ); + + let env: Value = serde_json::from_slice(&out.stdout).expect("apply envelope JSON"); + assert_eq!(env["status"], "error"); + assert_eq!(env["error"]["code"], "product_undetected"); + assert!(!vex_path.exists(), "no VEX file on failure"); + + // Patch still applied (apply itself succeeded before VEX failed). + let on_disk = std::fs::read(cwd.join("node_modules/vuln-pkg/index.js")).unwrap(); + assert_eq!(&on_disk, b"after contents\n"); +} + +// ────────────────────────────────────────────────────────────────────── +// scan --vex (read-only; zero installed packages → no network) +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn scan_json_vex_no_verify_emits_summary() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + + // Manifest with a vuln, but nothing installed on disk. With + // `--vex-no-verify` the manifest is trusted, so the empty-scan path + // still produces a document. + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/vuln-pkg@1.0.0".to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + &"a".repeat(64), + &"b".repeat(64), + "GHSA-aaaa-bbbb-cccc", + &["CVE-2024-0001"], + ), + ); + write_manifest(cwd, &manifest); + let vex_path = cwd.join("scan.vex.json"); + + let out = Command::new(binary()) + .args([ + "scan", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--vex", + vex_path.to_str().unwrap(), + "--vex-no-verify", + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke scan"); + assert!( + out.status.success(), + "scan --vex --vex-no-verify should exit 0. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let result: Value = serde_json::from_slice(&out.stdout).expect("scan JSON"); + assert_eq!(result["scannedPackages"], 0); + assert_eq!(result["vex"]["statements"], 1); + assert_eq!(result["vex"]["path"], vex_path.to_str().unwrap()); + + let doc: Value = + serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + assert_eq!(doc["statements"].as_array().unwrap().len(), 1); +} + +#[test] +fn scan_json_vex_verify_failure_is_error() { + // Verify mode (default), no installed packages → every manifest entry + // fails verification → no statements → fail-the-command. + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/vuln-pkg@1.0.0".to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + &"a".repeat(64), + &"b".repeat(64), + "GHSA-aaaa-bbbb-cccc", + &["CVE-2024-0001"], + ), + ); + write_manifest(cwd, &manifest); + let vex_path = cwd.join("scan.vex.json"); + + let out = Command::new(binary()) + .args([ + "scan", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke scan"); + assert!(!out.status.success(), "VEX verify failure must be non-zero"); + + let result: Value = serde_json::from_slice(&out.stdout).expect("scan JSON"); + assert_eq!(result["status"], "error"); + assert_eq!(result["error"]["code"], "no_applicable_patches"); + assert!(!vex_path.exists()); +} diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index c7ad0dd3..999a086d 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -47,6 +47,7 @@ fn default_apply(cwd: &Path) -> ApplyArgs { ..socket_patch_cli::args::GlobalArgs::default() }, force: false, + vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index f7020a21..7c915b35 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -219,6 +219,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { prune: false, sync: true, all_releases: false, + vex: Default::default(), }; // CARGO_HOME must be set in this process's env so the cargo crawler // probes the isolated location (not the developer's real ~/.cargo). @@ -291,6 +292,7 @@ async fn cargo_crawler_finds_real_fetched_crate() { prune: false, sync: false, all_releases: false, + vex: Default::default(), }; assert_eq!(scan_run(args).await, 0); std::env::remove_var("CARGO_HOME"); diff --git a/crates/socket-patch-cli/tests/in_process_edge_cases.rs b/crates/socket-patch-cli/tests/in_process_edge_cases.rs index 1d726ce8..74bc1c52 100644 --- a/crates/socket-patch-cli/tests/in_process_edge_cases.rs +++ b/crates/socket-patch-cli/tests/in_process_edge_cases.rs @@ -59,6 +59,7 @@ fn default_apply(cwd: &Path) -> ApplyArgs { ..socket_patch_cli::args::GlobalArgs::default() }, force: false, + vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index 1497e4a4..66a910be 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -204,6 +204,7 @@ async fn gem_install_scan_sync_patches_real_file() { prune: false, sync: true, all_releases: false, + vex: Default::default(), }; let code = scan_run(args).await; assert!(code == 0 || code == 1, "scan --sync exit: {code}"); @@ -264,6 +265,7 @@ async fn gem_crawler_finds_real_installed_gem() { prune: false, sync: false, all_releases: false, + vex: Default::default(), }; assert_eq!(scan_run(args).await, 0); } diff --git a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs index af2163b6..38356916 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs @@ -211,6 +211,7 @@ fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { prune: false, sync: false, all_releases, + vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index 2e948fc1..46a7ad73 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -238,6 +238,7 @@ async fn pypi_install_scan_sync_patches_real_file() { prune: false, sync: true, all_releases: false, + vex: Default::default(), }; // Avoid borrow problem with into_iter let _ = &mut args; @@ -299,6 +300,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { prune: false, sync: true, all_releases: false, + vex: Default::default(), }; let _ = scan_run(scan_args).await; @@ -320,6 +322,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { ..socket_patch_cli::args::GlobalArgs::default() }, force: true, + vex: Default::default(), }; let _ = apply_run(apply_args).await; @@ -374,6 +377,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { prune: false, sync: false, all_releases: false, + vex: Default::default(), }; let _ = scan_run(scan_args).await; @@ -449,6 +453,7 @@ async fn pypi_crawler_finds_real_installed_six() { prune: false, sync: false, all_releases: false, + vex: Default::default(), }; assert_eq!(scan_run(args).await, 0); } diff --git a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs index ba7612fa..10301b52 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs @@ -307,6 +307,7 @@ fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { prune: false, sync: false, all_releases, + vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 1a395173..6bf98900 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -59,6 +59,7 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs { prune: false, sync: false, all_releases: false, + vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index 16216e73..c348a1d1 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -61,6 +61,7 @@ fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { prune: false, sync: true, all_releases: false, + vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index ea71f33c..f855477b 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -37,6 +37,7 @@ fn default_args(cwd: &Path) -> ScanArgs { prune: false, sync: false, all_releases: false, + vex: Default::default(), } } From 3828cbea9473be24c7b5831934ca704362125103 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 29 May 2026 16:04:50 -0400 Subject: [PATCH 2/5] docs: changelog + README for inline --vex on apply/scan Add an Unreleased "Added" changelog entry, document the `--vex` / `--vex-*` flags in the apply & scan README tables with examples, and add an "Inline VEX on apply / scan" subsection covering the fail-the-command contract and JSON summary surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9690f14d..791de37b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ in this file — see `.github/workflows/release.yml` (`version` job). ## [Unreleased] +### Added + +- **Inline OpenVEX generation on `apply` and `scan` via `--vex `.** A + single successful `apply`/`scan` can now both patch and emit the OpenVEX + 0.2.0 attestation, instead of requiring a separate `socket-patch vex` step. + The `--vex-product` / `--vex-no-verify` / `--vex-doc-id` / `--vex-compact` + flags mirror the standalone `vex` knobs (and reuse the `SOCKET_VEX_*` env + vars). The document is always written to the given path (never stdout, so it + never races `--json`), built from the post-run manifest and verified against + on-disk state. JSON output gains a top-level `vex` summary + (`{ path, statements, format }`). A requested-but-failed VEX makes the + command exit non-zero even when the apply/scan itself succeeded, surfacing a + stable error code in the envelope. + ## [3.2.0] — 2026-05-29 A repo-wide correctness, security, and filesystem-safety hardening pass: every diff --git a/README.md b/README.md index c626d1ad..6f4b51aa 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ socket-patch scan [options] | `--sync` | Sugar for `--apply --prune`. The canonical bot-mode flag. | | `--batch-size ` | Packages per API request (default: `100`) | | `--all-releases` | Store patches for every release/distribution variant, not just the installed one — makes the manifest portable across environments (e.g. cross-platform CI caches) | +| `--vex ` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan). (env: `SOCKET_VEX`) | +| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | > Use `--dry-run` to preview what `--apply`/`--prune`/`--sync` would do without mutating disk. @@ -204,6 +206,9 @@ socket-patch scan --ecosystems npm # Scan global packages socket-patch scan -g + +# Scan + apply + emit an OpenVEX attestation in one pass +socket-patch scan --json --sync --yes --vex socket.vex.json ``` ### `apply` @@ -219,6 +224,8 @@ socket-patch apply [options] | Flag | Description | |------|-------------| | `-f, --force` | Skip pre-application hash verification (apply even if package version differs) | +| `--vex ` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan). (env: `SOCKET_VEX`) | +| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | **Examples:** ```bash @@ -236,6 +243,9 @@ socket-patch apply --offline # JSON output for CI/CD socket-patch apply --json + +# Apply and emit an OpenVEX attestation in one step +socket-patch apply --vex socket.vex.json ``` ### `rollback` @@ -469,6 +479,26 @@ trivy image --vex socket.vex.json Run `socket-patch get` or `socket-patch scan --sync` first — `vex` errors with `no_patches` against an empty manifest. +### Inline VEX on `apply` / `scan` + +You don't need a separate `vex` invocation: pass `--vex ` to `apply` or `scan` and the same OpenVEX document is generated as a side-effect of a successful run. + +```bash +# Patch and attest in one step +socket-patch apply --vex socket.vex.json + +# Discover, apply, prune, and attest — the full bot-mode pass +socket-patch scan --json --sync --yes --vex socket.vex.json +``` + +The `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, and `--vex-compact` flags mirror the standalone command's `--product` / `--no-verify` / `--doc-id` / `--compact` knobs. + +Contract: + +- The document is **always written to the file** (never stdout), so it never collides with the command's own `--json` output. JSON mode adds a top-level `vex` summary — `{ path, statements, format }` — to the envelope (`apply`) / result (`scan`). +- It's built from the manifest **as it stands after the run** (including any `--apply`/`--sync` writes) and verified against on-disk state unless `--vex-no-verify` is set. Generated for real applies, `--dry-run`, and read-only scans alike. +- **Fail-the-command:** if `--vex` was requested but generation fails (no detectable product, empty/missing manifest, nothing verified, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**, with a stable error code in the JSON output. + ## Scripting & CI/CD All commands support `--json` for machine-readable output. JSON responses always include a `"status"` field for easy error detection: From a7ad4cf551b1fcbac0e349f42ce82dd233a10dc9 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 29 May 2026 16:28:51 -0400 Subject: [PATCH 3/5] ci(release): auto-roll CHANGELOG [Unreleased] over after publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `scripts/rollover-changelog.sh` and a post-publish `changelog-rollover` job. After every artifact publishes, the job promotes `## [Unreleased]` to `## [] — ` and leaves a fresh empty `[Unreleased]` for the next cycle, then commits it back to the release branch (`[skip ci]`). The helper is idempotent and runs after publish, so it never fails the release: it's a no-op when a `## []` heading was written by hand or when `[Unreleased]` is empty, leaving the file byte-identical so there's nothing to commit. To make the new flow usable end-to-end, the pre-publish version-check now accepts a non-empty `[Unreleased]` section as valid release notes (in addition to an explicit `## [X.Y.Z]` heading), so maintainers can just add entries under `[Unreleased]` and let the rollover stamp them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 64 ++++++++++++++++++++++++++---- scripts/rollover-changelog.sh | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 7 deletions(-) create mode 100755 scripts/rollover-changelog.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80079bf9..5c9c87b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,20 +36,35 @@ jobs: exit 1 fi - - name: Check CHANGELOG.md has entry for version + - name: Check CHANGELOG.md has release notes for version run: | VERSION="${{ steps.read.outputs.VERSION }}" if [ ! -f CHANGELOG.md ]; then echo "::error::CHANGELOG.md does not exist at the repository root." exit 1 fi - # Accept either `## [X.Y.Z]` or `## X.Y.Z` headings, with an - # optional trailing space (followed by `— DATE`) or end-of-line. - if ! grep -qE "^## \[?${VERSION}\]?( |$)" CHANGELOG.md; then - echo "::error::CHANGELOG.md is missing an entry for version ${VERSION}." - echo "::error::Add a heading like \`## [${VERSION}] — $(date +%Y-%m-%d)\` describing the release before re-running." - exit 1 + # A release is valid two ways: + # 1. An explicit `## [X.Y.Z]` / `## X.Y.Z` heading already exists + # (notes written by hand), OR + # 2. The `## [Unreleased]` section is non-empty — the + # post-publish `changelog-rollover` job stamps it as + # `## [X.Y.Z] — DATE` after publishing. + if grep -qE "^## \[?${VERSION}\]?( |$)" CHANGELOG.md; then + echo "Found explicit CHANGELOG heading for ${VERSION}." + exit 0 fi + unreleased_content=$(awk ' + /^## \[Unreleased\]/ { inblock=1; next } + inblock && /^## / { inblock=0 } + inblock && NF { print } + ' CHANGELOG.md) + if [ -n "$unreleased_content" ]; then + echo "No explicit ${VERSION} heading, but [Unreleased] has content — it will be rolled over after publish." + exit 0 + fi + echo "::error::CHANGELOG.md has no release notes for ${VERSION}." + echo "::error::Add entries under \`## [Unreleased]\` (preferred — they roll over automatically), or a \`## [${VERSION}] — $(date +%Y-%m-%d)\` heading, before re-running." + exit 1 build: needs: version @@ -428,3 +443,38 @@ jobs: uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: dist/ + + # After every artifact has published, stamp the CHANGELOG: promote the + # `## [Unreleased]` section to `## [] — ` and leave a fresh + # empty `[Unreleased]` for the next cycle, then commit it back to the + # release branch. Idempotent — a no-op when a `## []` heading was + # written by hand. Runs last so a failed publish never rewrites history. + changelog-rollover: + needs: [version, github-release, cargo-publish, npm-publish, pypi-publish] + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout release branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.ref_name }} + # persist-credentials defaults to true so the rollover commit can + # be pushed back to the branch below. + + - name: Roll [Unreleased] over to the released version + run: bash scripts/rollover-changelog.sh "${{ needs.version.outputs.version }}" + + - name: Commit and push if changed + run: | + if git diff --quiet -- CHANGELOG.md; then + echo "CHANGELOG.md unchanged (heading already present); nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + # [skip ci] so this housekeeping commit doesn't retrigger CI. + git commit -m "chore(changelog): roll [Unreleased] over to v${{ needs.version.outputs.version }} [skip ci]" + git push origin "HEAD:${{ github.ref_name }}" diff --git a/scripts/rollover-changelog.sh b/scripts/rollover-changelog.sh new file mode 100755 index 00000000..17fb6aa8 --- /dev/null +++ b/scripts/rollover-changelog.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Roll the CHANGELOG.md `## [Unreleased]` section over to a released version. +# +# Usage: rollover-changelog.sh [date] +# the version just published (e.g. 3.3.0) +# [date] release date, YYYY-MM-DD (default: today, UTC) +# +# Effect: inserts a `## [] — ` heading immediately below the +# `## [Unreleased]` heading. Everything that was under `[Unreleased]` now +# lives under the new versioned heading, and `[Unreleased]` is left empty +# and ready for the next cycle — the standard Keep a Changelog rollover. +# +# Idempotent + safe by design (it runs *after* publish, so it must never +# fail the release): +# - If a `## []` heading already exists (a maintainer wrote the +# release notes by hand), this is a no-op. +# - If there is no `## [Unreleased]` heading, or it has no content to +# promote, this is a no-op. +# In every no-op case the file is left byte-for-byte unchanged so the +# caller's `git diff` check sees nothing to commit. + +VERSION="${1:?Usage: rollover-changelog.sh [date]}" +DATE="${2:-$(date -u +%Y-%m-%d)}" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FILE="${CHANGELOG_FILE:-$REPO_ROOT/CHANGELOG.md}" + +if [ ! -f "$FILE" ]; then + echo "::warning::$FILE not found; skipping changelog rollover." + exit 0 +fi + +# Already stamped (manual flow) — nothing to do. Matches `## [X.Y.Z]` or +# `## X.Y.Z`, with the version followed by a space or end-of-line, mirroring +# the release workflow's version-check. +if grep -qE "^## \[?${VERSION}\]?( |\$)" "$FILE"; then + echo "CHANGELOG already has a heading for ${VERSION}; nothing to roll over." + exit 0 +fi + +if ! grep -qE '^## \[Unreleased\]' "$FILE"; then + echo "::warning::No '## [Unreleased]' heading in $FILE; skipping rollover." + exit 0 +fi + +# Is there anything under [Unreleased] worth promoting? (any non-blank line +# between the [Unreleased] heading and the next `## ` heading) +unreleased_content="$(awk ' + /^## \[Unreleased\]/ { inblock=1; next } + inblock && /^## / { inblock=0 } + inblock && NF { print } +' "$FILE")" +if [ -z "$unreleased_content" ]; then + echo "::warning::[Unreleased] is empty; nothing to roll over for ${VERSION}." + exit 0 +fi + +tmp="$(mktemp)" +awk -v ver="$VERSION" -v date="$DATE" ' + /^## \[Unreleased\]/ && !done { + print + print "" + print "## [" ver "] — " date + done = 1 + next + } + { print } +' "$FILE" >"$tmp" +mv "$tmp" "$FILE" + +echo "Rolled [Unreleased] over to [${VERSION}] — ${DATE}." From da2512a2dccb387339a5bb497a68ef91a32ab75d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 30 May 2026 08:23:51 -0400 Subject: [PATCH 4/5] chore: nudge CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty commit to re-trigger the CI workflow — the previous push (a7ad4cf) did not fire a pull_request run. Co-Authored-By: Claude Opus 4.8 (1M context) From 1ac271554481b8a561648f96a5b221c6b89d017c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 30 May 2026 08:27:13 -0400 Subject: [PATCH 5/5] ci(release): fix zizmor template-injection in changelog-rollover The Audit GHA Workflows check (zizmor) flagged a High template-injection: `${{ github.ref_name }}` expanded directly inside the push `run:` block can inject attacker-controllable code via a crafted branch name. Pass workflow contexts (`github.ref_name`, `needs.version.outputs.version`) through `env:` and reference them as shell variables instead. Verified clean with `zizmor .github --gh-token --min-severity medium` (the exact CI invocation): "No findings to report." Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c9c87b2..f2adc335 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -464,9 +464,17 @@ jobs: # be pushed back to the branch below. - name: Roll [Unreleased] over to the released version - run: bash scripts/rollover-changelog.sh "${{ needs.version.outputs.version }}" + env: + VERSION: ${{ needs.version.outputs.version }} + run: bash scripts/rollover-changelog.sh "$VERSION" - name: Commit and push if changed + # Pass workflow contexts through env vars (never interpolate + # `${{ }}` directly into the shell) so a branch name can't inject + # code into this run block — see zizmor's template-injection audit. + env: + VERSION: ${{ needs.version.outputs.version }} + REF_NAME: ${{ github.ref_name }} run: | if git diff --quiet -- CHANGELOG.md; then echo "CHANGELOG.md unchanged (heading already present); nothing to commit." @@ -476,5 +484,5 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add CHANGELOG.md # [skip ci] so this housekeeping commit doesn't retrigger CI. - git commit -m "chore(changelog): roll [Unreleased] over to v${{ needs.version.outputs.version }} [skip ci]" - git push origin "HEAD:${{ github.ref_name }}" + git commit -m "chore(changelog): roll [Unreleased] over to v${VERSION} [skip ci]" + git push origin "HEAD:${REF_NAME}"