From 13377c4e67f4561e16a51947b8b6c1bdafad1fc8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 28 Jul 2026 11:32:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(release):=20publish=20to=20every=20ecosyst?= =?UTF-8?q?em=20from=20one=20release=20dispatch=20=E2=80=94=20Maven=20Cent?= =?UTF-8?q?ral=20+=20NuGet=20channels,=20consolidated=20release.yml,=20Pac?= =?UTF-8?q?kagist=20root=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new distribution channels, both download-on-first-run launchers mirroring gem/composer (version-matched GitHub release binary, SHA256SUMS verification, HTTPS-only incl. redirects, per-user cache, SOCKET_PATCH_BIN airgap escape): - Maven Central: dev.socket:socket-patch — dependency-free Java 11 launcher jar (java -jar / mvn dependency:copy / jbang), Central Portal publishing with GPG signing + sources/javadoc jars, env https_proxy support. - NuGet: SocketSecurity.SocketPatch — net8.0 dotnet tool (`dotnet tool install -g` puts socket-patch on PATH), executability-gated cache with self-heal, no shell-outs (BCL zip/tar). Release pipeline consolidation: - Delete release-ecosystems.yml: its `release: published` trigger could never fire (GitHub suppresses events from GITHUB_TOKEN-created releases), so the launcher-gem/Packagist jobs only ever ran by hand. All publishing now fans out from the single workflow_dispatch in release.yml, with launcher jobs gated on `needs: [version, github-release]`. - Fix the no-op tag guard (shallow tagless checkout) with a stateless `git ls-remote` check that permits same-commit retries; make github-release idempotent (view -> upload --clobber); probe crates.io before publishing; add `environment: pypi` + skip-existing; migrate both gems to one RubyGems OIDC trusted-publishing job; port the Packagist ping. - npm: add prepack:tsc so the published ./schema export actually contains dist/ (it pointed at a gitignored, never-built directory); install devDependencies with `npm install --no-save --ignore-scripts` — NOT `npm ci`, which refuses the lockfile's hollow optional platform entries (version-sync refreshes the lock while the new platform packages are unpublished; reproduced with npm 11.6.2); tolerate staged-but-unapproved versions on re-runs; refresh package-lock.json. - Packagist requires the manifest at the VCS repo root: move composer.json there (bin -> composer/socket-patch/bin/socket-patch) with a fail-closed .gitattributes export-ignore allowlist so the dist zip ships only the composer files. Deliberate side effect (documented): GitHub "Source code" release archives now contain only the allowlisted files. - version-sync.sh stamps the four new version sites; lint-ecosystems builds the jar (`mvn package -Dgpg.skip=true`) and packs the tool (`dotnet pack`) and validates the root composer.json. Both launchers e2e-verified against the live v3.3.0 release: fresh-cache download + SHA verify + `socket-patch 3.3.0`, cache hit, stub escape with exit-code propagation, non-executable-override fall-through, and (.NET) mode-stripped-cache self-heal. One-time registry setup (environments, trusted publishers, dev.socket DNS claim, GPG key, nuget.org policy) is listed in the PR description; RubyGems, Packagist, Maven Central and NuGet jobs skip gracefully (or fail loud where sanctioned) until credentials land. Co-Authored-By: Claude Fable 5 --- .gitattributes | 22 + .github/workflows/ci.yml | 22 +- .github/workflows/release-ecosystems.yml | 146 ------ .github/workflows/release.yml | 493 ++++++++++++++++-- .gitignore | 5 + CHANGELOG.md | 34 ++ README.md | 20 +- .../composer.json => composer.json | 2 +- composer/socket-patch/README.md | 8 + crates/socket-patch-cli/CLI_CONTRACT.md | 19 +- maven/socket-patch/README.md | 55 ++ maven/socket-patch/pom.xml | 155 ++++++ .../java/dev/socket/socketpatch/Launcher.java | 488 +++++++++++++++++ npm/socket-patch/package-lock.json | 168 +++++- npm/socket-patch/package.json | 1 + nuget/socket-patch/Program.cs | 480 +++++++++++++++++ nuget/socket-patch/README.md | 43 ++ .../SocketSecurity.SocketPatch.csproj | 47 ++ scripts/version-sync.sh | 46 ++ 19 files changed, 2022 insertions(+), 232 deletions(-) delete mode 100644 .github/workflows/release-ecosystems.yml rename composer/socket-patch/composer.json => composer.json (91%) create mode 100644 maven/socket-patch/README.md create mode 100644 maven/socket-patch/pom.xml create mode 100644 maven/socket-patch/src/main/java/dev/socket/socketpatch/Launcher.java create mode 100644 nuget/socket-patch/Program.cs create mode 100644 nuget/socket-patch/README.md create mode 100644 nuget/socket-patch/SocketSecurity.SocketPatch.csproj diff --git a/.gitattributes b/.gitattributes index 92ff3b6a..7684ae86 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,25 @@ # golden.test.ts). EOL conversion on checkout (Windows CRLF) breaks the # byte-for-byte comparison, so they are checked out exactly as committed. crates/socket-patch-core/tests/fixtures/redirect/** -text + +# Packagist dist allowlist (fail-closed). +# +# Packagist serves dist zips from GitHub codeload archives (git archive), +# which honor export-ignore. The Composer package manifest must live at the +# repository root (Packagist only publishes root manifests), so without this +# block the dist zip would ship the entire repository. The deny-by-default +# first line export-ignores every top-level entry, then the -export-ignore +# lines un-ignore exactly the files the Composer package needs — so any +# future top-level directory stays out of the dist zip automatically. +# +# Side effect (deliberate, but easy to miss): export-ignore applies to EVERY +# git-archive consumer, not just Packagist — GitHub's auto-generated +# "Source code (zip/tar.gz)" assets on releases and codeload tarballs +# (`npm install SocketDev/socket-patch#tag`, pip-from-archive-URL, distro +# packagers) will contain only the files allowlisted below. Anyone needing +# the full source should clone the repository or use the tagged tree. +/* export-ignore +/composer.json -export-ignore +/composer -export-ignore +/LICENSE -export-ignore +/README.md -export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c9fb001..39c56bab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,10 @@ jobs: # Lint the out-of-workspace packaging artifacts for the ecosystems whose setup # / CLI-distribution we added: the RubyGems CLI launcher gem + the Bundler - # plugin gem (Ruby) and the Composer CLI launcher (PHP). Ruby, PHP, and - # Composer are all pre-installed on the ubuntu-latest runner. + # plugin gem (Ruby), the Composer CLI launcher (PHP), the Maven Central + # launcher jar (Java), and the NuGet .NET-tool launcher. Ruby, PHP, Composer, + # a Temurin JDK + Maven, and the .NET SDK are all pre-installed on the + # ubuntu-latest runner. lint-ecosystems: runs-on: ubuntu-latest steps: @@ -60,9 +62,23 @@ jobs: ruby -c crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl - name: PHP — lint the Composer launcher + validate composer.json + # composer.json lives at the repo root (Packagist requires the + # manifest at the VCS root), so validate runs from there; the + # launcher script itself stays under composer/socket-patch/bin. run: | php -l composer/socket-patch/bin/socket-patch - ( cd composer/socket-patch && composer validate --no-check-publish ) + composer validate --no-check-publish + + - name: Java — build the Maven Central launcher jar + # `package` exercises compile + jar + the -sources/-javadoc plugins + # Central mandates; -Dgpg.skip because CI has no release signing key. + run: mvn --batch-mode --no-transfer-progress -f maven/socket-patch/pom.xml package -Dgpg.skip=true + + - name: .NET — pack the NuGet tool launcher + # `pack` (not just build) so the PackAsTool/ToolCommandName metadata + # is validated too. Output goes to RUNNER_TEMP to keep the checkout + # clean. + run: dotnet pack nuget/socket-patch -c Release -o "$RUNNER_TEMP/nupkg" - name: Shell — shellcheck the curl|sh installer # install.sh is the third distribution artifact this job lints; it diff --git a/.github/workflows/release-ecosystems.yml b/.github/workflows/release-ecosystems.yml deleted file mode 100644 index 37426485..00000000 --- a/.github/workflows/release-ecosystems.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Release (composer + RubyGems CLI) - -# Separate release for distributing the `socket-patch` CLI on the composer and -# RubyGems ecosystems. Decoupled from the main `release.yml`: these are thin -# launcher packages that download the prebuilt binary from the GitHub release at -# their own version, so they only need that release (and its `SHA256SUMS`) to -# already exist. Runs automatically once the main release publishes, or manually -# for a given version. -# -# Required secrets (each publish step skips gracefully when its secret is -# absent, so this workflow is safe to land before credentials are wired): -# - RUBYGEMS_API_KEY — a RubyGems API key with push scope for `socket-patch` -# - PACKAGIST_USERNAME / PACKAGIST_TOKEN — Packagist API credentials -# Preferred hardening: RubyGems / Packagist trusted publishing (OIDC) instead of -# long-lived keys, mirroring the crates.io / PyPI jobs in release.yml. - -on: - release: - types: [published] - workflow_dispatch: - inputs: - version: - description: 'Version to publish (no leading v), e.g. 3.3.0' - required: true - type: string - -permissions: {} - -jobs: - resolve-version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.VERSION }} - tag: ${{ steps.v.outputs.TAG }} - steps: - - name: Resolve version + tag - id: v - # Pass event/input data through env (never interpolate `${{ ... }}` - # directly into the shell) to avoid template-injection (zizmor). - env: - EVENT_NAME: ${{ github.event_name }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [ "$EVENT_NAME" = "release" ]; then - TAG="$RELEASE_TAG" - else - TAG="v${INPUT_VERSION}" - fi - VERSION="${TAG#v}" - echo "TAG=$TAG" >> "$GITHUB_OUTPUT" - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "Publishing CLI launcher packages for $TAG" - - rubygems-cli: - needs: resolve-version - runs-on: ubuntu-latest - # Scope the publish secret to a deployment environment (zizmor - # secrets-outside-env); also lets a maintainer gate publishing with required - # reviewers. Auto-created with no protection rules until configured. - environment: rubygems - permissions: - contents: read - steps: - - name: Checkout (at the release tag) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-version.outputs.tag }} - persist-credentials: false - - # Ruby is pre-installed on ubuntu-latest; no setup action needed. - - name: Lint + build the launcher gem - working-directory: gem/socket-patch - env: - EXPECTED_VERSION: ${{ needs.resolve-version.outputs.version }} - run: | - ruby -c lib/socket_patch/launcher.rb - ruby -c exe/socket-patch - # The gemspec version is baked at the tag by scripts/version-sync.sh. - gemver="$(ruby -e 'print Gem::Specification.load("socket-patch.gemspec").version')" - if [ "$gemver" != "$EXPECTED_VERSION" ]; then - echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" - exit 1 - fi - gem build socket-patch.gemspec - - - name: Publish socket-patch to RubyGems - working-directory: gem/socket-patch - # zizmor: ignore[use-trusted-publishing] - # Uses an API key for now; RubyGems trusted publishing (OIDC) is the - # documented future hardening (see the header), mirroring the crates.io - # / PyPI jobs. Suppressed until that publisher is registered. - env: - GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} - VERSION: ${{ needs.resolve-version.outputs.version }} - run: | - if [ -z "${GEM_HOST_API_KEY}" ]; then - echo "::notice title=RubyGems publish skipped::RUBYGEMS_API_KEY not set; built the gem but did not push." - exit 0 - fi - # `gem list -r -e -a` prints `socket-patch (3.3.0, 3.2.0, ...)`; match - # this version as a precise list element (preceded by `(`/space, - # followed by `,`/`)`). - if gem list --remote --exact --all socket-patch 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then - echo "socket-patch ${VERSION} already on RubyGems; skipping." - exit 0 - fi - gem push "socket-patch-${VERSION}.gem" - - packagist-cli: - needs: resolve-version - runs-on: ubuntu-latest - # Scope the Packagist secrets to a deployment environment (zizmor - # secrets-outside-env); auto-created with no protection until configured. - environment: packagist - permissions: - contents: read - steps: - - name: Checkout (at the release tag) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-version.outputs.tag }} - persist-credentials: false - - # PHP + composer are pre-installed on ubuntu-latest. - - name: Lint launcher + validate composer.json - working-directory: composer/socket-patch - run: | - php -l bin/socket-patch - composer validate --no-check-publish - - # Packagist is git-tag-driven: it ingests the tagged composer.json on its - # own (typically via a repo webhook). This step nudges its update API so - # the new tag is picked up promptly; without credentials it is a no-op. - - name: Notify Packagist of the new tag - env: - PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} - PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} - run: | - if [ -z "${PACKAGIST_USERNAME}" ] || [ -z "${PACKAGIST_TOKEN}" ]; then - echo "::notice title=Packagist sync skipped::PACKAGIST_USERNAME/PACKAGIST_TOKEN not set; Packagist will sync via its repo webhook." - exit 0 - fi - curl -fsSL -XPOST -H 'content-type:application/json' \ - "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_TOKEN}" \ - -d '{"repository":{"url":"https://github.com/SocketDev/socket-patch"}}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 571062dc..cc155aa2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,39 @@ name: Release +# One-workflow release: build, tag, GitHub release, and EVERY ecosystem publish +# live in this single file — deliberately. The tag and the GitHub release are +# created with this workflow's own GITHUB_TOKEN, and GitHub suppresses events +# caused by GITHUB_TOKEN: a `release: published` (or `push: tags:`) trigger in +# a second workflow file would never fire. Fanning every publish job out from +# the one dispatch via `needs:` is the only reliable topology without a +# PAT/GitHub App token, and it gives full job-graph visibility plus +# "Re-run failed jobs" retryability (every publish job is idempotent). +# +# The launcher-package jobs (rubygems, packagist, maven-central, nuget) publish +# thin launchers that download the prebuilt binary from the GitHub release at +# their own version, so they only need the GitHub release (and its SHA256SUMS) +# to exist: `needs: [version, github-release]`. +# +# Credentials / deployment-environment matrix (per-registry): +# - crates.io: OIDC trusted publishing (rust-lang/crates-io-auth-action); +# no long-lived secret, no environment. +# - npm: OIDC via `npm stage publish`; staged versions require +# manual 2FA approval (see the npm job's step summary). +# - PyPI: OIDC trusted publishing; environment `pypi`. +# - RubyGems: OIDC trusted publishing; environment `rubygems`. One +# repo+workflow publisher per gem (`socket-patch` and +# `socket-patch-bundler`), both satisfied by one exchange. +# - Packagist: PACKAGIST_USERNAME / PACKAGIST_TOKEN in environment +# `packagist` (optional — Packagist's GitHub hook syncs +# tags on its own; the API ping is a promptness nudge). +# - Maven Central: CENTRAL_USERNAME / CENTRAL_PASSWORD (portal user token) +# + CENTRAL_GPG_PRIVATE_KEY / CENTRAL_GPG_PASSPHRASE in +# environment `maven-central` (no OIDC trusted publishing +# exists for Central as of 2026-07). +# - NuGet: OIDC trusted publishing via NuGet/login with NUGET_USER, +# plus a long-lived NUGET_API_KEY fallback; environment +# `nuget`. + on: workflow_dispatch: inputs: @@ -13,6 +47,8 @@ permissions: {} jobs: version: runs-on: ubuntu-latest + permissions: + contents: read outputs: version: ${{ steps.read.outputs.VERSION }} steps: @@ -28,17 +64,29 @@ jobs: echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" echo "Release version: $VERSION" - - name: Check tag does not exist + - name: Check tag does not exist at a different commit + env: + VERSION: ${{ steps.read.outputs.VERSION }} run: | - VERSION="${{ steps.read.outputs.VERSION }}" - if git rev-parse "v${VERSION}" >/dev/null 2>&1; then - echo "::error::Tag v${VERSION} already exists. Bump the version in a PR first." + # The checkout above is shallow and tagless, so local tag lookups + # prove nothing — ask the remote directly (stateless). Fail only + # when the tag exists at a DIFFERENT commit: a tag already at + # $GITHUB_SHA means a previous run of this workflow got as far as + # tagging, and re-running it (idempotent retry) must be allowed. + EXISTING_SHA="$(git ls-remote origin "refs/tags/v${VERSION}" | cut -f1)" + if [ -z "$EXISTING_SHA" ]; then + echo "Tag v${VERSION} does not exist yet." + elif [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then + echo "::notice::Tag v${VERSION} already exists at this commit; continuing as a retry of a previous run." + else + echo "::error::Tag v${VERSION} already exists at ${EXISTING_SHA} (this run is at ${GITHUB_SHA}). Bump the version in a PR first." exit 1 fi - name: Check CHANGELOG.md has release notes for version + env: + VERSION: ${{ steps.read.outputs.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 @@ -180,12 +228,22 @@ jobs: contents: write steps: - name: Checkout + # Intentionally persists credentials: the tag push below authenticates + # with this workflow's GITHUB_TOKEN. Note that tags (and releases) + # created with GITHUB_TOKEN do NOT trigger other workflows — GitHub + # suppresses events caused by that token — which is exactly why every + # publish job lives in THIS file instead of hanging off a + # `push: tags:` or `release: published` trigger elsewhere. uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Create and push tag + env: + VERSION: ${{ needs.version.outputs.version }} run: | - TAG="v${{ needs.version.outputs.version }}" + TAG="v${VERSION}" git tag "$TAG" + # Pushing a tag that already exists at the same commit is a no-op + # success, so a re-run after a mid-release failure passes here. git push origin "$TAG" github-release: @@ -209,15 +267,26 @@ jobs: sha256sum *.tar.gz *.zip 2>/dev/null | sort > SHA256SUMS cat SHA256SUMS - - name: Create GitHub Release + - name: Create or update GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.version.outputs.version }} run: | - TAG="v${{ needs.version.outputs.version }}" - gh release create "$TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --generate-notes \ - artifacts/* + TAG="v${VERSION}" + # Idempotent for "Re-run failed jobs": if a previous attempt already + # created the release, refresh its assets instead of hard-failing. + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists; re-uploading assets with --clobber." + gh release upload "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber \ + artifacts/* + else + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --generate-notes \ + artifacts/* + fi cargo-publish: needs: [version, build, tag] @@ -238,22 +307,53 @@ jobs: # pinned channel + listed components if missing. run: rustup show + - name: Probe crates.io for already-published versions + id: published + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + # crates.io returns HTTP 200 for a published version and 404 + # otherwise; its crawler policy requires a User-Agent identifying + # the caller. Anything but a definite 200 (including transient + # network errors) falls through to publishing, where `cargo publish` + # gives the authoritative error. + probe() { + curl -fsSL -o /dev/null \ + -H 'User-Agent: socket-patch-release-workflow (https://github.com/SocketDev/socket-patch)' \ + "https://crates.io/api/v1/crates/${1}/${VERSION}" + } + if probe socket-patch-core; then + echo "socket-patch-core ${VERSION} already on crates.io; skipping its publish." + echo "core=true" >> "$GITHUB_OUTPUT" + else + echo "core=false" >> "$GITHUB_OUTPUT" + fi + if probe socket-patch-cli; then + echo "socket-patch-cli ${VERSION} already on crates.io; skipping its publish." + echo "cli=true" >> "$GITHUB_OUTPUT" + else + echo "cli=false" >> "$GITHUB_OUTPUT" + fi + - name: Authenticate with crates.io id: crates-io-auth uses: rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec # v1.0.3 - name: Publish socket-patch-core + if: steps.published.outputs.core != 'true' run: cargo publish -p socket-patch-core env: CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} - name: Wait for crates.io index update + if: steps.published.outputs.core != 'true' run: sleep 30 - name: Copy README for CLI crate run: cp README.md crates/socket-patch-cli/README.md - name: Publish socket-patch-cli + if: steps.published.outputs.cli != 'true' run: cargo publish -p socket-patch-cli env: CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} @@ -322,6 +422,8 @@ jobs: - name: Stage-publish platform packages id: stage-platform + env: + VERSION: ${{ needs.version.outputs.version }} run: | : > "${RUNNER_TEMP}/staged-packages.txt" for pkg_dir in npm/socket-patch-*/; do @@ -329,40 +431,69 @@ jobs: echo "Staging ${pkg_name}..." if npm stage publish "./${pkg_dir}" --access public; then echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" + elif npm view "${pkg_name}@${VERSION}" version >/dev/null 2>&1; then + # Already fully published (approved) — a clean re-run. + echo "Already published, skipping." + elif npm stage list 2>/dev/null | grep -qF "${pkg_name}@${VERSION}"; then + # Staged but not yet 2FA-approved: `npm view` can't see staged + # versions, so without this check a re-run after a mid-job + # failure could never succeed. Best-effort match on the stage + # listing (format unverified against a live staged state — if it + # misses we still fail loudly below). + echo "Already staged awaiting approval, skipping." + echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" else - if npm view "${pkg_name}@${{ needs.version.outputs.version }}" version >/dev/null 2>&1; then - echo "Already published, skipping." - else - exit 1 - fi + exit 1 fi done - name: Copy README for npm package run: cp README.md npm/socket-patch/README.md + - name: Install main-package devDependencies + # `npm stage publish` (like `npm pack`) runs the package's `prepack` + # script, which compiles the `./schema` export with tsc — the + # `typescript` devDependency must be installed for that build. + # + # NOT `npm ci`: version-sync.sh refreshes package-lock.json while the + # release's platform packages are not yet on the registry, so npm + # records the optionalDependencies as hollow stubs ("optional": true, + # no version/resolved/integrity) and `npm ci` refuses that lockfile + # ("lock file's ...@ does not satisfy ...@X.Y.Z"). `npm install` + # tolerates the stubs and skips unresolvable optional platform + # packages (verified for both the published and unpublished-version + # states); --no-save keeps the runner from touching package.json. + working-directory: npm/socket-patch + run: npm install --no-save --ignore-scripts --no-audit --no-fund + - name: Stage-publish main package + env: + VERSION: ${{ needs.version.outputs.version }} run: | pkg_name="@socketsecurity/socket-patch" if npm stage publish ./npm/socket-patch --access public; then echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" + elif npm view "${pkg_name}@${VERSION}" version >/dev/null 2>&1; then + echo "Already published, skipping." + elif npm stage list 2>/dev/null | grep -qF "${pkg_name}@${VERSION}"; then + # See the platform-package step: staged-not-approved versions are + # invisible to `npm view`; tolerate them so re-runs can succeed. + echo "Already staged awaiting approval, skipping." + echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" else - if npm view "${pkg_name}@${{ needs.version.outputs.version }}" version >/dev/null 2>&1; then - echo "Already published, skipping." - else - exit 1 - fi + exit 1 fi - name: Summarize staged versions awaiting approval if: always() + env: + VERSION: ${{ needs.version.outputs.version }} run: | STAGED_FILE="${RUNNER_TEMP}/staged-packages.txt" if [ ! -s "$STAGED_FILE" ]; then echo "No packages staged this run (all versions already published or publish step failed before staging)." >> "$GITHUB_STEP_SUMMARY" exit 0 fi - VERSION="${{ needs.version.outputs.version }}" { echo "## npm staged versions awaiting approval" echo "" @@ -398,6 +529,10 @@ jobs: needs: [version, build, tag] if: ${{ !inputs.dry-run }} runs-on: ubuntu-latest + # OIDC trusted publishing scoped to a deployment environment; also lets a + # maintainer gate publishing with required reviewers. Auto-created with no + # protection rules until configured. + environment: pypi permissions: contents: read id-token: write @@ -422,17 +557,22 @@ jobs: run: cp README.md pypi/socket-patch/README.md - name: Build wheels (platform socket-patch + pure-python socket-patch-hook) + env: + VERSION: ${{ needs.version.outputs.version }} run: | - VERSION="${{ needs.version.outputs.version }}" # Builds the platform-tagged socket-patch wheels AND the pure-python # socket-patch-hook wheel (the .pth carrier behind `socket-patch[hook]`). python scripts/build-pypi-wheels.py --version "$VERSION" --artifacts artifacts --dist dist # socket-patch and socket-patch-hook are two distinct PyPI projects. # Publish each from its own dir so trusted publishing mints an OIDC # token scoped to the right project (one upload spanning both projects - # can be rejected). Each project needs its own trusted publisher on - # PyPI; register a "pending" publisher for socket-patch-hook before the - # first release (repo + workflow `release.yml` + this environment). + # can be rejected). Each needs its own trusted publisher on PyPI + # (repo + workflow `release.yml`; register a "pending" publisher for + # socket-patch-hook before its first release). This job now runs in + # the `pypi` deployment environment: tighten both publishers to + # REQUIRE environment `pypi` — publishers registered without an + # environment constraint still match, so this change lands safely + # before that tightening happens. mkdir -p dist-hook mv dist/socket_patch_hook-*.whl dist-hook/ @@ -440,31 +580,32 @@ jobs: uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: dist/ + # Idempotent for "Re-run failed jobs": already-uploaded files skip. + skip-existing: true - name: Publish socket-patch-hook to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: dist-hook/ - - # Phase 2 scaffolding (CLI_CONTRACT "gem" support matrix): publish the - # `socket-patch-bundler` gem — the published form of the Bundler plugin that - # `socket-patch setup` currently wires via an in-tree `git:` reference. This - # gem is NOT yet the active mechanism (gem_setup still emits the in-tree - # plugin), so the job is **non-blocking** (`continue-on-error`) and skips the - # push when no credential is configured. A follow-up switches the generated - # Gemfile directive to `plugin "socket-patch-bundler"` and drops continue-on- - # error. Preferred hardening: RubyGems trusted publishing (OIDC), mirroring - # the crates.io / PyPI jobs above, in place of a long-lived API key. - gem-publish: - needs: [version, build, tag] + skip-existing: true + + # Publishes BOTH gems via one OIDC trusted-publishing exchange: + # - gem/socket-patch — the CLI launcher gem (downloads the + # prebuilt binary from the GitHub release at its own version, so this + # job only needs the GitHub release + SHA256SUMS to exist). + # - gem/socket-patch-bundler — Phase 2 scaffolding, non-blocking (see the + # step comment below). + rubygems-publish: + needs: [version, github-release] if: ${{ !inputs.dry-run }} runs-on: ubuntu-latest - continue-on-error: true - # Scope the publish secret to a deployment environment (zizmor - # secrets-outside-env); auto-created with no protection until configured. + # OIDC trusted publishing scoped to a deployment environment; also lets a + # maintainer gate publishing with required reviewers. Auto-created with no + # protection rules until configured. environment: rubygems permissions: contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -472,24 +613,264 @@ jobs: persist-credentials: false # Ruby is pre-installed on ubuntu-latest; no setup action needed. - - name: Build socket-patch-bundler gem + - name: Lint + version-check the launcher gem + working-directory: gem/socket-patch + env: + EXPECTED_VERSION: ${{ needs.version.outputs.version }} run: | - cd gem/socket-patch-bundler - gem build socket-patch-bundler.gemspec + ruby -c lib/socket_patch/launcher.rb + ruby -c exe/socket-patch + # The gemspec version is baked at the tag by scripts/version-sync.sh. + gemver="$(ruby -e 'print Gem::Specification.load("socket-patch.gemspec").version')" + if [ "$gemver" != "$EXPECTED_VERSION" ]; then + echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" + exit 1 + fi + + - name: Lint + version-check the bundler-plugin gem + working-directory: gem/socket-patch-bundler + env: + EXPECTED_VERSION: ${{ needs.version.outputs.version }} + run: | + ruby -c plugins.rb + # The gemspec version is baked at the tag by scripts/version-sync.sh. + gemver="$(ruby -e 'print Gem::Specification.load("socket-patch-bundler.gemspec").version')" + if [ "$gemver" != "$EXPECTED_VERSION" ]; then + echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" + exit 1 + fi + - name: Configure RubyGems credentials (OIDC trusted publishing) + # One OIDC exchange covers both gems: a trusted publisher keyed on + # this repo + workflow (+ the `rubygems` environment) can be + # registered on multiple gems on rubygems.org, and the exchanged + # token pushes any gem whose publisher matches. + uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 + + - name: Publish socket-patch to RubyGems + working-directory: gem/socket-patch + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + gem build socket-patch.gemspec + # `gem list -r -e -a` prints `socket-patch (3.3.0, 3.2.0, ...)`; match + # this version as a precise list element (preceded by `(`/space, + # followed by `,`/`)`). + if gem list --remote --exact --all socket-patch 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then + echo "socket-patch ${VERSION} already on RubyGems; skipping." + exit 0 + fi + gem push "socket-patch-${VERSION}.gem" + + # Phase 2 scaffolding (CLI_CONTRACT "gem" support matrix): publish the + # `socket-patch-bundler` gem — the published form of the Bundler plugin + # that `socket-patch setup` currently wires via an in-tree `git:` + # reference. This gem is NOT yet the active mechanism (gem_setup still + # emits the in-tree plugin), so the push is **non-blocking** + # (`continue-on-error`). A follow-up switches the generated Gemfile + # directive to `plugin "socket-patch-bundler"` and drops + # continue-on-error. - name: Publish socket-patch-bundler to RubyGems - # zizmor: ignore[use-trusted-publishing] - # Uses an API key for now; RubyGems trusted publishing (OIDC) is the - # documented future hardening (see the job comment), matching the - # crates.io / PyPI jobs. Suppressed until that publisher is registered. + continue-on-error: true + working-directory: gem/socket-patch-bundler + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + gem build socket-patch-bundler.gemspec + # Same precise-list-element match as the launcher gem above. + if gem list --remote --exact --all socket-patch-bundler 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then + echo "socket-patch-bundler ${VERSION} already on RubyGems; skipping." + exit 0 + fi + gem push "socket-patch-bundler-${VERSION}.gem" + + packagist-publish: + needs: [version, github-release] + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + # Scope the Packagist secrets to a deployment environment (zizmor + # secrets-outside-env); auto-created with no protection until configured. + environment: packagist + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # PHP + composer are pre-installed on ubuntu-latest. + - name: Lint launcher + validate composer.json + run: | + # The package manifest lives at the REPO ROOT (Packagist only + # ingests a composer.json at the VCS repository root); the launcher + # script itself stays under composer/socket-patch/. + composer validate --no-check-publish + php -l composer/socket-patch/bin/socket-patch + + # Packagist is git-tag-driven: it ingests the tagged composer.json on + # its own via the repo's GitHub hook, so tags sync anyway — this ping is + # a promptness nudge. Without credentials it is a graceful no-op. + - name: Notify Packagist of the new tag + env: + PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} + PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} + run: | + if [ -z "${PACKAGIST_USERNAME}" ] || [ -z "${PACKAGIST_TOKEN}" ]; then + echo "::notice title=Packagist sync skipped::PACKAGIST_USERNAME/PACKAGIST_TOKEN not set; Packagist will sync via its repo webhook." + exit 0 + fi + curl -fsSL -XPOST -H 'content-type:application/json' \ + "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_TOKEN}" \ + -d '{"repository":{"url":"https://github.com/SocketDev/socket-patch"}}' + + # Publishes the dependency-free launcher JAR as `dev.socket:socket-patch`. + # Like the other launcher packages it downloads the prebuilt binary from the + # GitHub release at its own version, so it only needs the GitHub release + + # SHA256SUMS to exist. + maven-central-publish: + needs: [version, github-release] + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + # Scope the Central portal token + GPG signing key to a deployment + # environment; auto-created with no protection until configured. + environment: maven-central + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Java + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: '17' + # server-id must match in pom.xml. The + # username/password values here are environment-variable NAMES — + # setup-java writes a settings.xml that resolves them from the env + # of the mvn process at deploy time, so no secret lands on disk. + server-id: central + server-username: CENTRAL_USERNAME + server-password: CENTRAL_PASSWORD + # Empty when the secret is unset; setup-java skips the GPG import + # in that case (the deploy step below also skips itself). + gpg-private-key: ${{ secrets.CENTRAL_GPG_PRIVATE_KEY }} + + - name: Lint + version-check the launcher jar + working-directory: maven/socket-patch + env: + EXPECTED_VERSION: ${{ needs.version.outputs.version }} + run: | + # The project is baked at the tag by + # scripts/version-sync.sh; the sed is anchored on the literal + # `` marker so plugin elements + # are never matched. + pomver="$(sed -n 's|.*\(.*\).*|\1|p' pom.xml)" + if [ "$pomver" != "$EXPECTED_VERSION" ]; then + echo "::error::pom.xml version $pomver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" + exit 1 + fi + mvn --batch-mode --no-transfer-progress package -Dgpg.skip=true + + - name: Publish to Maven Central + # No OIDC trusted publishing exists for Maven Central as of 2026-07 + # (OSSRH is gone; the Central Portal user token is the documented + # fallback), so this authenticates with long-lived environment + # secrets — unlike the crates.io / PyPI / RubyGems / NuGet jobs. + working-directory: maven/socket-patch + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.CENTRAL_GPG_PASSPHRASE }} + VERSION: ${{ needs.version.outputs.version }} + run: | + if [ -z "${CENTRAL_USERNAME}" ]; then + echo "::notice title=Maven Central publish skipped::CENTRAL_USERNAME not set; built the jar but did not deploy." + exit 0 + fi + # Idempotent for "Re-run failed jobs": repo1 serves the pom once + # the version is published (and Central rejects re-uploads). + if curl -sfI "https://repo1.maven.org/maven2/dev/socket/socket-patch/${VERSION}/socket-patch-${VERSION}.pom" >/dev/null; then + echo "dev.socket:socket-patch ${VERSION} already on Maven Central; skipping." + exit 0 + fi + mvn --batch-mode --no-transfer-progress deploy + + # Publishes the .NET-tool launcher as `SocketSecurity.SocketPatch`. Like the + # other launcher packages it downloads the prebuilt binary from the GitHub + # release at its own version, so it only needs the GitHub release + + # SHA256SUMS to exist. + nuget-publish: + needs: [version, github-release] + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + # OIDC trusted publishing scoped to a deployment environment; also lets a + # maintainer gate publishing with required reviewers. Auto-created with no + # protection rules until configured. + environment: nuget + permissions: + contents: read + id-token: write + env: + # The `secrets` context is unreliable in step-level `if:` expressions; + # hoist the secret to job env and gate steps on `env.NUGET_USER` + # instead (house pattern). + NUGET_USER: ${{ secrets.NUGET_USER }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 8.0.x + + - name: Version-check + pack the .NET tool + working-directory: nuget/socket-patch + env: + EXPECTED_VERSION: ${{ needs.version.outputs.version }} + run: | + # The csproj is baked at the tag by + # scripts/version-sync.sh (the csproj has exactly one + # element). + csprojver="$(sed -n 's|.*\(.*\).*|\1|p' SocketSecurity.SocketPatch.csproj | head -1)" + if [ "$csprojver" != "$EXPECTED_VERSION" ]; then + echo "::error::csproj Version $csprojver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" + exit 1 + fi + dotnet pack -c Release -o "$RUNNER_TEMP/nupkg" + + - name: NuGet OIDC login (trusted publishing) + id: nuget-login + # Exchanges this job's OIDC token for a short-lived nuget.org API key + # (trusted-publishing policy: repo `SocketDev/socket-patch` + + # workflow file `release.yml` + environment `nuget`). Skipped when + # NUGET_USER is not configured; the push step then falls back to the + # long-lived NUGET_API_KEY, or skips with a notice when neither is + # set. + if: env.NUGET_USER != '' + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push to nuget.org env: - GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + OIDC_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + FALLBACK_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | - cd gem/socket-patch-bundler - if [ -z "${GEM_HOST_API_KEY}" ]; then - echo "::notice title=gem publish skipped::RUBYGEMS_API_KEY not set; built socket-patch-bundler but did not push (Phase 2 scaffolding)." + KEY="${OIDC_API_KEY:-${FALLBACK_API_KEY}}" + if [ -z "$KEY" ]; then + echo "::notice title=NuGet publish skipped::neither NUGET_USER (trusted publishing) nor NUGET_API_KEY is set; packed the tool but did not push." exit 0 fi - # A re-push of an already-published version errors; the job is - # continue-on-error so that is non-fatal to the release. - gem push socket-patch-bundler-*.gem + # --skip-duplicate makes re-runs idempotent (a 409 for an + # already-published version is reported as a warning, not an error). + dotnet nuget push "$RUNNER_TEMP"/nupkg/*.nupkg \ + --api-key "$KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/.gitignore b/.gitignore index bd852c1c..f011beb4 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,11 @@ vite.config.ts.timestamp-* # Rust target/ +# Maven / NuGet launcher build output +maven/socket-patch/target/ +nuget/socket-patch/bin/ +nuget/socket-patch/obj/ + # npm binaries (populated at publish time) npm/socket-patch/bin/socket-patch-* diff --git a/CHANGELOG.md b/CHANGELOG.md index fdee8184..743858dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,16 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Added +- **Maven Central and NuGet distribution.** Two new install channels for the + CLI. Maven Central: `dev.socket:socket-patch`, a dependency-free launcher + jar — run via `java -jar` (fetch it with `mvn dependency:copy`) or in one + shot with JBang. NuGet: `SocketSecurity.SocketPatch`, a .NET tool — + `dotnet tool install -g SocketSecurity.SocketPatch` puts `socket-patch` + on `PATH`. Both behave like the existing gem/composer launchers: on first + run they download the version-matched prebuilt binary from the GitHub + release, verify it against the published `SHA256SUMS` (HTTPS-only, + including redirects), cache it per-user, and run it; `SOCKET_PATCH_BIN` + points them at an existing binary instead. - **`socket-patch --update` — self-update.** Downloads the release for the compiled target from GitHub Releases, verifies it against the published `SHA256SUMS` before extraction, sanity-execs the staged binary, and @@ -312,6 +322,19 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Changed +- **Release workflow consolidated into a single `release.yml`.** One + dispatch now publishes every ecosystem package — crates.io, npm, PyPI, + RubyGems (both gems, via OIDC trusted publishing), Packagist, Maven + Central, and NuGet — with the launcher-package jobs gated on the GitHub + release existing. The separate `release-ecosystems.yml` workflow is + removed (its `release: published` trigger never fired: the release is + created with `GITHUB_TOKEN`, which suppresses downstream workflow + events). `composer.json` moved to the repository root — a Packagist + requirement — with a fail-closed `.gitattributes` `export-ignore` set, + so Packagist can publish `socketsecurity/socket-patch`. Note the + `export-ignore` allowlist applies to every `git archive` consumer, so + GitHub's auto-generated "Source code" release assets now contain only + the Composer package files — clone the repo for full source. - `--api-url` / `--proxy-url` no longer carry clap-level defaults: with neither flag nor env var set they parse as unset and the documented default URLs are applied at API-client construction (after the @@ -343,6 +366,17 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Fixed +- **npm `@socketsecurity/socket-patch`: the `./schema` export is now built + at publish.** The subpath pointed at a gitignored `dist/` directory that + nothing built during release, so it shipped broken; a `prepack` script + now compiles it as part of `npm publish`. +- **Release workflow tag-guard and idempotency fixes.** The + tag-already-exists guard never fired (it ran `git rev-parse` in a + shallow, tagless checkout) — it is now a stateless `git ls-remote` check + that still permits same-commit retries; the GitHub-release step re-runs + cleanly instead of hard-failing when the release already exists; and the + cargo/PyPI/gem publish jobs skip already-published versions, so + "Re-run failed jobs" can resume a partial release safely. - **NuGet hosted rewriter: creating a `packageSourceMapping` from scratch now emits a catch-all for pre-existing sources.** `packageSourceMapping` is exclusive — once ANY mapping exists, every package must match some source's diff --git a/README.md b/README.md index 07cfa18c..a243fce6 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, and to `/usr/local/bin` or `~/.local/bin`. Use `sudo sh` instead of `sh` if `/usr/local/bin` requires root. -On Windows, install via npm (below) or grab a prebuilt `socket-patch-*-pc-windows-msvc.zip` -from the [latest release](https://github.com/SocketDev/socket-patch/releases/latest). +On Windows, install via npm or the dotnet tool (below), or grab a prebuilt +`socket-patch-*-pc-windows-msvc.zip` from the +[latest release](https://github.com/SocketDev/socket-patch/releases/latest). Or install through your package manager: @@ -42,10 +43,17 @@ Or install through your package manager: | cargo | `cargo install socket-patch-cli` (builds from source with every ecosystem compiled in) | | gem | `gem install socket-patch` | | composer | `composer require socketsecurity/socket-patch` (run as `vendor/bin/socket-patch`) | - -The gem and composer packages are thin launchers: on first run they download the prebuilt -binary for your platform from the matching GitHub release, verify its SHA-256, cache it, -and exec it. Set `SOCKET_PATCH_BIN` to an existing binary to skip the download. +| dotnet | `dotnet tool install -g SocketSecurity.SocketPatch` (puts `socket-patch` on your `PATH`) | +| Maven | `mvn dependency:copy -Dartifact=dev.socket:socket-patch: -DoutputDirectory=.`, then `java -jar socket-patch-.jar` (see the Maven note below) | + +The gem, composer, Maven, and NuGet packages are thin launchers: on first run they +download the prebuilt binary for your platform from the matching GitHub release, verify +its SHA-256, cache it, and exec it. Set `SOCKET_PATCH_BIN` to an existing binary to skip +the download. The Maven artifact (`dev.socket:socket-patch`) is a dependency-free +launcher jar — there is no "latest" shorthand on Maven Central, so pin a +[released version](https://github.com/SocketDev/socket-patch/releases); besides the +`mvn dependency:copy` + `java -jar` recipe above, [JBang](https://www.jbang.dev) users +can run it in one shot: `jbang dev.socket:socket-patch: scan`.
Manual download diff --git a/composer/socket-patch/composer.json b/composer.json similarity index 91% rename from composer/socket-patch/composer.json rename to composer.json index 48df6abe..13de6988 100644 --- a/composer/socket-patch/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": ">=7.4" }, - "bin": ["bin/socket-patch"], + "bin": ["composer/socket-patch/bin/socket-patch"], "support": { "issues": "https://github.com/SocketDev/socket-patch/issues", "source": "https://github.com/SocketDev/socket-patch" diff --git a/composer/socket-patch/README.md b/composer/socket-patch/README.md index ce3c6682..4e5fecdf 100644 --- a/composer/socket-patch/README.md +++ b/composer/socket-patch/README.md @@ -19,6 +19,14 @@ the cached binary. So `composer require socketsecurity/socket-patch:3.2.0` downloads the `v3.2.0` binary — the binary version always tracks the installed package version. +Note: the package manifest (`composer.json`) lives at the **repository root**, +not in this directory — Packagist only publishes manifests found at the root of +the VCS repository (and `composer.json` cannot carry comments explaining that +itself). The launcher script stays here; the root manifest points its `bin` at +`composer/socket-patch/bin/socket-patch`, and the root `.gitattributes` +export-ignore allowlist keeps the rest of the repository out of the Packagist +dist zip. + ## Airgapped / offline use The launcher downloads on first run. For offline CI, point it at an diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index cf0df2ef..b2e8a324 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -748,7 +748,7 @@ These exist for staged rollouts and the launcher wrappers. They are **internal** |---|---| | `SOCKET_EXPERIMENTAL_MAVEN` | Opt-in gate (`=1`) for the maven installed-package crawl behind `scan`/`apply`/`vendor` — agent-mode in-place jar patching corrupts the `~/.m2` checksum sidecars, so discovery stays off by default (`src/ecosystem_dispatch.rs`). | | `SOCKET_EXPERIMENTAL_NUGET` | Same gate for nuget — in-place patching breaks the `.nupkg.sha512` tamper-evidence sidecar. | -| `SOCKET_PATCH_BIN` | Points the RubyGems / Composer launcher wrappers and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run); also the escape hatch `apply` names when a golang-featureless binary is asked to audit Go redirects. | +| `SOCKET_PATCH_BIN` | Points the CLI launcher wrappers (RubyGems / Composer / Maven / NuGet) and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run); also the escape hatch `apply` names when a golang-featureless binary is asked to audit Go redirects. | | `SOCKET_UPDATE_BASE_URL` | Points BOTH the release-metadata and asset-download routes of `--update`/the update notice at one base (mirror or test fixture) instead of `github.com` + `api.github.com`. Overriding it relaxes the downloaded binary's version self-check from hard-fail to warning. | | `SOCKET_UPDATE_STATE_DIR` | Overrides the per-user dir holding `update-check.json` + `update.lock` (tests point it into a tempdir). | | `SOCKET_UPDATE_TIMEOUT_MS` | Caps the update fetches' connect/metadata/download budgets (defaults 10 s / 30 s / 300 s; the notice's fetch defaults to 2 s). Doubles as the slow-network escape hatch. | @@ -1037,7 +1037,7 @@ When verification is enabled (the default) and a patch is omitted, the failed PU ## Semver policy -Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to npm, pypi, and cargo wrappers by **`scripts/version-sync.sh `**. +Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to every ecosystem wrapper and launcher package by **`scripts/version-sync.sh `** (the full list of stamped files is below). | Change | Bump | |---|---| @@ -1075,10 +1075,17 @@ This syncs the workspace package version into: - `gem/socket-patch-bundler/socket-patch-bundler.gemspec` (the Bundler plugin gem) - `gem/socket-patch/socket-patch.gemspec` + its launcher `VERSION` (the RubyGems CLI launcher) - the Composer CLI launcher's `SP_VERSION` (`composer/socket-patch/bin/socket-patch`) - -The RubyGems + Composer CLI launchers (`socket-patch` gem, `socketsecurity/socket-patch` -on Packagist) are published by the separate **`.github/workflows/release-ecosystems.yml`**, -which runs after the main release publishes and only needs the GitHub release binaries to exist. +- `maven/socket-patch/pom.xml` (``) + the Java launcher's fallback `VERSION` + (`maven/socket-patch/src/main/java/dev/socket/socketpatch/Launcher.java`) +- `nuget/socket-patch/SocketSecurity.SocketPatch.csproj` (``) + the .NET + launcher's fallback version constant (`nuget/socket-patch/Program.cs`) + +All ecosystem publishing lives in the single **`.github/workflows/release.yml`** workflow: +one dispatch publishes crates.io, npm, and PyPI plus the CLI launcher packages +(`socket-patch` on RubyGems, `socketsecurity/socket-patch` on Packagist, +`dev.socket:socket-patch` on Maven Central, `SocketSecurity.SocketPatch` on NuGet). +The launcher-package jobs are gated on the GitHub release — with its binaries and +`SHA256SUMS` — existing. ## How the contract is enforced diff --git a/maven/socket-patch/README.md b/maven/socket-patch/README.md new file mode 100644 index 00000000..aaefe73d --- /dev/null +++ b/maven/socket-patch/README.md @@ -0,0 +1,55 @@ +# socket-patch (Maven Central) + +Distributes the [`socket-patch`](https://github.com/SocketDev/socket-patch) CLI +through Maven Central (`dev.socket:socket-patch`) so it can be run in +Java / JVM environments: + +```sh +mvn dependency:copy -Dartifact=dev.socket:socket-patch:3.3.0 -DoutputDirectory=. +java -jar socket-patch-3.3.0.jar apply +``` + +With [jbang](https://www.jbang.dev/): + +```sh +jbang dev.socket:socket-patch:3.3.0 --help +``` + +Or fetch the jar directly (it has no dependencies): + +```sh +curl -fsSLO https://repo1.maven.org/maven2/dev/socket/socket-patch/3.3.0/socket-patch-3.3.0.jar +java -jar socket-patch-3.3.0.jar --help +``` + +(Replace `3.3.0` with the release you want.) + +This is a thin **launcher** jar. On first run it downloads the prebuilt binary +for your platform from the GitHub release **matching the jar's own version** +(read from the jar manifest's `Implementation-Version`, so resolving +`dev.socket:socket-patch:3.2.0` fetches the `v3.2.0` binary), verifies it +against the release's `SHA256SUMS`, caches it under your user cache +(`~/.cache/socket-patch/bin/` or `%LOCALAPPDATA%\socket-patch\bin\` on Windows), +and runs it. Subsequent runs use the cached binary. + +Behind an egress proxy, the launcher honors the `https_proxy` / `HTTPS_PROXY` / +`all_proxy` / `ALL_PROXY` environment variables (like the other socket-patch +launchers); explicit JVM proxy properties (`-Dhttps.proxyHost=...`) take +precedence when set. + +## Airgapped / offline use + +The launcher downloads on first run, so for offline CI either pre-warm the cache +or point it at an already-installed binary: + +```sh +export SOCKET_PATCH_BIN=/usr/local/bin/socket-patch +``` + +When `SOCKET_PATCH_BIN` is set to an executable, the launcher skips the download +entirely and runs it. (The npm and PyPI distributions bundle the binary instead +of downloading.) + +## License + +MIT diff --git a/maven/socket-patch/pom.xml b/maven/socket-patch/pom.xml new file mode 100644 index 00000000..b5e587d4 --- /dev/null +++ b/maven/socket-patch/pom.xml @@ -0,0 +1,155 @@ + + + + 4.0.0 + + dev.socket + socket-patch + + 3.3.0 + jar + + socket-patch + CLI tool for applying security patches to dependencies. Launcher that downloads the prebuilt socket-patch binary for the host platform. + https://github.com/SocketDev/socket-patch + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + Socket Security + Socket + https://socket.dev + + + + + scm:git:https://github.com/SocketDev/socket-patch.git + scm:git:git@github.com:SocketDev/socket-patch.git + https://github.com/SocketDev/socket-patch + + + + UTF-8 + + 11 + + 2026-01-01T00:00:00Z + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + dev.socket.socketpatch.Launcher + + true + + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + + attach-javadocs + + jar + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.7 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + central + true + + + + + diff --git a/maven/socket-patch/src/main/java/dev/socket/socketpatch/Launcher.java b/maven/socket-patch/src/main/java/dev/socket/socketpatch/Launcher.java new file mode 100644 index 00000000..2616e514 --- /dev/null +++ b/maven/socket-patch/src/main/java/dev/socket/socketpatch/Launcher.java @@ -0,0 +1,488 @@ +package dev.socket.socketpatch; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Resolves and runs the prebuilt {@code socket-patch} binary for the host + * platform (Maven Central distribution of the socket-patch CLI). + * + *

