From a2354101be2ca12872df4a1885eec9ccc5e16d34 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 29 May 2026 13:33:17 -0400 Subject: [PATCH 1/4] docs: consolidate global CLI options + document OpenVEX generation (#93) Move the flags that clap flattens into every subcommand into a single "Global Options" section (with env vars and precedence), and trim each command's table down to its command-specific flags. Also fixes a few stale entries (no `-d` short for `--dry-run`, repair's download-mode default is `diff` not `file`) and documents the previously-undocumented `--all-releases`, `--proxy-url`, `--lock-timeout`, `--break-lock`, `--debug`, and `--no-telemetry` flags. Adds a `vex` command entry and an "OpenVEX attestations" section covering product auto-detection, output channels, and how to feed the document into Grype/Trivy/vexctl. Co-authored-by: Claude Opus 4.8 (1M context) --- README.md | 197 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 121 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index af68c6c5..c626d1ad 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,38 @@ socket-patch 550e8400-e29b-41d4-a716-446655440000 # equivalent to: socket-patch get 550e8400-e29b-41d4-a716-446655440000 ``` +## Global Options + +These flags are accepted by **every** subcommand — they are flattened into each command's argument set, so `socket-patch --json --cwd ./app` works uniformly. A command silently ignores any global flag it doesn't use (e.g. `list --global` parses fine and the flag is a no-op). + +Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI arg > env var > default**, so a flag on the command line always wins over the environment. + +| Flag | Env var | Description | +|------|---------|-------------| +| `--cwd ` | `SOCKET_CWD` | Working directory (default: `.`). The manifest path is resolved relative to this. | +| `-m, --manifest-path ` | `SOCKET_MANIFEST_PATH` | Path to the patch manifest, resolved relative to `--cwd` (default: `.socket/manifest.json`). | +| `--api-url ` | `SOCKET_API_URL` | Socket API URL for the authenticated endpoint (default: `https://api.socket.dev`). | +| `--api-token ` | `SOCKET_API_TOKEN` | Socket API token. When omitted, the public patch proxy is used. | +| `-o, --org ` | `SOCKET_ORG_SLUG` | Organization slug. Auto-resolved when omitted and a token is set. | +| `--proxy-url ` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set. | +| `-e, --ecosystems ` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). | +| `--download-mode ` | `SOCKET_DOWNLOAD_MODE` | Artifact to fetch when local files are missing: `diff` (default, smallest delta), `package` (full per-package tarball), or `file` (legacy per-file blobs). | +| `--offline` | `SOCKET_OFFLINE` | Strict airgap: never contact the network. Operations that need remote data fail loudly. | +| `-g, --global` | `SOCKET_GLOBAL` | Operate on globally-installed packages. | +| `--global-prefix ` | `SOCKET_GLOBAL_PREFIX` | Override the path used to discover globally-installed packages. | +| `-j, --json` | `SOCKET_JSON` | Emit machine-readable JSON output. Every JSON response includes a `"status"` field (`"success"`, `"error"`, `"no_manifest"`, etc.) for reliable programmatic consumption. | +| `-v, --verbose` | `SOCKET_VERBOSE` | Show extra detail in human-readable output. | +| `-s, --silent` | `SOCKET_SILENT` | Suppress non-error output. | +| `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | +| `-y, --yes` | `SOCKET_YES` | Skip interactive confirmation prompts. | +| `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for mutating commands (`apply`, `rollback`, `repair`, `remove`). | +| `--break-lock` | `SOCKET_BREAK_LOCK` | Force-remove a stale `.socket/apply.lock` before acquiring it. Use only when no other socket-patch process is running; emits an auditable `lock_broken` event in the JSON envelope. | +| `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | +| `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | + ## Commands -All commands support `--json` for structured JSON output and `--cwd ` to set the working directory (default: `.`). Every JSON response includes a `"status"` field (`"success"`, `"error"`, `"no_manifest"`, etc.) for reliable programmatic consumption. +The tables below list only the **command-specific** flags. Every command also accepts the [Global Options](#global-options) above. ### `get` @@ -93,23 +122,18 @@ Alias: `download` socket-patch get [options] ``` -**Options:** +**Command-specific options** (plus all [Global Options](#global-options)): | Flag | Description | |------|-------------| -| `--org ` | Organization slug (required when using `SOCKET_API_TOKEN`) | | `--id` | Force identifier to be treated as a UUID | | `--cve` | Force identifier to be treated as a CVE ID | | `--ghsa` | Force identifier to be treated as a GHSA ID | | `-p, --package` | Force identifier to be treated as a package name | -| `-y, --yes` | Skip confirmation prompt for multiple patches | | `--save-only` | Download patch without applying it (alias: `--no-apply`) | -| `--one-off` | Apply patch immediately without saving to `.socket` folder | -| `-g, --global` | Apply to globally installed packages | -| `--global-prefix ` | Custom path to global `node_modules` | -| `--json` | Output results as JSON | -| `--api-token ` | Socket API token (overrides `SOCKET_API_TOKEN`) | -| `--api-url ` | Socket API URL (overrides `SOCKET_API_URL`) | -| `--cwd ` | Working directory (default: `.`) | +| `--one-off` | Apply patch immediately without saving to the `.socket` folder | +| `--all-releases` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one | + +> Authenticated lookups require an org: pass `--org ` (or set `SOCKET_ORG_SLUG`) when using `SOCKET_API_TOKEN`. **Examples:** ```bash @@ -144,24 +168,16 @@ Scan installed packages for available security patches. Since v3.0 `scan --sync` socket-patch scan [options] ``` -**Options:** +**Command-specific options** (plus all [Global Options](#global-options)): | Flag | Description | |------|-------------| | `--apply` | Download and apply selected patches in JSON mode (non-interactive). Without it, `scan --json` is read-only. | | `--prune` | Garbage-collect after the scan: remove manifest entries for uninstalled packages and orphan blob/diff/package-archive files. Off by default. | | `--sync` | Sugar for `--apply --prune`. The canonical bot-mode flag. | -| `-d, --dry-run` | Preview what `--apply`/`--prune`/`--sync` would do without mutating disk. | -| `--org ` | Organization slug | -| `--json` | Output results as JSON | -| `-y, --yes` | Skip confirmation prompts | -| `--ecosystems ` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`) | -| `-g, --global` | Scan globally installed packages | -| `--global-prefix ` | Custom path to global `node_modules` | | `--batch-size ` | Packages per API request (default: `100`) | -| `--download-mode ` | `diff` (default), `package`, or `file` | -| `--api-token ` | Socket API token (overrides `SOCKET_API_TOKEN`) | -| `--api-url ` | Socket API URL (overrides `SOCKET_API_URL`) | -| `--cwd ` | Working directory (default: `.`) | +| `--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) | + +> Use `--dry-run` to preview what `--apply`/`--prune`/`--sync` would do without mutating disk. **Examples:** ```bash @@ -199,20 +215,10 @@ Apply security patches from the local manifest. socket-patch apply [options] ``` -**Options:** +**Command-specific options** (plus all [Global Options](#global-options)): | Flag | Description | |------|-------------| -| `-d, --dry-run` | Verify patches without modifying files | -| `-s, --silent` | Only output errors | | `-f, --force` | Skip pre-application hash verification (apply even if package version differs) | -| `-m, --manifest-path ` | Path to manifest (default: `.socket/manifest.json`) | -| `--offline` | Do not download missing blobs; fail if any are missing | -| `-g, --global` | Apply to globally installed packages | -| `--global-prefix ` | Custom path to global `node_modules` | -| `--ecosystems ` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`) | -| `--json` | Output results as JSON | -| `-v, --verbose` | Show detailed per-file verification information | -| `--cwd ` | Working directory (default: `.`) | **Examples:** ```bash @@ -241,23 +247,10 @@ Rollback patches to restore original files. If no identifier is given, all patch socket-patch rollback [identifier] [options] ``` -**Options:** +**Command-specific options** (plus all [Global Options](#global-options)): | Flag | Description | |------|-------------| -| `-d, --dry-run` | Verify rollback without modifying files | -| `-s, --silent` | Only output errors | -| `-m, --manifest-path ` | Path to manifest (default: `.socket/manifest.json`) | -| `--offline` | Do not download missing blobs; fail if any are missing | -| `-g, --global` | Rollback globally installed packages | -| `--global-prefix ` | Custom path to global `node_modules` | -| `--one-off` | Rollback by fetching original files from API (no manifest required) | -| `--ecosystems ` | Restrict to specific ecosystems (comma-separated) | -| `--json` | Output results as JSON | -| `-v, --verbose` | Show detailed per-file verification information | -| `--org ` | Organization slug | -| `--api-token ` | Socket API token (overrides `SOCKET_API_TOKEN`) | -| `--api-url ` | Socket API URL (overrides `SOCKET_API_URL`) | -| `--cwd ` | Working directory (default: `.`) | +| `--one-off` | Rollback by fetching original (`beforeHash`) files from the API — no manifest required | **Examples:** ```bash @@ -286,12 +279,7 @@ List all patches in the local manifest. socket-patch list [options] ``` -**Options:** -| Flag | Description | -|------|-------------| -| `--json` | Output as JSON | -| `-m, --manifest-path ` | Path to manifest (default: `.socket/manifest.json`) | -| `--cwd ` | Working directory (default: `.`) | +No command-specific options — see [Global Options](#global-options) (`--json`, `--manifest-path`, `--cwd` are the relevant ones). **Examples:** ```bash @@ -330,15 +318,10 @@ socket-patch remove [options] **Arguments:** - `identifier` - Package PURL (e.g., `pkg:npm/package@version`) or patch UUID -**Options:** +**Command-specific options** (plus all [Global Options](#global-options)): | Flag | Description | |------|-------------| | `--skip-rollback` | Only update manifest, do not restore original files | -| `-g, --global` | Remove from globally installed packages | -| `--global-prefix ` | Custom path to global `node_modules` | -| `--json` | Output results as JSON | -| `-m, --manifest-path ` | Path to manifest (default: `.socket/manifest.json`) | -| `--cwd ` | Working directory (default: `.`) | **Examples:** ```bash @@ -368,16 +351,10 @@ Alias: `gc` socket-patch repair [options] ``` -**Options:** +**Command-specific options** (plus all [Global Options](#global-options)): | Flag | Description | |------|-------------| -| `-d, --dry-run` | Show what would be done without doing it | -| `--offline` | Skip network operations (cleanup only) | -| `--download-only` | Only download missing blobs, do not clean up | -| `--json` | Output results as JSON | -| `-m, --manifest-path ` | Path to manifest (default: `.socket/manifest.json`) | -| `--cwd ` | Working directory (default: `.`) | -| `--download-mode ` | `file` (default), `diff`, or `package` | +| `--download-only` | Only download missing artifacts, do not clean up (incompatible with `--offline`) | **Examples:** ```bash @@ -403,13 +380,7 @@ Configure `package.json` postinstall scripts to automatically apply patches afte socket-patch setup [options] ``` -**Options:** -| Flag | Description | -|------|-------------| -| `-d, --dry-run` | Preview changes without modifying files | -| `-y, --yes` | Skip confirmation prompt | -| `--json` | Output results as JSON | -| `--cwd ` | Working directory (default: `.`) | +No command-specific options — see [Global Options](#global-options) (`--dry-run`, `--yes`, `--json`, `--cwd` are the relevant ones). **Examples:** ```bash @@ -426,6 +397,78 @@ socket-patch setup --dry-run socket-patch setup --json -y ``` +### `vex` + +Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the vulnerabilities that the applied patches have mitigated. See [OpenVEX attestations](#openvex-attestations) below for the full workflow. + +**Usage:** +```bash +socket-patch vex [options] +``` + +**Command-specific options** (plus all [Global Options](#global-options)): +| Flag | Description | +|------|-------------| +| `-O, --output ` | Write the VEX document to this path instead of stdout. Required when combined with `--json`. (env: `SOCKET_VEX_OUTPUT`) | +| `--product ` | Override the auto-detected top-level product PURL/identifier. (env: `SOCKET_VEX_PRODUCT`) | +| `--no-verify` | Skip the on-disk file-hash check and trust the manifest — useful on a build machine that doesn't have the patched files laid out. (env: `SOCKET_VEX_NO_VERIFY`) | +| `--doc-id ` | Override the document `@id`. Default is a random `urn:uuid:` regenerated each run; pin this for a reproducible identifier. (env: `SOCKET_VEX_DOC_ID`) | +| `--compact` | Emit compact JSON instead of pretty-printed. (env: `SOCKET_VEX_COMPACT`) | + +**Examples:** +```bash +# Print a VEX document to stdout (human-readable status goes to stderr) +socket-patch vex + +# Write the document to a file +socket-patch vex --output socket.vex.json + +# CI shape: VEX doc to file, machine-readable envelope to stdout +socket-patch vex --json --output socket.vex.json + +# Generate on a build box without verifying on-disk files +socket-patch vex --no-verify --output socket.vex.json +``` + +## OpenVEX attestations + +`socket-patch vex` turns your local manifest into a signed-off statement of *which known vulnerabilities no longer affect your build* because a Socket patch has been applied. This lets vulnerability scanners stop flagging CVEs that you've already remediated in place — without bumping the package version. + +**How it works** + +1. Reads `.socket/manifest.json` and, unless `--no-verify` is passed, re-checks each patched file's hash on disk so the attestation only covers patches that are actually applied. +2. Auto-detects the top-level **product** identifier (override with `--product`), probing in order: + - `.git/config` `[remote "origin"]` → `pkg:github//` (similar for GitLab/Bitbucket; raw URL otherwise) + - `package.json` → `pkg:npm/@` + - `pyproject.toml` → `pkg:pypi/@` + - `Cargo.toml` → `pkg:cargo/@` +3. Emits an OpenVEX 0.2.0 document whose statements mark each mitigated vulnerability as `not_affected` (justification: the patch is present), suitable for piping into `vexctl`, Grype, Trivy, and similar tools. + +**Output channels** + +| Invocation | VEX document | stdout | +|------------|--------------|--------| +| _default_ (no `--output`, no `--json`) | stdout | human-readable status on stderr | +| `--output ` | the file | one-line summary | +| `--json --output ` | the file | machine-readable envelope (the CI shape) | + +`--json` requires `--output`, since the VEX document is itself JSON and would otherwise collide with the envelope on stdout. + +**Using it with a scanner** + +```bash +# Generate the attestation as part of CI, then hand it to a scanner +socket-patch vex --output socket.vex.json + +# Suppress already-patched findings in Grype +grype --vex socket.vex.json + +# Or with Trivy +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. + ## Scripting & CI/CD All commands support `--json` for machine-readable output. JSON responses always include a `"status"` field for easy error detection: @@ -452,6 +495,8 @@ When stdin is not a TTY (e.g., in CI pipelines), interactive prompts auto-procee ## Environment Variables +Every [Global Option](#global-options) has a matching `SOCKET_*` environment variable (listed in that table), and `vex`-specific flags map to `SOCKET_VEX_*`. The most commonly used variables are: + | Variable | Description | |----------|-------------| | `SOCKET_API_TOKEN` | API authentication token. Use the raw token (`sktsec_<...>_api`) shown when it was generated, **not** the SHA-512 hash (`sha512-...`) that the dashboard may also display for identification. | From d7f57d98ec8e1a05ce897fdc85fecb424a37d642 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 30 May 2026 08:40:46 -0400 Subject: [PATCH 2/4] feat(apply,scan): generate OpenVEX document inline via --vex (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(apply,scan): generate OpenVEX document inline via --vex 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) * 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) * ci(release): auto-roll CHANGELOG [Unreleased] over after publish 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) * chore: nudge CI 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 72 +++- CHANGELOG.md | 14 + README.md | 30 ++ 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 + scripts/rollover-changelog.sh | 73 ++++ 23 files changed, 1058 insertions(+), 116 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_embedded_vex.rs create mode 100755 scripts/rollover-changelog.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80079bf9..f2adc335 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,46 @@ 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 + 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." + 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${VERSION} [skip ci]" + git push origin "HEAD:${REF_NAME}" 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: 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(), } } 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 dafef0b42da435887a92001de7bc5f9cd632845b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 09:04:20 -0400 Subject: [PATCH 3/4] chore(deps): Bump tar from 0.4.45 to 0.4.46 (#95) Bumps [tar](https://github.com/composefs/tar-rs) from 0.4.45 to 0.4.46. - [Release notes](https://github.com/composefs/tar-rs/releases) - [Commits](https://github.com/composefs/tar-rs/compare/0.4.45...0.4.46) --- updated-dependencies: - dependency-name: tar dependency-version: 0.4.46 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4a2a74e..c1122c45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2542,9 +2542,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", diff --git a/Cargo.toml b/Cargo.toml index 9b9db0f1..3c32ab73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ tempfile = "=3.26.0" regex = "=1.12.3" once_cell = "=1.21.3" qbsdiff = "=1.4.4" -tar = "=0.4.45" +tar = "=0.4.46" flate2 = "=1.1.9" fs2 = "=0.4.3" wiremock = "=0.6.5" From a74c5c5467d7d36a7aead61046505ddfad2c6321 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 30 May 2026 22:54:20 -0400 Subject: [PATCH 4/4] chore(release): bump version to 3.3.0 (#96) Bump 3.2.0 -> 3.3.0 (minor) for the inline OpenVEX feature already staged under CHANGELOG `## [Unreleased]`. Synced via scripts/version-sync.sh across Cargo.toml (workspace version + core dep pin), Cargo.lock, all npm packages (main + platform shims + lockfile), and pypi/pyproject.toml. The Release workflow reads this version from Cargo.toml; once merged it can be dispatched to publish v3.3.0 and roll the CHANGELOG over. Co-authored-by: Claude Opus 4.8 (1M context) --- Cargo.lock | 4 +-- Cargo.toml | 4 +-- npm/socket-patch-android-arm64/package.json | 2 +- npm/socket-patch-darwin-arm64/package.json | 2 +- npm/socket-patch-darwin-x64/package.json | 2 +- npm/socket-patch-linux-arm-gnu/package.json | 2 +- npm/socket-patch-linux-arm-musl/package.json | 2 +- npm/socket-patch-linux-arm64-gnu/package.json | 2 +- .../package.json | 2 +- npm/socket-patch-linux-ia32-gnu/package.json | 2 +- npm/socket-patch-linux-ia32-musl/package.json | 2 +- npm/socket-patch-linux-x64-gnu/package.json | 2 +- npm/socket-patch-linux-x64-musl/package.json | 2 +- npm/socket-patch-win32-arm64/package.json | 2 +- npm/socket-patch-win32-ia32/package.json | 2 +- npm/socket-patch-win32-x64/package.json | 2 +- npm/socket-patch/package-lock.json | 32 +++++++++---------- npm/socket-patch/package.json | 30 ++++++++--------- pypi/socket-patch/pyproject.toml | 2 +- 19 files changed, 50 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1122c45..33f3e66e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2403,7 +2403,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket-patch-cli" -version = "3.2.0" +version = "3.3.0" dependencies = [ "base64", "clap", @@ -2428,7 +2428,7 @@ dependencies = [ [[package]] name = "socket-patch-core" -version = "3.2.0" +version = "3.3.0" dependencies = [ "flate2", "fs2", diff --git a/Cargo.toml b/Cargo.toml index 3c32ab73..704cc791 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,13 +3,13 @@ members = ["crates/socket-patch-core", "crates/socket-patch-cli"] resolver = "2" [workspace.package] -version = "3.2.0" +version = "3.3.0" edition = "2021" license = "MIT" repository = "https://github.com/SocketDev/socket-patch" [workspace.dependencies] -socket-patch-core = { path = "crates/socket-patch-core", version = "=3.2.0" } +socket-patch-core = { path = "crates/socket-patch-core", version = "=3.3.0" } clap = { version = "=4.5.60", features = ["derive", "env"] } serde = { version = "=1.0.228", features = ["derive"] } serde_json = { version = "=1.0.149", features = ["preserve_order"] } diff --git a/npm/socket-patch-android-arm64/package.json b/npm/socket-patch-android-arm64/package.json index d77225e4..ea485a9e 100644 --- a/npm/socket-patch-android-arm64/package.json +++ b/npm/socket-patch-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-android-arm64", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Android ARM64", "os": [ "android" diff --git a/npm/socket-patch-darwin-arm64/package.json b/npm/socket-patch-darwin-arm64/package.json index c5f78a44..4ac2472a 100644 --- a/npm/socket-patch-darwin-arm64/package.json +++ b/npm/socket-patch-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-darwin-arm64", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for macOS ARM64", "os": [ "darwin" diff --git a/npm/socket-patch-darwin-x64/package.json b/npm/socket-patch-darwin-x64/package.json index 3059a3f8..0d6e3e5e 100644 --- a/npm/socket-patch-darwin-x64/package.json +++ b/npm/socket-patch-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-darwin-x64", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for macOS x64", "os": [ "darwin" diff --git a/npm/socket-patch-linux-arm-gnu/package.json b/npm/socket-patch-linux-arm-gnu/package.json index 62344232..57148643 100644 --- a/npm/socket-patch-linux-arm-gnu/package.json +++ b/npm/socket-patch-linux-arm-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm-gnu", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux ARM (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-arm-musl/package.json b/npm/socket-patch-linux-arm-musl/package.json index 9b41b15a..3aa01e69 100644 --- a/npm/socket-patch-linux-arm-musl/package.json +++ b/npm/socket-patch-linux-arm-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm-musl", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux ARM (musl)", "os": [ "linux" diff --git a/npm/socket-patch-linux-arm64-gnu/package.json b/npm/socket-patch-linux-arm64-gnu/package.json index a247d4a3..cf8d6ac6 100644 --- a/npm/socket-patch-linux-arm64-gnu/package.json +++ b/npm/socket-patch-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm64-gnu", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-arm64-musl/package.json b/npm/socket-patch-linux-arm64-musl/package.json index df8e25f9..6e6ca89e 100644 --- a/npm/socket-patch-linux-arm64-musl/package.json +++ b/npm/socket-patch-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm64-musl", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux ARM64 (musl)", "os": [ "linux" diff --git a/npm/socket-patch-linux-ia32-gnu/package.json b/npm/socket-patch-linux-ia32-gnu/package.json index 71473e6b..35e38e9c 100644 --- a/npm/socket-patch-linux-ia32-gnu/package.json +++ b/npm/socket-patch-linux-ia32-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-ia32-gnu", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux ia32 (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-ia32-musl/package.json b/npm/socket-patch-linux-ia32-musl/package.json index d368ce36..a7777e5d 100644 --- a/npm/socket-patch-linux-ia32-musl/package.json +++ b/npm/socket-patch-linux-ia32-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-ia32-musl", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux ia32 (musl)", "os": [ "linux" diff --git a/npm/socket-patch-linux-x64-gnu/package.json b/npm/socket-patch-linux-x64-gnu/package.json index a41e71d9..2f01c01e 100644 --- a/npm/socket-patch-linux-x64-gnu/package.json +++ b/npm/socket-patch-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-x64-gnu", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-x64-musl/package.json b/npm/socket-patch-linux-x64-musl/package.json index 9fa95ace..b7b13c08 100644 --- a/npm/socket-patch-linux-x64-musl/package.json +++ b/npm/socket-patch-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-x64-musl", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Linux x64 (musl)", "os": [ "linux" diff --git a/npm/socket-patch-win32-arm64/package.json b/npm/socket-patch-win32-arm64/package.json index 0434d5c3..f1a4718f 100644 --- a/npm/socket-patch-win32-arm64/package.json +++ b/npm/socket-patch-win32-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-win32-arm64", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Windows ARM64", "os": [ "win32" diff --git a/npm/socket-patch-win32-ia32/package.json b/npm/socket-patch-win32-ia32/package.json index 9f90a93d..0f710f6b 100644 --- a/npm/socket-patch-win32-ia32/package.json +++ b/npm/socket-patch-win32-ia32/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-win32-ia32", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Windows ia32", "os": [ "win32" diff --git a/npm/socket-patch-win32-x64/package.json b/npm/socket-patch-win32-x64/package.json index d117a98d..a78c34e9 100644 --- a/npm/socket-patch-win32-x64/package.json +++ b/npm/socket-patch-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-win32-x64", - "version": "3.2.0", + "version": "3.3.0", "description": "socket-patch binary for Windows x64", "os": [ "win32" diff --git a/npm/socket-patch/package-lock.json b/npm/socket-patch/package-lock.json index fe00334c..520cfc41 100644 --- a/npm/socket-patch/package-lock.json +++ b/npm/socket-patch/package-lock.json @@ -1,12 +1,12 @@ { "name": "@socketsecurity/socket-patch", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@socketsecurity/socket-patch", - "version": "3.2.0", + "version": "3.3.0", "license": "MIT", "dependencies": { "zod": "3.25.76" @@ -22,20 +22,20 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "@socketsecurity/socket-patch-android-arm64": "3.2.0", - "@socketsecurity/socket-patch-darwin-arm64": "3.2.0", - "@socketsecurity/socket-patch-darwin-x64": "3.2.0", - "@socketsecurity/socket-patch-linux-arm-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-arm-musl": "3.2.0", - "@socketsecurity/socket-patch-linux-arm64-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-arm64-musl": "3.2.0", - "@socketsecurity/socket-patch-linux-ia32-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-ia32-musl": "3.2.0", - "@socketsecurity/socket-patch-linux-x64-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-x64-musl": "3.2.0", - "@socketsecurity/socket-patch-win32-arm64": "3.2.0", - "@socketsecurity/socket-patch-win32-ia32": "3.2.0", - "@socketsecurity/socket-patch-win32-x64": "3.2.0" + "@socketsecurity/socket-patch-android-arm64": "3.3.0", + "@socketsecurity/socket-patch-darwin-arm64": "3.3.0", + "@socketsecurity/socket-patch-darwin-x64": "3.3.0", + "@socketsecurity/socket-patch-linux-arm-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-arm-musl": "3.3.0", + "@socketsecurity/socket-patch-linux-arm64-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-arm64-musl": "3.3.0", + "@socketsecurity/socket-patch-linux-ia32-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-ia32-musl": "3.3.0", + "@socketsecurity/socket-patch-linux-x64-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-x64-musl": "3.3.0", + "@socketsecurity/socket-patch-win32-arm64": "3.3.0", + "@socketsecurity/socket-patch-win32-ia32": "3.3.0", + "@socketsecurity/socket-patch-win32-x64": "3.3.0" } }, "node_modules/@socketsecurity/socket-patch-android-arm64": { diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json index cd7c1cb2..37702a35 100644 --- a/npm/socket-patch/package.json +++ b/npm/socket-patch/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch", - "version": "3.2.0", + "version": "3.3.0", "description": "CLI tool and schema library for applying security patches to dependencies", "bin": { "socket-patch": "bin/socket-patch" @@ -42,19 +42,19 @@ "@types/node": "20.19.41" }, "optionalDependencies": { - "@socketsecurity/socket-patch-android-arm64": "3.2.0", - "@socketsecurity/socket-patch-darwin-arm64": "3.2.0", - "@socketsecurity/socket-patch-darwin-x64": "3.2.0", - "@socketsecurity/socket-patch-linux-arm-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-arm-musl": "3.2.0", - "@socketsecurity/socket-patch-linux-arm64-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-arm64-musl": "3.2.0", - "@socketsecurity/socket-patch-linux-ia32-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-ia32-musl": "3.2.0", - "@socketsecurity/socket-patch-linux-x64-gnu": "3.2.0", - "@socketsecurity/socket-patch-linux-x64-musl": "3.2.0", - "@socketsecurity/socket-patch-win32-arm64": "3.2.0", - "@socketsecurity/socket-patch-win32-ia32": "3.2.0", - "@socketsecurity/socket-patch-win32-x64": "3.2.0" + "@socketsecurity/socket-patch-android-arm64": "3.3.0", + "@socketsecurity/socket-patch-darwin-arm64": "3.3.0", + "@socketsecurity/socket-patch-darwin-x64": "3.3.0", + "@socketsecurity/socket-patch-linux-arm-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-arm-musl": "3.3.0", + "@socketsecurity/socket-patch-linux-arm64-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-arm64-musl": "3.3.0", + "@socketsecurity/socket-patch-linux-ia32-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-ia32-musl": "3.3.0", + "@socketsecurity/socket-patch-linux-x64-gnu": "3.3.0", + "@socketsecurity/socket-patch-linux-x64-musl": "3.3.0", + "@socketsecurity/socket-patch-win32-arm64": "3.3.0", + "@socketsecurity/socket-patch-win32-ia32": "3.3.0", + "@socketsecurity/socket-patch-win32-x64": "3.3.0" } } diff --git a/pypi/socket-patch/pyproject.toml b/pypi/socket-patch/pyproject.toml index 9a6c8890..2a6cec1c 100644 --- a/pypi/socket-patch/pyproject.toml +++ b/pypi/socket-patch/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "socket-patch" -version = "3.2.0" +version = "3.3.0" description = "CLI tool for applying security patches to dependencies" readme = "README.md" license = "MIT"