Strategy (mirrors scripts/install.sh's target mapping and the RubyGems / + * Composer launchers): + *

    + *
  1. honor {@code SOCKET_PATCH_BIN} if it points at an executable (airgap + * escape);
  2. + *
  3. else use a cached binary under the per-user cache, keyed by + * version + target;
  4. + *
  5. else download {@code socket-patch-.{tar.gz,zip}} from the + * matching GitHub release, verify its SHA-256 against the release's + * SHA256SUMS, extract the binary, cache it, and run it.
  6. + *
+ */ +public final class Launcher { + /** + * Fallback version, used ONLY when the jar manifest's + * Implementation-Version is unavailable (e.g. running unpacked classes + * straight from a checkout). Kept current by scripts/version-sync.sh. + * In a real Maven-resolved jar the download uses the jar's own stamped + * version — see {@link #version()}. + */ + private static final String VERSION = "3.3.0"; + + private static final String REPO = "SocketDev/socket-patch"; + private static final String BINARY = "socket-patch"; + + /** + * Plain release versions look like 3.3.0 — anchored full match, because a + * suffixed version (3.3.1-SNAPSHOT, prereleases) has no matching GitHub + * release binary and must fall back to {@link #VERSION} instead of + * guaranteeing a 404. + */ + private static final Pattern RELEASE_VERSION = Pattern.compile("^\\d+\\.\\d+\\.\\d+$"); + + /** + * Follow redirects (GitHub release downloads redirect to a CDN), but only + * to HTTPS targets: {@code Redirect.NORMAL} is documented to always + * redirect "except from HTTPS URLs to HTTP URLs", so every hop is + * JDK-vetted to stay on HTTPS. That matters because a redirect to + * http:// would let a network attacker serve a malicious binary AND a + * matching SHA256SUMS (both attacker-controlled), defeating the checksum + * check. The initial URL is separately asserted HTTPS in + * {@link #httpsRequest(String)}. + */ + private static final HttpClient HTTP = buildHttpClient(); + + private static HttpClient buildHttpClient() { + HttpClient.Builder builder = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL); + ProxySelector proxy = envProxySelector(); + if (proxy != null) { + builder.proxy(proxy); + } + return builder.build(); + } + + /** + * The JVM's default proxy selector only honors {@code -Dhttps.proxyHost} + * -style system properties, never the {@code https_proxy}/{@code + * HTTPS_PROXY} environment variables that every sibling launcher (curl in + * install.sh, Ruby's Net::HTTP, PHP's libcurl, .NET's HttpClient) picks up + * — so behind an env-configured egress proxy the first-run download would + * fail only for the Maven distribution. Honor the env vars here; explicit + * JVM proxy properties still take precedence (returning null keeps the + * default selector, which reads them). + */ + private static ProxySelector envProxySelector() { + if (System.getProperty("https.proxyHost") != null + || System.getProperty("http.proxyHost") != null) { + return null; + } + for (String name : new String[] {"https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"}) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + continue; + } + URI uri = URI.create(value.contains("://") ? value : "http://" + value); + if (uri.getHost() == null) { + continue; + } + int port = uri.getPort() != -1 + ? uri.getPort() + : ("https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80); + return ProxySelector.of(new InetSocketAddress(uri.getHost(), port)); + } + return null; + } + + private Launcher() { + } + + /** + * Entry point: resolves the platform binary, runs it with the given + * arguments (inheriting stdio), and exits with the child's exit code. + * + * @param args CLI arguments passed through to the socket-patch binary + */ + public static void main(String[] args) { + try { + String bin = resolveBinary(); + // Java has no exec() that replaces the process; spawn with + // inherited stdio and propagate the child's exit status. + List cmd = new ArrayList<>(); + cmd.add(bin); + cmd.addAll(Arrays.asList(args)); + Process child = new ProcessBuilder(cmd).inheritIO().start(); + System.exit(child.waitFor()); + } catch (LauncherError e) { + System.err.println("socket-patch: " + e.getMessage()); + System.exit(1); + } catch (IOException e) { + System.err.println("socket-patch: " + e.getMessage()); + System.exit(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println("socket-patch: interrupted"); + System.exit(1); + } + } + + /** Launcher-level failure with a user-facing message. */ + private static final class LauncherError extends RuntimeException { + private static final long serialVersionUID = 1L; + + LauncherError(String message) { + super(message); + } + } + + // ── binary resolution ──────────────────────────────────────────────────── + + private static String resolveBinary() { + String env = System.getenv("SOCKET_PATCH_BIN"); + if (env != null && !env.isEmpty()) { + Path p = Paths.get(env); + if (Files.isRegularFile(p) && Files.isExecutable(p)) { + return env; + } + } + + String ver = version(); + String[] targetExt = detectTarget(); + String target = targetExt[0]; + String ext = targetExt[1]; + String exe = BINARY + (isWindows() ? ".exe" : ""); + Path cached = cacheDir().resolve(ver).resolve(target).resolve(exe); + // Cache hit: the cached binary was SHA-256-verified when first + // downloaded and lives under the user's own cache dir. We trust it + // without re-verifying (re-verification would require re-fetching + // SHA256SUMS every run), matching npx / pip / rustup; an attacker able + // to write here can already replace the installed jar or the binary + // itself. + if (Files.isRegularFile(cached) && Files.isExecutable(cached)) { + return cached.toString(); + } + + downloadBinary(ver, target, ext, cached); + return cached.toString(); + } + + /** + * The version to fetch — the binary MUST match the artifact the user + * actually resolved, so derive it from the jar's own Implementation-Version + * manifest attribute (stamped from {@code project.version} by + * maven-jar-plugin) rather than trusting the {@code VERSION} constant + * (which version-sync.sh keeps current but which could drift). Falls back + * to the constant when the manifest isn't available (e.g. running unpacked + * classes from a checkout) or reports a non-release version with no + * matching release binary. + */ + private static String version() { + Package pkg = Launcher.class.getPackage(); + String v = pkg == null ? null : pkg.getImplementationVersion(); + if (v != null && RELEASE_VERSION.matcher(v).matches()) { + return v; + } + return VERSION; + } + + /** + * Map the host to a release target triple + archive extension. Mirrors + * scripts/install.sh. + */ + private static String[] detectTarget() { + String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String osArch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + + String arch; + if (osArch.matches(".*(x86_64|x64|amd64).*")) { + arch = "x86_64"; + } else if (osArch.matches(".*(aarch64|arm64).*")) { + arch = "aarch64"; + } else if (osArch.matches(".*(i[3-6]86|x86).*")) { + arch = "i686"; + } else if (osArch.matches(".*(armv7|armhf|arm\\b).*")) { + arch = "arm"; + } else { + throw new LauncherError("unsupported CPU architecture: " + osArch); + } + + // Check macOS before Windows: "darwin" contains "win". + if (osName.contains("mac") || osName.contains("darwin")) { + if (!arch.equals("x86_64") && !arch.equals("aarch64")) { + throw new LauncherError("unsupported macOS arch: " + arch); + } + return new String[] {arch + "-apple-darwin", "tar.gz"}; + } + if (osName.startsWith("windows")) { + if (arch.equals("x86_64") || arch.equals("aarch64") || arch.equals("i686")) { + return new String[] {arch + "-pc-windows-msvc", "zip"}; + } + throw new LauncherError("unsupported Windows arch: " + arch); + } + if (osName.contains("linux")) { + String libc = isMusl() ? "musl" : "gnu"; + String suffix = arch.equals("arm") ? "eabihf" : ""; + return new String[] {arch + "-unknown-linux-" + libc + suffix, "tar.gz"}; + } + throw new LauncherError("unsupported OS: " + osName); + } + + private static boolean isMusl() { + try (DirectoryStream ds = + Files.newDirectoryStream(Paths.get("/lib"), "ld-musl-*.so.1")) { + return ds.iterator().hasNext(); + } catch (IOException e) { + return false; + } + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("windows"); + } + + private static Path cacheDir() { + String base; + if (isWindows()) { + String localAppData = System.getenv("LOCALAPPDATA"); + base = (localAppData != null && !localAppData.isEmpty()) + ? localAppData + : Paths.get(System.getProperty("user.home"), "AppData", "Local").toString(); + } else { + String xdg = System.getenv("XDG_CACHE_HOME"); + base = (xdg != null && !xdg.isEmpty()) + ? xdg + : Paths.get(System.getProperty("user.home"), ".cache").toString(); + } + return Paths.get(base, "socket-patch", "bin"); + } + + // ── download + verify + extract ────────────────────────────────────────── + + private static void downloadBinary(String ver, String target, String ext, Path dest) { + String archive = BINARY + "-" + target + "." + ext; + String base = "https://github.com/" + REPO + "/releases/download/v" + ver; + + Path tmp; + try { + tmp = Files.createTempDirectory("socket-patch"); + } catch (IOException e) { + throw new LauncherError("could not create temp dir: " + e.getMessage()); + } + try { + Path archivePath = tmp.resolve(archive); + fetch(base + "/" + archive, archivePath); + + String sums = fetchString(base + "/SHA256SUMS"); + verifySha256(archivePath, archive, sums); + + extract(archivePath, ext, tmp); + String exe = BINARY + (ext.equals("zip") ? ".exe" : ""); + Path extracted = tmp.resolve(exe); + if (!Files.isRegularFile(extracted)) { + throw new LauncherError("release archive " + archive + " did not contain " + exe); + } + + try { + Files.createDirectories(dest.getParent()); + Files.copy(extracted, dest, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new LauncherError("could not cache binary at " + dest + ": " + e.getMessage()); + } + if (!isWindows()) { + dest.toFile().setExecutable(true, false); + } + } finally { + deleteRecursively(tmp); // best-effort temp cleanup (Ruby's mktmpdir block equivalent) + } + } + + /** + * Require HTTPS for every request — including after a redirect. The + * shared client's {@code Redirect.NORMAL} policy never follows an + * HTTPS-to-HTTP redirect (see {@link #HTTP}); this asserts the INITIAL + * URL is HTTPS too, so no request ever leaves over plain HTTP. A + * non-HTTPS URL anywhere would let a network attacker serve a malicious + * binary AND a matching SHA256SUMS (both attacker-controlled), defeating + * the checksum check. + */ + private static HttpRequest httpsRequest(String url) { + if (!url.startsWith("https://")) { + throw new LauncherError("refusing non-HTTPS URL: " + url); + } + return HttpRequest.newBuilder(URI.create(url)).GET().build(); + } + + private static void fetch(String url, Path dest) { + HttpResponse res; + try { + res = HTTP.send(httpsRequest(url), HttpResponse.BodyHandlers.ofFile(dest)); + } catch (IOException e) { + throw new LauncherError("download failed for " + url + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LauncherError("download interrupted for " + url); + } + if (res.statusCode() / 100 != 2) { + throw new LauncherError("download failed (" + res.statusCode() + ") for " + url); + } + } + + private static String fetchString(String url) { + HttpResponse res; + try { + res = HTTP.send(httpsRequest(url), HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new LauncherError("download failed for " + url + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LauncherError("download interrupted for " + url); + } + if (res.statusCode() / 100 != 2) { + throw new LauncherError("download failed (" + res.statusCode() + ") for " + url); + } + return res.body(); + } + + /** + * SHA256SUMS lines are {@code " "} (some tools prefix the + * name with {@code *} for binary mode); match either. + */ + private static void verifySha256(Path path, String archive, String sums) { + String expected = null; + for (String line : sums.split("\\r?\\n")) { + String[] parts = line.trim().split("\\s+", 2); + if (parts.length < 2) { + continue; + } + String name = parts[1].trim(); + if (name.startsWith("*")) { + name = name.substring(1); + } + if (name.equals(archive)) { + expected = parts[0]; + break; + } + } + if (expected == null) { + throw new LauncherError("no SHA256SUMS entry for " + archive); + } + String actual = sha256Hex(path); + if (!actual.equalsIgnoreCase(expected)) { + throw new LauncherError( + "checksum mismatch for " + archive + " (expected " + expected + ", got " + actual + ")"); + } + } + + private static String sha256Hex(Path file) { + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new LauncherError("SHA-256 unavailable: " + e.getMessage()); + } + try (InputStream in = Files.newInputStream(file)) { + byte[] buf = new byte[65536]; + int n; + while ((n = in.read(buf)) != -1) { + md.update(buf, 0, n); + } + } catch (IOException e) { + throw new LauncherError("could not read " + file + ": " + e.getMessage()); + } + StringBuilder sb = new StringBuilder(); + for (byte b : md.digest()) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static void extract(Path archivePath, String ext, Path dir) { + if (ext.equals("zip")) { + extractZip(archivePath, dir); + return; + } + // Shell out to tar for tar.gz — the same choice as the Ruby and PHP + // launchers: the JDK has no built-in tar support, tar ships on every + // supported non-Windows platform, and the archive's checksum was + // already verified against SHA256SUMS before extraction. + try { + Process p = new ProcessBuilder("tar", "xzf", archivePath.toString(), "-C", dir.toString()) + .inheritIO() + .start(); + if (p.waitFor() != 0) { + throw new LauncherError("failed to extract " + archivePath.getFileName()); + } + } catch (IOException e) { + throw new LauncherError( + "failed to extract " + archivePath.getFileName() + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LauncherError("failed to extract " + archivePath.getFileName() + ": interrupted"); + } + } + + /** Zip extraction (Windows archives) with a zip-slip guard on every entry. */ + private static void extractZip(Path archivePath, Path dir) { + Path root = dir.toAbsolutePath().normalize(); + try (ZipInputStream zin = new ZipInputStream(Files.newInputStream(archivePath))) { + ZipEntry entry; + while ((entry = zin.getNextEntry()) != null) { + // Zip-slip guard: resolve + normalize each entry path and + // refuse anything that escapes the extraction directory. + Path out = root.resolve(entry.getName()).normalize(); + if (!out.startsWith(root)) { + throw new LauncherError( + "refusing zip entry escaping extraction dir: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(out); + } else { + if (out.getParent() != null) { + Files.createDirectories(out.getParent()); + } + Files.copy(zin, out, StandardCopyOption.REPLACE_EXISTING); + } + zin.closeEntry(); + } + } catch (IOException e) { + throw new LauncherError( + "failed to extract " + archivePath.getFileName() + ": " + e.getMessage()); + } + } + + private static void deleteRecursively(Path root) { + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // best effort + } + }); + } catch (IOException ignored) { + // best effort + } + } +} diff --git a/npm/socket-patch/package-lock.json b/npm/socket-patch/package-lock.json index 520cfc41..d8077e78 100644 --- a/npm/socket-patch/package-lock.json +++ b/npm/socket-patch/package-lock.json @@ -39,46 +39,186 @@ } }, "node_modules/@socketsecurity/socket-patch-android-arm64": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-android-arm64/-/socket-patch-android-arm64-3.3.0.tgz", + "integrity": "sha512-lLIMZtkmN0iXP00bO9oia0NIfvi0E9OZsVtPWh3BYwDgFfUcSxm2swfW0jWUoeFiPi/eEy+spvSqQkghPjhMhw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, "node_modules/@socketsecurity/socket-patch-darwin-arm64": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-darwin-arm64/-/socket-patch-darwin-arm64-3.3.0.tgz", + "integrity": "sha512-vmkJUa4i/o+CQcXBg7L2nfjj71cdCUsdDlgYexYiDh5U8xwOFGNgdAfKFoIj8loBd7l5ZBmdtxH8WuVfB85rIg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, "node_modules/@socketsecurity/socket-patch-darwin-x64": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-darwin-x64/-/socket-patch-darwin-x64-3.3.0.tgz", + "integrity": "sha512-ycEDhOD2f8cpTVROKKPcZ95iAKO2lRpBYaOnDgvHWY+/gyz8TbkUWk0tGk9/JygMdpxBfsoehp47WJgAiyYr0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, "node_modules/@socketsecurity/socket-patch-linux-arm-gnu": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-arm-gnu/-/socket-patch-linux-arm-gnu-3.3.0.tgz", + "integrity": "sha512-zFQASt8I4x/A+TAndLeL3X0llKIbg4mCIpcLBuaTKZP47mvXbumH+lJHTvx+i0ieRC5sm7x/i4BXNGE2gWjU8A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-linux-arm-musl": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-arm-musl/-/socket-patch-linux-arm-musl-3.3.0.tgz", + "integrity": "sha512-6Qnh0hM8QqqjIdF6IrmgrVxVm12kosWuZL9OW0/QWUy/cYRUr7vfX7Y86rpT8Qy18ljHm0VWgSAd20bePNcYxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-linux-arm64-gnu": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-arm64-gnu/-/socket-patch-linux-arm64-gnu-3.3.0.tgz", + "integrity": "sha512-7dfLWAlVg+/R/2UBEbTKxg7XvpA6NEzMAVxuXHrOLQLNOTCVN29b9oN7v5HPAPDVY4xQQLhF+RT57feM9llW8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-linux-arm64-musl": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-arm64-musl/-/socket-patch-linux-arm64-musl-3.3.0.tgz", + "integrity": "sha512-Y8lb86qNsSSVY7BCbCTHrx0kf3nsf0CuF9ZHNplcjGWQ/UY7/k9cUssG4Q/nwzYkszEHXo7T3DeitPyRsf9MJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-linux-ia32-gnu": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-ia32-gnu/-/socket-patch-linux-ia32-gnu-3.3.0.tgz", + "integrity": "sha512-30iGdXP5HcFV7czBRFOEHDYrqrY66m65WWE9gazXr6EFxolJK+O+Y0r5btxyL1kKGekdw59Kb+/6FrdwhLHRTQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-linux-ia32-musl": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-ia32-musl/-/socket-patch-linux-ia32-musl-3.3.0.tgz", + "integrity": "sha512-8N/ekSSumoJEkhNrG+d2iIYkhF0mMyEyd4DLdkEztVDNX4qBek/R4l/jhP+RpmXEppfalfnC08xa0aNnylWa3w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-linux-x64-gnu": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-x64-gnu/-/socket-patch-linux-x64-gnu-3.3.0.tgz", + "integrity": "sha512-tM8L6jyjRFQipCL80QaOrqQVctba7NqKlkdcAUEwp99h2kbGHCaFM0DF2Uw00zgdgT9aOL1N8X11VOFWbYE76w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-linux-x64-musl": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-linux-x64-musl/-/socket-patch-linux-x64-musl-3.3.0.tgz", + "integrity": "sha512-rkcrcwS3APMKzn90gHtsZqCz7lAwBi1uTJ0N7mipfcqycIO4VZ7rpcNDY+4A8WPvegY00oMOpyybBzcMMzQWYQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@socketsecurity/socket-patch-win32-arm64": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-win32-arm64/-/socket-patch-win32-arm64-3.3.0.tgz", + "integrity": "sha512-iFCn/IPWFLJJ40XTl1RaoSLFwfDvNYFsqmU3NU1UboOOIl9MWAX8Wz6Q+NVPurtKcc+/iN5+A7rsseJCVLpvFQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@socketsecurity/socket-patch-win32-ia32": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-win32-ia32/-/socket-patch-win32-ia32-3.3.0.tgz", + "integrity": "sha512-v4DflqE+ioWj8DUZQz9VZQ9qK2/EuKHdKNUaPWBDEb/KC+Rgw3KsjcjOdIu2eSVHxvKLlVxxkSN79jMSzaizvw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@socketsecurity/socket-patch-win32-x64": { - "optional": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@socketsecurity/socket-patch-win32-x64/-/socket-patch-win32-x64-3.3.0.tgz", + "integrity": "sha512-nY3k/XAmsD3YJsuTDVJjVQqEyJszO0LGwcNZH0RV0dQHWFinuPUvBGmpn29IVURT9rRVSs9WQmSXlYXPs1Mxpw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@types/node": { "version": "20.19.41", diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json index 37702a35..5fd29baa 100644 --- a/npm/socket-patch/package.json +++ b/npm/socket-patch/package.json @@ -17,6 +17,7 @@ }, "scripts": { "build": "tsc", + "prepack": "tsc", "test": "pnpm run build && node --test dist/**/*.test.js" }, "keywords": [ diff --git a/nuget/socket-patch/Program.cs b/nuget/socket-patch/Program.cs new file mode 100644 index 00000000..50e6b5ff --- /dev/null +++ b/nuget/socket-patch/Program.cs @@ -0,0 +1,480 @@ +// socket-patch CLI launcher (NuGet / .NET tool distribution). +// +// `dotnet tool install -g SocketSecurity.SocketPatch` puts `socket-patch` on +// PATH (or install repo-locally with a tool manifest and run it via +// `dotnet tool run socket-patch`). This resolves and runs the prebuilt +// `socket-patch` binary for the host platform. +// +// Strategy (mirrors scripts/install.sh's target mapping and the RubyGems / +// Composer / Maven launchers — see gem/socket-patch, composer/socket-patch, +// and maven/socket-patch): +// 1. honor SOCKET_PATCH_BIN if it points at an executable (airgap escape); +// 2. else use a cached binary under the per-user cache, keyed by +// version + target; +// 3. else download `socket-patch-.{tar.gz,zip}` from the matching +// GitHub release, verify its SHA-256 against the release's SHA256SUMS, +// extract the binary, cache it, and run it. +// +// MIT License — Copyright (c) Socket Security. + +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace SocketSecurity.SocketPatch; + +internal static class Program +{ + // Fallback version, used ONLY when the assembly's informational version + // can't be read or isn't a plain release version (e.g. a local dev build). + // In a real `dotnet tool install` the download uses the installed + // package's own version — see ResolveVersion(). Kept in sync by + // scripts/version-sync.sh. + private const string FallbackVersion = "3.3.0"; + private const string Repo = "SocketDev/socket-patch"; + private const string Binary = "socket-patch"; + private const int MaxRedirects = 10; + + private static int Main(string[] args) + { + try + { + var bin = ResolveBinary(); + // UseShellExecute = false: run the binary directly (no shell + // interpretation of the path or arguments) and inherit + // stdin/stdout/stderr from this process. + var psi = new ProcessStartInfo { FileName = bin, UseShellExecute = false }; + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + + Process child; + try + { + child = Process.Start(psi) ?? throw new LauncherException($"failed to run {bin}"); + } + catch (Exception e) when (e is not LauncherException) + { + throw new LauncherException($"failed to run {bin}: {e.Message}"); + } + + using (child) + { + child.WaitForExit(); + return child.ExitCode; + } + } + catch (LauncherException e) + { + Console.Error.WriteLine($"socket-patch: {e.Message}"); + return 1; + } + } + + private sealed class LauncherException : Exception + { + public LauncherException(string message) : base(message) { } + } + + // ── binary resolution ──────────────────────────────────────────────────── + + private static string ResolveBinary() + { + var env = Env("SOCKET_PATCH_BIN"); + if (env is not null && IsExecutableFile(env)) + { + return env; + } + + var ver = ResolveVersion(); + var (target, ext) = DetectTarget(); + var exe = Binary + (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""); + var cached = Path.Combine(CacheDir(), ver, target, exe); + // Cache hit: the cached binary was SHA-256-verified when first + // downloaded and lives under the user's own cache dir. We trust it + // without re-verifying (re-verification would require re-fetching + // SHA256SUMS every run), matching npx / pip / rustup; an attacker able + // to write here can already replace the installed tool or the binary + // itself. A cache entry that lost its exec bit fails the check and is + // re-downloaded (self-heal), like the Ruby/PHP/Java launchers. + if (IsExecutableFile(cached)) + { + return cached; + } + + DownloadBinary(ver, target, ext, cached, exe); + return cached; + } + + // Executability gate for the trust decisions above: on Windows existence + // suffices (no exec bit); elsewhere require the user-execute bit so a + // non-executable SOCKET_PATCH_BIN falls through to the normal path and a + // mode-stripped cache entry gets re-downloaded, matching File.executable? + // / is_executable / Files.isExecutable in the sibling launchers. + private static bool IsExecutableFile(string path) + { + if (!File.Exists(path)) + { + return false; + } + if (OperatingSystem.IsWindows()) + { + return true; + } + try + { + return (File.GetUnixFileMode(path) & UnixFileMode.UserExecute) != 0; + } + catch + { + return false; + } + } + + // The version to fetch — the binary MUST match the tool package the user + // actually installed, so derive it from the assembly's informational + // version (the csproj pins AssemblyInformationalVersion to the package + // version and disables the "+" suffix) rather than trusting the + // FallbackVersion constant (which version-sync.sh keeps current but which + // could drift). Falls back to the constant when the attribute is missing + // or isn't a plain major.minor.patch release (e.g. a dev/prerelease build + // with no matching release binary). + private static string ResolveVersion() + { + var info = Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion; + if (info is not null) + { + // Strip SemVer build metadata ("3.3.0+abc123" → "3.3.0") in case a + // build appends it despite the csproj setting. + var plus = info.IndexOf('+'); + if (plus >= 0) + { + info = info[..plus]; + } + if (Regex.IsMatch(info, @"^\d+\.\d+\.\d+$")) + { + return info; + } + } + return FallbackVersion; + } + + // Map the host to a release target triple + archive extension. Mirrors + // scripts/install.sh. + private static (string Target, string Ext) DetectTarget() + { + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => "x86_64", + Architecture.Arm64 => "aarch64", + Architecture.X86 => "i686", + Architecture.Arm => "arm", + var other => throw new LauncherException($"unsupported CPU architecture: {other}"), + }; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + if (arch is not ("x86_64" or "aarch64")) + { + throw new LauncherException($"unsupported macOS arch: {arch}"); + } + return ($"{arch}-apple-darwin", "tar.gz"); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var target = arch switch + { + "x86_64" => "x86_64-pc-windows-msvc", + "aarch64" => "aarch64-pc-windows-msvc", + "i686" => "i686-pc-windows-msvc", + _ => throw new LauncherException($"unsupported Windows arch: {arch}"), + }; + return (target, "zip"); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + var libc = IsMusl() ? "musl" : "gnu"; + var suffix = arch == "arm" ? "eabihf" : ""; + return ($"{arch}-unknown-linux-{libc}{suffix}", "tar.gz"); + } + throw new LauncherException($"unsupported OS: {RuntimeInformation.OSDescription}"); + } + + // Alpine-style .NET builds carry "musl" in the RID; runtimes that don't + // are caught by the musl loader at /lib/ld-musl-*.so.1 (the same probe as + // the gem/composer launchers and scripts/install.sh). + private static bool IsMusl() + { + if (RuntimeInformation.RuntimeIdentifier.Contains("musl", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + try + { + return Directory.GetFiles("/lib", "ld-musl-*.so.1").Length > 0; + } + catch + { + return false; // /lib missing or unreadable — assume glibc + } + } + + private static string CacheDir() + { + string baseDir; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + baseDir = Env("LOCALAPPDATA") + ?? Path.Combine(Env("USERPROFILE") ?? HomeDir(), "AppData", "Local"); + } + else + { + baseDir = Env("XDG_CACHE_HOME") + ?? Path.Combine(Env("HOME") ?? HomeDir(), ".cache"); + } + return Path.Combine(baseDir, "socket-patch", "bin"); + } + + /// Environment variable value, with empty treated as unset. + private static string? Env(string name) + { + var value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrEmpty(value) ? null : value; + } + + private static string HomeDir() => + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify); + + // ── download + verify + extract ────────────────────────────────────────── + + private static void DownloadBinary(string ver, string target, string ext, string dest, string exe) + { + var archive = $"{Binary}-{target}.{ext}"; + var baseUrl = $"https://github.com/{Repo}/releases/download/v{ver}"; + + string tmp; + try + { + tmp = Directory.CreateTempSubdirectory("socket-patch-").FullName; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + throw new LauncherException($"could not create temp dir: {e.Message}"); + } + try + { + // AllowAutoRedirect = false: redirects are followed MANUALLY in + // Fetch() so every hop's URL can be vetted as HTTPS (see HttpsUri). + using var handler = new SocketsHttpHandler { AllowAutoRedirect = false }; + using var client = new HttpClient(handler); + client.DefaultRequestHeaders.UserAgent.ParseAdd("socket-patch-dotnet"); + + var archivePath = Path.Combine(tmp, archive); + FetchToFile(client, $"{baseUrl}/{archive}", archivePath); + + var sums = FetchString(client, $"{baseUrl}/SHA256SUMS"); + VerifySha256(archivePath, archive, sums); + + Extract(archivePath, ext, tmp); + var extracted = Path.Combine(tmp, exe); + if (!File.Exists(extracted)) + { + throw new LauncherException($"release archive {archive} did not contain {exe}"); + } + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.Copy(extracted, dest, overwrite: true); + if (!OperatingSystem.IsWindows()) + { + // 0755 — user rwx, group/other rx. + File.SetUnixFileMode(dest, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + throw new LauncherException($"could not cache binary at {dest}: {e.Message}"); + } + } + finally + { + try + { + Directory.Delete(tmp, recursive: true); + } + catch + { + // best-effort cleanup + } + } + } + + // Require HTTPS for every request — including after a redirect. GitHub + // release downloads redirect to a CDN (still HTTPS); a redirect to http:// + // would let a network attacker serve a malicious binary AND a matching + // SHA256SUMS (both attacker-controlled), defeating the checksum check. So + // a non-HTTPS URL — initial or redirect target — is refused. (.NET's + // auto-redirect would itself refuse an https→http downgrade, but we + // disable it and vet each hop explicitly to match the gem/composer + // launchers.) + private static Uri HttpsUri(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + { + throw new LauncherException($"refusing non-HTTPS URL: {url}"); + } + return uri; + } + + // Follow redirects manually (GitHub release downloads redirect to a CDN), + // vetting every hop as HTTPS. Relative redirect targets are resolved + // against the current URL; the result must still be HTTPS (see HttpsUri). + // The caller owns (and must dispose) the returned response. + private static HttpResponseMessage Fetch(HttpClient client, string url) + { + var uri = HttpsUri(url); + for (var hop = 0; ; hop++) + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + HttpResponseMessage response; + try + { + response = client.Send(request, HttpCompletionOption.ResponseHeadersRead); + } + catch (HttpRequestException e) + { + throw new LauncherException($"download failed for {uri}: {e.Message}"); + } + + var status = (int)response.StatusCode; + if (status is >= 300 and < 400 && response.Headers.Location is not null) + { + var location = response.Headers.Location; + response.Dispose(); + if (hop >= MaxRedirects) + { + throw new LauncherException($"too many redirects fetching {url}"); + } + uri = HttpsUri(new Uri(uri, location).ToString()); + continue; + } + if (!response.IsSuccessStatusCode) + { + response.Dispose(); + throw new LauncherException($"download failed ({status}) for {uri}"); + } + return response; + } + } + + private static void FetchToFile(HttpClient client, string url, string dest) + { + using var response = Fetch(client, url); + // The headers-only Send above means the body transfers here — wrap it + // so a dropped connection mid-download (a routine first-run failure) + // reports "socket-patch: ..." + exit 1 instead of an unhandled + // exception, matching the sibling launchers. + try + { + using var body = response.Content.ReadAsStream(); + using var file = File.Create(dest); + body.CopyTo(file); + } + catch (Exception e) when (e is IOException or HttpRequestException) + { + throw new LauncherException($"download failed for {url}: {e.Message}"); + } + } + + private static string FetchString(HttpClient client, string url) + { + using var response = Fetch(client, url); + try + { + using var body = response.Content.ReadAsStream(); + using var reader = new StreamReader(body); + return reader.ReadToEnd(); + } + catch (Exception e) when (e is IOException or HttpRequestException) + { + throw new LauncherException($"download failed for {url}: {e.Message}"); + } + } + + // SHA256SUMS lines are " " (some tools prefix the name + // with `*` for binary mode); match either. + private static void VerifySha256(string path, string archive, string sums) + { + string? expected = null; + foreach (var rawLine in sums.Split('\n')) + { + var parts = rawLine.Trim().Split((char[]?)null, 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + continue; + } + var name = parts[1].Trim().TrimStart('*'); + if (name == archive) + { + expected = parts[0].Trim(); + break; + } + } + if (expected is null) + { + throw new LauncherException($"no SHA256SUMS entry for {archive}"); + } + + string actual; + try + { + using var stream = File.OpenRead(path); + actual = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + throw new LauncherException($"could not read {path}: {e.Message}"); + } + if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherException( + $"checksum mismatch for {archive} (expected {expected}, got {actual})"); + } + } + + // Extract without shelling out (unlike the gem/composer launchers, the BCL + // has native zip + tar.gz support). Both ZipFile and TarFile refuse entry + // paths that would escape the destination directory. + private static void Extract(string archivePath, string ext, string dir) + { + try + { + if (ext == "zip") + { + ZipFile.ExtractToDirectory(archivePath, dir); + } + else + { + using var file = File.OpenRead(archivePath); + using var gunzip = new GZipStream(file, CompressionMode.Decompress); + TarFile.ExtractToDirectory(gunzip, dir, overwriteFiles: false); + } + } + catch (Exception e) when (e is not LauncherException) + { + throw new LauncherException( + $"failed to extract {Path.GetFileName(archivePath)}: {e.Message}"); + } + } +} diff --git a/nuget/socket-patch/README.md b/nuget/socket-patch/README.md new file mode 100644 index 00000000..bce325b3 --- /dev/null +++ b/nuget/socket-patch/README.md @@ -0,0 +1,43 @@ +# socket-patch (NuGet) + +Distributes the [`socket-patch`](https://github.com/SocketDev/socket-patch) CLI +through NuGet as a .NET tool so it can be installed in .NET environments: + +```sh +dotnet tool install -g SocketSecurity.SocketPatch +socket-patch --help +``` + +Or pin it per-repository with a tool manifest (restored by +`dotnet tool restore`): + +```sh +dotnet new tool-manifest # once per repo +dotnet tool install SocketSecurity.SocketPatch +dotnet tool run socket-patch -- --help +``` + +This is a thin **launcher** package. On first run it downloads the prebuilt +binary for your platform from the GitHub release **matching the installed +package's own version** (so `dotnet tool install -g SocketSecurity.SocketPatch +--version 3.2.0` fetches the `v3.2.0` binary), verifies it against the +release's `SHA256SUMS`, caches it under your user cache +(`~/.cache/socket-patch/bin/` or `%LOCALAPPDATA%\socket-patch\bin\` on +Windows), and runs it. Subsequent runs use the cached binary. + +## Airgapped / offline use + +The launcher downloads on first run, so for offline CI either pre-warm the +cache or point it at an already-installed binary: + +```sh +export SOCKET_PATCH_BIN=/usr/local/bin/socket-patch +``` + +When `SOCKET_PATCH_BIN` is set to an existing executable, the launcher skips +the download entirely and runs it. (The npm and PyPI distributions bundle the +binary instead of downloading.) + +## License + +MIT diff --git a/nuget/socket-patch/SocketSecurity.SocketPatch.csproj b/nuget/socket-patch/SocketSecurity.SocketPatch.csproj new file mode 100644 index 00000000..799145e3 --- /dev/null +++ b/nuget/socket-patch/SocketSecurity.SocketPatch.csproj @@ -0,0 +1,47 @@ + + + + + Exe + net8.0 + + Major + enable + enable + + true + socket-patch + SocketSecurity.SocketPatch + + 3.3.0 + + false + + Socket Security + CLI tool for applying security patches to dependencies. Launcher that downloads the prebuilt socket-patch binary for the host platform. + MIT + https://github.com/SocketDev/socket-patch + https://github.com/SocketDev/socket-patch + git + security;patch;cli;dependencies + README.md + + + + + + + diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 5fbd8208..148ecad6 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -1,4 +1,14 @@ #!/usr/bin/env bash +# Stamps the release version into every packaging artifact that carries one: +# - Cargo.toml (workspace version + socket-patch-core exact pin) +# - npm/socket-patch/package.json (+ optionalDependencies, package-lock.json) +# - npm/socket-patch-*/package.json (per-platform packages) +# - pypi/socket-patch/pyproject.toml + pypi/socket-patch-hook/pyproject.toml +# - gem/socket-patch-bundler/socket-patch-bundler.gemspec +# - gem/socket-patch/socket-patch.gemspec + lib/socket_patch/launcher.rb +# - composer/socket-patch/bin/socket-patch (SP_VERSION constant) +# - maven/socket-patch/pom.xml + src/main/java/dev/socket/socketpatch/Launcher.java +# - nuget/socket-patch/SocketSecurity.SocketPatch.csproj + Program.cs set -euo pipefail VERSION="${1:?Usage: version-sync.sh }" @@ -92,4 +102,40 @@ if [ -f "$composer_cli_bin" ]; then rm -f "$composer_cli_bin.bak" fi +# Update the Maven Central launcher pom. The sed is anchored on the literal +# x-version-sync marker comment so ONLY the project's own line is +# rewritten — the pom carries other elements for build plugins. +maven_pom="$REPO_ROOT/maven/socket-patch/pom.xml" +if [ -f "$maven_pom" ]; then + sed -i.bak "s|.*|$VERSION|" "$maven_pom" + rm -f "$maven_pom.bak" +fi + +# Update the Maven launcher's fallback VERSION constant (the launcher prefers +# the jar manifest's Implementation-Version; this constant covers running from +# exploded classes, where no manifest is available). +maven_launcher="$REPO_ROOT/maven/socket-patch/src/main/java/dev/socket/socketpatch/Launcher.java" +if [ -f "$maven_launcher" ]; then + sed -i.bak "s/VERSION = \".*\"/VERSION = \"$VERSION\"/" "$maven_launcher" + rm -f "$maven_launcher.bak" +fi + +# Update the NuGet .NET-tool launcher package version. The csproj has exactly +# one element; the informational version derives from it, so a +# single stamp keeps package and assembly versions equal. +nuget_csproj="$REPO_ROOT/nuget/socket-patch/SocketSecurity.SocketPatch.csproj" +if [ -f "$nuget_csproj" ]; then + sed -i.bak "s|.*|$VERSION|" "$nuget_csproj" + rm -f "$nuget_csproj.bak" +fi + +# Update the NuGet launcher's fallback version constant (the launcher prefers +# the assembly's informational version; this constant covers builds where the +# attribute is unavailable). +nuget_program="$REPO_ROOT/nuget/socket-patch/Program.cs" +if [ -f "$nuget_program" ]; then + sed -i.bak "s/FallbackVersion = \".*\"/FallbackVersion = \"$VERSION\"/" "$nuget_program" + rm -f "$nuget_program.bak" +fi + echo "Synced version to $VERSION"