From ff465e98d7039b60bff02bfbba9049a66506bfdd Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Fri, 11 Sep 2026 15:13:28 -0400 Subject: [PATCH 1/6] feat: add Bun lockfile scrub action Restore Socket Firewall Bun tarball URL fields to Bun native empty resolution fields. Spec: docs/ideation/bun-lockfile-socket-firewall-scrub/spec-phase-1.md --- .github/workflows/ci.yml | 1 + README.md | 17 +++++ lockfile-scrub/action.yml | 22 ++++++ release-manifest.txt | 2 + scripts/build-release.sh | 1 + scripts/build-release.test.sh | 9 ++- scripts/scrub-lockfile.sh | 56 ++++++++++++++ scripts/scrub-lockfile.test.sh | 135 +++++++++++++++++++++++++++++++++ 8 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 lockfile-scrub/action.yml create mode 100755 scripts/scrub-lockfile.sh create mode 100755 scripts/scrub-lockfile.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f27b108..338d8d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: bash -n scripts/*.sh bash scripts/configure.test.sh bash scripts/teardown.test.sh + bash scripts/scrub-lockfile.test.sh bash scripts/build-release.test.sh bash scripts/publish-release.test.sh diff --git a/README.md b/README.md index 4aca5d6..08a3a01 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This repository exposes two action entrypoints from the same action-only release - `/` — configure protected dependency downloads. - `/teardown` — remove only SFW-owned configuration before an npm/pnpm/Yarn/Bun publish in the same job. +- `/lockfile-scrub` — detect or normalize Socket Firewall tarball URLs in a root Bun lockfile. It does not route package publication or Python, Java, Go, Ruby, Rust, .NET, private-registry, or other dependency ecosystems through the WorkOS SFW instance. @@ -79,6 +80,22 @@ The action independently requires a `pull_request` event from a different reposi Do not use this action in an install-bearing `pull_request_target` job. Such workflows can combine base-repository secrets with contributor-controlled checkout, lockfiles, scripts, local/reusable actions, or artifacts. Apply the same review to `workflow_run`, `issue_comment`, `workflow_dispatch`, reusable workflows with inherited secrets, and artifact handoffs whenever they select an untrusted ref or input. Redesign that trust boundary before enabling SFW. +### Bun lockfile cleanup + +Bun records an absolute tarball URL when it installs through Socket Firewall. Its native portable representation uses an empty resolved-URL field, so a committed `bun.lock` should not retain `https://socket-firewall.workos.dev/...` URLs. + +`/lockfile-scrub` operates only on the caller workspace's root `bun.lock`. It takes no token and never runs package code. `check` is the default: it leaves the file unchanged and exposes `changed=true` when a cleanup is required. `apply` replaces only quoted Socket Firewall tarball URLs with Bun's native empty URL field. + +```yaml +- name: Check Bun lockfile for Socket Firewall URLs + id: scrub + uses: workos/setup-socket-firewall/lockfile-scrub@ # v1 + with: + mode: check +``` + +A same-repository pull-request workflow may run `apply`, verify that no SFW URL remains, then commit only `bun.lock` with its scoped `GITHUB_TOKEN`. Keep that Git write in the caller workflow—not in this action—and never use `pull_request_target` or write to an external fork. GitHub does not trigger another Actions run for a `GITHUB_TOKEN` push, so validate the transformed working tree before committing. + ### Package publication Socket Firewall is a dependency-download control, not a package publication registry. Prefer a clean publish job that never configures SFW. diff --git a/lockfile-scrub/action.yml b/lockfile-scrub/action.yml new file mode 100644 index 0000000..9b6dc96 --- /dev/null +++ b/lockfile-scrub/action.yml @@ -0,0 +1,22 @@ +name: Scrub Socket Firewall Bun lockfile URLs +description: >- + Detect or remove Socket Firewall tarball URLs from the root Bun lockfile, + restoring Bun's portable empty resolved-URL fields. +inputs: + mode: + description: Check for URLs without changing bun.lock, or apply the normalization. + required: false + default: check +outputs: + changed: + description: Whether bun.lock contains or contained a Socket Firewall tarball URL. + value: ${{ steps.scrub.outputs.changed }} +runs: + using: composite + steps: + - name: Scrub Bun lockfile + id: scrub + shell: bash + env: + SFW_SCRUB_MODE: ${{ inputs.mode }} + run: bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh" diff --git a/release-manifest.txt b/release-manifest.txt index 5cebab8..47af90f 100644 --- a/release-manifest.txt +++ b/release-manifest.txt @@ -2,4 +2,6 @@ LICENSE action.yml scripts/configure.sh scripts/teardown.sh +scripts/scrub-lockfile.sh teardown/action.yml +lockfile-scrub/action.yml diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 7f80590..bc1ad20 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -105,6 +105,7 @@ done <"$expected" diff -u "$expected" "$actual" bash -n "${staging}/scripts/configure.sh" bash -n "${staging}/scripts/teardown.sh" +bash -n "${staging}/scripts/scrub-lockfile.sh" if [[ "$OUTPUT_WAS_EXISTING" == 'true' ]]; then [[ ! -L "$OUTPUT" && -d "$OUTPUT" && -z "$(find "$OUTPUT" -mindepth 1 -print -quit)" ]] || { diff --git a/scripts/build-release.test.sh b/scripts/build-release.test.sh index 6822d34..8d5800b 100755 --- a/scripts/build-release.test.sh +++ b/scripts/build-release.test.sh @@ -20,10 +20,11 @@ expect_build_failure() { prepare_source_copy() { local destination="$1" - mkdir -p "$destination/scripts" "$destination/teardown" + mkdir -p "$destination/scripts" "$destination/teardown" "$destination/lockfile-scrub" cp "$ROOT/action.yml" "$ROOT/release-manifest.txt" "$destination/" cp "$ROOT/teardown/action.yml" "$destination/teardown/" - cp "$ROOT/scripts/build-release.sh" "$ROOT/scripts/configure.sh" "$ROOT/scripts/teardown.sh" "$destination/scripts/" + cp "$ROOT/lockfile-scrub/action.yml" "$destination/lockfile-scrub/" + cp "$ROOT/scripts/build-release.sh" "$ROOT/scripts/configure.sh" "$ROOT/scripts/teardown.sh" "$ROOT/scripts/scrub-lockfile.sh" "$destination/scripts/" } "${ROOT}/scripts/build-release.sh" "${CASE_DIR}/release" >/dev/null @@ -83,14 +84,16 @@ grep -Ev '^[[:space:]]*(#|$)' "${ROOT}/release-manifest.txt" | LC_ALL=C sort >"$ ) >"$actual" diff -u "$expected" "$actual" -for forbidden in README.md package.json package-lock.json reports tools .github scripts/configure.test.sh scripts/teardown.test.sh scripts/build-release.sh scripts/build-release.test.sh scripts/publish-release.sh scripts/publish-release.test.sh; do +for forbidden in README.md package.json package-lock.json reports tools .github scripts/configure.test.sh scripts/teardown.test.sh scripts/scrub-lockfile.test.sh scripts/build-release.sh scripts/build-release.test.sh scripts/publish-release.sh scripts/publish-release.test.sh; do [[ ! -e "${CASE_DIR}/release/${forbidden}" ]] || fail "forbidden release path present: ${forbidden}" done [[ -x "${CASE_DIR}/release/scripts/configure.sh" ]] || fail 'configure.sh lost executable mode' [[ -x "${CASE_DIR}/release/scripts/teardown.sh" ]] || fail 'teardown.sh lost executable mode' +[[ -x "${CASE_DIR}/release/scripts/scrub-lockfile.sh" ]] || fail 'scrub-lockfile.sh lost executable mode' grep -Fq 'bash "$GITHUB_ACTION_PATH/scripts/configure.sh"' "${CASE_DIR}/release/action.yml" || fail 'root action does not invoke its shipped configure script' grep -Fq 'bash "$GITHUB_ACTION_PATH/../scripts/teardown.sh"' "${CASE_DIR}/release/teardown/action.yml" || fail 'teardown action does not invoke its shipped teardown script' +grep -Fq 'bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh"' "${CASE_DIR}/release/lockfile-scrub/action.yml" || fail 'lockfile scrub action does not invoke its shipped script' printf 'release tree tests passed\n' diff --git a/scripts/scrub-lockfile.sh b/scripts/scrub-lockfile.sh new file mode 100755 index 0000000..aeb772f --- /dev/null +++ b/scripts/scrub-lockfile.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly SFW_HOST='https://socket-firewall.workos.dev/' + +fail() { + printf '::error::Socket Firewall Bun lockfile scrub failed: %s\n' "$1" >&2 + exit 1 +} + +file_mode() { + if stat -c '%a' "$1" >/dev/null 2>&1; then + stat -c '%a' "$1" + else + stat -f '%Lp' "$1" + fi +} + +mode="${SFW_SCRUB_MODE:-}" +case "$mode" in + check | apply) ;; + *) fail 'mode must be check or apply' ;; +esac + +workspace="${GITHUB_WORKSPACE:-}" +[[ -n "$workspace" && -d "$workspace" && ! -L "$workspace" ]] || fail 'GITHUB_WORKSPACE must name a real directory' +[[ -n "${GITHUB_OUTPUT:-}" ]] || fail 'GITHUB_OUTPUT is required' + +lockfile="${workspace}/bun.lock" +[[ -f "$lockfile" && ! -L "$lockfile" ]] || fail 'root bun.lock must be a regular file' + +if ! grep -Fq "$SFW_HOST" "$lockfile"; then + printf 'changed=false\n' >>"$GITHUB_OUTPUT" + exit 0 +fi + +printf 'changed=true\n' >>"$GITHUB_OUTPUT" +if [[ "$mode" == 'check' ]]; then + exit 0 +fi + +temporary="$(mktemp "${lockfile}.workos-sfw.XXXXXX")" +cleanup() { + rm -f -- "$temporary" +} +trap cleanup EXIT + +sed 's#"https://socket-firewall\.workos\.dev/[^"]*"#""#g' "$lockfile" >"$temporary" +chmod "$(file_mode "$lockfile")" "$temporary" +mv -f -- "$temporary" "$lockfile" + +if grep -Fq "$SFW_HOST" "$lockfile"; then + fail 'Socket Firewall URL remains after apply mode' +fi + +trap - EXIT diff --git a/scripts/scrub-lockfile.test.sh b/scripts/scrub-lockfile.test.sh new file mode 100755 index 0000000..2aaaf99 --- /dev/null +++ b/scripts/scrub-lockfile.test.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${ROOT}/scripts/scrub-lockfile.sh" +ACTION="${ROOT}/lockfile-scrub/action.yml" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + grep -Fq "$2" "$1" || fail "${1} does not contain: ${2}" +} + +assert_not_contains() { + if grep -Fq "$2" "$1" 2>/dev/null; then + fail "${1} unexpectedly contains: ${2}" + fi +} + +new_case() { + CASE_DIR="$(mktemp -d)" + export GITHUB_WORKSPACE="${CASE_DIR}/workspace" + export GITHUB_OUTPUT="${CASE_DIR}/github-output" + mkdir -p "$GITHUB_WORKSPACE" + : >"$GITHUB_OUTPUT" +} + +cleanup_case() { + rm -rf "$CASE_DIR" +} + +write_lockfile() { + cat >"${GITHUB_WORKSPACE}/bun.lock" <<'JSON' +{ + "lockfileVersion": 1, + "packages": { + "left-pad": ["left-pad@1.3.0", "https://socket-firewall.workos.dev/left-pad/-/left-pad-1.3.0.tgz", {}, "sha512-left-pad"], + "plain": ["plain@1.0.0", "", {}, "sha512-plain"], + "unrelated": ["unrelated@1.0.0", "https://example.test/pkg.tgz", {}, "sha512-unrelated"] + } +} +JSON +} + +test_clean_lockfile_reports_unchanged() { + new_case + printf '{"lockfileVersion":1,"packages":{}}\n' >"${GITHUB_WORKSPACE}/bun.lock" + cp "${GITHUB_WORKSPACE}/bun.lock" "${CASE_DIR}/before" + SFW_SCRUB_MODE=check bash "$SCRIPT" + assert_contains "$GITHUB_OUTPUT" 'changed=false' + cmp "${CASE_DIR}/before" "${GITHUB_WORKSPACE}/bun.lock" || fail 'clean lockfile changed' + cleanup_case +} + +test_check_mode_does_not_modify_lockfile() { + new_case + write_lockfile + cp "${GITHUB_WORKSPACE}/bun.lock" "${CASE_DIR}/before" + SFW_SCRUB_MODE=check bash "$SCRIPT" + assert_contains "$GITHUB_OUTPUT" 'changed=true' + cmp "${CASE_DIR}/before" "${GITHUB_WORKSPACE}/bun.lock" || fail 'check mode changed lockfile' + cleanup_case +} + +test_apply_mode_restores_native_bun_fields() { + new_case + write_lockfile + chmod 640 "${GITHUB_WORKSPACE}/bun.lock" + SFW_SCRUB_MODE=apply bash "$SCRIPT" + assert_contains "$GITHUB_OUTPUT" 'changed=true' + assert_not_contains "${GITHUB_WORKSPACE}/bun.lock" 'https://socket-firewall.workos.dev/' + assert_contains "${GITHUB_WORKSPACE}/bun.lock" '"left-pad": ["left-pad@1.3.0", "", {}, "sha512-left-pad"]' + assert_contains "${GITHUB_WORKSPACE}/bun.lock" '"unrelated": ["unrelated@1.0.0", "https://example.test/pkg.tgz", {}, "sha512-unrelated"]' + mode="$(stat -c '%a' "${GITHUB_WORKSPACE}/bun.lock" 2>/dev/null || stat -f '%Lp' "${GITHUB_WORKSPACE}/bun.lock")" + [[ "$mode" == 640 ]] || fail "apply mode changed file mode to ${mode}" + cleanup_case +} + +test_apply_mode_rewrites_multiple_sfw_urls() { + new_case + write_lockfile + cat >>"${GITHUB_WORKSPACE}/bun.lock" <<'JSON' +"second": ["second@1.0.0", "https://socket-firewall.workos.dev/second/-/second-1.0.0.tgz", {}, "sha512-second"] +JSON + SFW_SCRUB_MODE=apply bash "$SCRIPT" + assert_not_contains "${GITHUB_WORKSPACE}/bun.lock" 'https://socket-firewall.workos.dev/' + assert_contains "${GITHUB_WORKSPACE}/bun.lock" '"second": ["second@1.0.0", "", {}, "sha512-second"]' + cleanup_case +} + +test_missing_or_symlink_lockfile_is_rejected() { + new_case + set +e + SFW_SCRUB_MODE=check bash "$SCRIPT" >/dev/null 2>&1 + status=$? + set -e + [[ "$status" -ne 0 ]] || fail 'missing lockfile succeeded' + printf '{}\n' >"${CASE_DIR}/real-lock" + ln -s "${CASE_DIR}/real-lock" "${GITHUB_WORKSPACE}/bun.lock" + set +e + SFW_SCRUB_MODE=check bash "$SCRIPT" >/dev/null 2>&1 + status=$? + set -e + [[ "$status" -ne 0 ]] || fail 'symlink lockfile succeeded' + cleanup_case +} + +test_invalid_mode_is_rejected() { + new_case + write_lockfile + set +e + SFW_SCRUB_MODE=replace bash "$SCRIPT" >/dev/null 2>&1 + status=$? + set -e + [[ "$status" -ne 0 ]] || fail 'invalid mode succeeded' + cleanup_case +} + +test_action_wires_changed_output_and_script() { + assert_contains "$ACTION" 'value: ${{ steps.scrub.outputs.changed }}' + assert_contains "$ACTION" 'run: bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh"' +} + +test_clean_lockfile_reports_unchanged +test_check_mode_does_not_modify_lockfile +test_apply_mode_restores_native_bun_fields +test_apply_mode_rewrites_multiple_sfw_urls +test_missing_or_symlink_lockfile_is_rejected +test_invalid_mode_is_rejected +test_action_wires_changed_output_and_script + +printf 'scrub lockfile tests passed\n' From 93735c95e1d583f9a3566d36b689091937aa8875 Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Fri, 11 Sep 2026 15:42:03 -0400 Subject: [PATCH 2/6] feat: support npm lockfiles in lockfile scrub action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit workspace-relative lockfile selection, preserve Bun defaults, and normalize npm resolved URL fields without reformatting. Cover npm lockfile versions 1–3 and shrinkwrap with tests and CI install smokes. Spec: docs/ideation/bun-lockfile-socket-firewall-scrub/spec-phase-1.md --- .github/workflows/ci.yml | 92 ++++++++++++++++++ CONTRIBUTING.md | 5 +- README.md | 35 +++++-- lockfile-scrub/action.yml | 19 ++-- release-manifest.txt | 1 + scripts/build-release.sh | 1 + scripts/build-release.test.sh | 23 ++++- scripts/scrub-lockfile.sh | 63 ++++++++---- scripts/scrub-lockfile.test.sh | 36 +++++++ scripts/scrub-npm-lockfile.mjs | 41 ++++++++ scripts/scrub-npm-lockfile.test.mjs | 144 ++++++++++++++++++++++++++++ 11 files changed, 425 insertions(+), 35 deletions(-) create mode 100644 scripts/scrub-npm-lockfile.mjs create mode 100644 scripts/scrub-npm-lockfile.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 338d8d5..5cc3b00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 - name: Check shell style and test coverage policy run: | set -euo pipefail @@ -31,9 +34,83 @@ jobs: bash scripts/configure.test.sh bash scripts/teardown.test.sh bash scripts/scrub-lockfile.test.sh + node --test scripts/scrub-npm-lockfile.test.mjs bash scripts/build-release.test.sh bash scripts/publish-release.test.sh + scrub-npm-smoke: + name: Npm scrub (${{ matrix.lockfile }}, v${{ matrix.version }}) + runs-on: ubuntu-22.04 + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + lockfile: [package-lock.json, npm-shrinkwrap.json] + version: [1, 2, 3] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - name: Prepare tainted npm fixture + env: + LOCKFILE: ${{ matrix.lockfile }} + VERSION: ${{ matrix.version }} + run: | + node --input-type=module <<'JS' + import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs'; + const fixture = JSON.parse(readFileSync('.github/fixtures/npm/package-lock.json', 'utf8')); + const dependency = fixture.packages['node_modules/left-pad']; + dependency.resolved = dependency.resolved.replace('registry.yarnpkg.com', 'socket-firewall.workos.dev'); + fixture.lockfileVersion = Number(process.env.VERSION); + if (fixture.lockfileVersion < 3) fixture.dependencies = { 'left-pad': { ...dependency } }; + if (fixture.lockfileVersion === 1) delete fixture.packages; + mkdirSync('scrub-smoke'); + copyFileSync('.github/fixtures/npm/package.json', 'scrub-smoke/package.json'); + const text = JSON.stringify(fixture, null, 2) + '\n'; + writeFileSync(`scrub-smoke/${process.env.LOCKFILE}`, text); + writeFileSync('scrub-smoke/before', text); + writeFileSync('scrub-smoke/expected', text.replaceAll('socket-firewall.workos.dev', 'registry.npmjs.org')); + JS + - uses: ./lockfile-scrub + id: check + with: + lockfile: scrub-smoke/${{ matrix.lockfile }} + - name: Check mode preserves bytes + env: + CHANGED: ${{ steps.check.outputs.changed }} + LOCKFILE: ${{ matrix.lockfile }} + run: | + test "$CHANGED" = true + cmp scrub-smoke/before "scrub-smoke/$LOCKFILE" + - uses: ./lockfile-scrub + id: apply + with: + lockfile: scrub-smoke/${{ matrix.lockfile }} + mode: apply + - name: Install from normalized lockfile without credentials + working-directory: scrub-smoke + env: + CHANGED: ${{ steps.apply.outputs.changed }} + LOCKFILE: ${{ matrix.lockfile }} + run: | + set -euo pipefail + test "$CHANGED" = true + cmp expected "$LOCKFILE" + npm ci --ignore-scripts --no-audit --no-fund --registry=https://registry.npmjs.org/ --cache="$RUNNER_TEMP/scrub-npm-cache" + node -e "require('left-pad')" + cmp expected "$LOCKFILE" + - uses: ./lockfile-scrub + id: recheck + with: + lockfile: scrub-smoke/${{ matrix.lockfile }} + - name: Assert idempotence + env: + CHANGED: ${{ steps.recheck.outputs.changed }} + run: test "$CHANGED" = false + check-secret: name: Check secret availability runs-on: ubuntu-22.04 @@ -169,6 +246,21 @@ jobs: npm ci --ignore-scripts --no-audit --no-fund node -e "require('left-pad'); console.log('non-npmjs lockfile host rewritten through SFW')" + - name: Install a scrubbed npm lockfile through Socket Firewall + if: matrix.manager == 'npm' + run: | + set -euo pipefail + work="$RUNNER_TEMP/scrub-npm-protected" + mkdir "$work" + cp .github/fixtures/npm/package.json "$work/" + sed 's#https://registry.yarnpkg.com/#https://socket-firewall.workos.dev/#g' .github/fixtures/npm/package-lock.json > "$work/package-lock.json" + GITHUB_WORKSPACE="$work" SFW_SCRUB_LOCKFILE=package-lock.json SFW_SCRUB_MODE=apply bash scripts/scrub-lockfile.sh + cp "$work/package-lock.json" "$work/expected" + cd "$work" + npm ci --ignore-scripts --no-audit --no-fund --cache="$RUNNER_TEMP/scrub-protected-cache" + node -e "require('left-pad')" + cmp expected package-lock.json + - name: Restore public registry before publication id: teardown uses: ./teardown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b24203..0ec5117 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ Thanks for helping improve the WorkOS Socket Firewall GitHub Action. ## Development requirements - Bash on Linux or macOS +- Node.js 22 or later, for npm lockfile transformation, tests, and release validation - Go, for the pinned `shfmt` check - ShellCheck - Passwordless `sudo` and a disposable Linux runner for integration testing that modifies `/etc/hosts` @@ -21,11 +22,13 @@ go run mvdan.cc/sh/v3/cmd/shfmt@v3.14.0 -d -i 2 -ci scripts/*.sh bash -n scripts/*.sh bash scripts/configure.test.sh bash scripts/teardown.test.sh +bash scripts/scrub-lockfile.test.sh +node --test scripts/scrub-npm-lockfile.test.mjs bash scripts/build-release.test.sh bash scripts/publish-release.test.sh ``` -CI runs the same static and unit checks on every pull request. Token-backed GitHub-hosted smoke jobs additionally exercise every supported package manager. +CI runs the same static and unit checks on every pull request. The npm scrub smoke matrix runs the composite action and `npm ci --ignore-scripts` against both npm filenames and lockfile versions 1–3 without credentials. Token-backed GitHub-hosted smoke jobs additionally exercise every supported package manager and a scrubbed npm lockfile with public registry DNS blocked. The minimum test-coverage policy is one shell test suite for every executable shell source file. Changes to supported package-manager behavior must also include a token-backed frozen-lockfile smoke test. diff --git a/README.md b/README.md index 08a3a01..7a6cc56 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ Composite GitHub Actions for routing **public npm-compatible JavaScript/TypeScript dependency downloads** through the WorkOS Socket Firewall and restoring public-registry access before package publication. -This repository exposes two action entrypoints from the same action-only release commit: +This repository exposes three action entrypoints from the same action-only release commit: - `/` — configure protected dependency downloads. - `/teardown` — remove only SFW-owned configuration before an npm/pnpm/Yarn/Bun publish in the same job. -- `/lockfile-scrub` — detect or normalize Socket Firewall tarball URLs in a root Bun lockfile. +- `/lockfile-scrub` — detect or normalize Socket Firewall registry URLs in Bun and npm lockfiles. It does not route package publication or Python, Java, Go, Ruby, Rust, .NET, private-registry, or other dependency ecosystems through the WorkOS SFW instance. @@ -80,21 +80,42 @@ The action independently requires a `pull_request` event from a different reposi Do not use this action in an install-bearing `pull_request_target` job. Such workflows can combine base-repository secrets with contributor-controlled checkout, lockfiles, scripts, local/reusable actions, or artifacts. Apply the same review to `workflow_run`, `issue_comment`, `workflow_dispatch`, reusable workflows with inherited secrets, and artifact handoffs whenever they select an untrusted ref or input. Redesign that trust boundary before enabling SFW. -### Bun lockfile cleanup +### Bun and npm lockfile cleanup -Bun records an absolute tarball URL when it installs through Socket Firewall. Its native portable representation uses an empty resolved-URL field, so a committed `bun.lock` should not retain `https://socket-firewall.workos.dev/...` URLs. +Registry URLs saved in lockfiles can bind future installations to Socket Firewall even when the installer has no access to it. `/lockfile-scrub` provides an optional backstop for Bun and npm; npm's `omit-lockfile-registry-resolved=true` remains the preferred preventive configuration. -`/lockfile-scrub` operates only on the caller workspace's root `bun.lock`. It takes no token and never runs package code. `check` is the default: it leaves the file unchanged and exposes `changed=true` when a cleanup is required. `apply` replaces only quoted Socket Firewall tarball URLs with Bun's native empty URL field. +| Selected filename | Cleanup | +| --- | --- | +| `bun.lock` (default) | Replace quoted Socket Firewall tarball URLs with Bun's native empty resolved-URL field (`""`). | +| `package-lock.json` | Replace the exact `https://socket-firewall.workos.dev/` prefix in JSON `resolved` string values with `https://registry.npmjs.org/`. | +| `npm-shrinkwrap.json` | Same npm transform; lockfile versions 1, 2, and 3 are supported for both npm filenames. | + +The npm transform preserves tarball paths, query strings, fragments, versions, integrity hashes, formatting, and unrelated values. It does not remove npm `resolved` fields or blank their URLs. Malformed or unsupported-version npm JSON fails without changing the selected file. + +The action handles **one explicitly selected lockfile** per invocation. `lockfile` is a workspace-relative path and defaults to `bun.lock`, retaining the Bun-only caller behavior. Nested paths such as `apps/site/package-lock.json` are supported. Missing files, unrecognized filenames, traversal, and symlinked files or directories fail. There is no automatic scan of other lockfiles, `node_modules`, or repository history; use separate invocations for multiple locks. Yarn, pnpm, and binary `bun.lockb` are not supported by this scrub action. + +`mode: check` is the default: the file stays byte-identical and `changed=true` means a supported repair is needed. Detecting a supported repair does **not** fail the step; a caller enforcing check-only behavior must fail when that output is true. An unrecognized Bun SFW URL that cannot be normalized fails in both modes without replacing the original file. `mode: apply` performs the same repair and reports whether it changed the file; repeating it is a no-op. The action receives no token, installs nothing, runs no package code, and never commits or pushes. npm scrubbing requires Node.js 22 or later on PATH; Bun retains its Bash/coreutils requirements. Neither mode requires sudo or Socket Firewall configuration. ```yaml -- name: Check Bun lockfile for Socket Firewall URLs +- uses: actions/setup-node@ + with: + node-version: 22 + +- name: Check npm lockfile for Socket Firewall URLs id: scrub uses: workos/setup-socket-firewall/lockfile-scrub@ # v1 with: + lockfile: package-lock.json mode: check + +- name: Reject a lockfile that needs repair + if: steps.scrub.outputs.changed == 'true' + run: | + echo '::error::Normalize Socket Firewall registry URLs before committing this lockfile.' + exit 1 ``` -A same-repository pull-request workflow may run `apply`, verify that no SFW URL remains, then commit only `bun.lock` with its scoped `GITHUB_TOKEN`. Keep that Git write in the caller workflow—not in this action—and never use `pull_request_target` or write to an external fork. GitHub does not trigger another Actions run for a `GITHUB_TOKEN` push, so validate the transformed working tree before committing. +A same-repository pull-request workflow may instead run `apply`, validate the transformed file, then commit **only that selected lockfile** with its scoped `GITHUB_TOKEN`. Keep Git writes in the caller workflow, restrict the trigger to relevant lockfile changes, and never use `pull_request_target` or write to an external fork. A `GITHUB_TOKEN` push does not trigger another Actions run: validating the transformed file is not a replacement for all CI checks on the new commit. Repositories requiring fresh-head checks need a separately reviewed solution before enabling auto-commits. ### Package publication diff --git a/lockfile-scrub/action.yml b/lockfile-scrub/action.yml index 9b6dc96..74047b8 100644 --- a/lockfile-scrub/action.yml +++ b/lockfile-scrub/action.yml @@ -1,22 +1,29 @@ -name: Scrub Socket Firewall Bun lockfile URLs +name: Scrub Socket Firewall lockfile URLs description: >- - Detect or remove Socket Firewall tarball URLs from the root Bun lockfile, - restoring Bun's portable empty resolved-URL fields. + Detect or normalize Socket Firewall registry URLs in Bun and npm lockfiles + without installing dependencies or committing changes. inputs: + lockfile: + description: >- + Workspace-relative path to bun.lock, package-lock.json, or npm-shrinkwrap.json. + Nested paths are supported; symlinks and paths outside the workspace are rejected. + required: false + default: bun.lock mode: - description: Check for URLs without changing bun.lock, or apply the normalization. + description: Check for supported URL repairs without changing the lockfile, or apply them. required: false default: check outputs: changed: - description: Whether bun.lock contains or contained a Socket Firewall tarball URL. + description: Whether the selected lockfile needs (check) or received (apply) a URL repair. value: ${{ steps.scrub.outputs.changed }} runs: using: composite steps: - - name: Scrub Bun lockfile + - name: Scrub lockfile id: scrub shell: bash env: SFW_SCRUB_MODE: ${{ inputs.mode }} + SFW_SCRUB_LOCKFILE: ${{ inputs.lockfile }} run: bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh" diff --git a/release-manifest.txt b/release-manifest.txt index 47af90f..4945473 100644 --- a/release-manifest.txt +++ b/release-manifest.txt @@ -3,5 +3,6 @@ action.yml scripts/configure.sh scripts/teardown.sh scripts/scrub-lockfile.sh +scripts/scrub-npm-lockfile.mjs teardown/action.yml lockfile-scrub/action.yml diff --git a/scripts/build-release.sh b/scripts/build-release.sh index bc1ad20..67a1064 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -106,6 +106,7 @@ diff -u "$expected" "$actual" bash -n "${staging}/scripts/configure.sh" bash -n "${staging}/scripts/teardown.sh" bash -n "${staging}/scripts/scrub-lockfile.sh" +node --check "${staging}/scripts/scrub-npm-lockfile.mjs" if [[ "$OUTPUT_WAS_EXISTING" == 'true' ]]; then [[ ! -L "$OUTPUT" && -d "$OUTPUT" && -z "$(find "$OUTPUT" -mindepth 1 -print -quit)" ]] || { diff --git a/scripts/build-release.test.sh b/scripts/build-release.test.sh index 8d5800b..07d1692 100755 --- a/scripts/build-release.test.sh +++ b/scripts/build-release.test.sh @@ -21,10 +21,10 @@ expect_build_failure() { prepare_source_copy() { local destination="$1" mkdir -p "$destination/scripts" "$destination/teardown" "$destination/lockfile-scrub" - cp "$ROOT/action.yml" "$ROOT/release-manifest.txt" "$destination/" + cp "$ROOT/LICENSE" "$ROOT/action.yml" "$ROOT/release-manifest.txt" "$destination/" cp "$ROOT/teardown/action.yml" "$destination/teardown/" cp "$ROOT/lockfile-scrub/action.yml" "$destination/lockfile-scrub/" - cp "$ROOT/scripts/build-release.sh" "$ROOT/scripts/configure.sh" "$ROOT/scripts/teardown.sh" "$ROOT/scripts/scrub-lockfile.sh" "$destination/scripts/" + cp "$ROOT/scripts/build-release.sh" "$ROOT/scripts/configure.sh" "$ROOT/scripts/teardown.sh" "$ROOT/scripts/scrub-lockfile.sh" "$ROOT/scripts/scrub-npm-lockfile.mjs" "$destination/scripts/" } "${ROOT}/scripts/build-release.sh" "${CASE_DIR}/release" >/dev/null @@ -75,6 +75,14 @@ printf '\nif then\n' >>"${syntax_source}/scripts/configure.sh" expect_build_failure "${syntax_source}/scripts/build-release.sh" "${CASE_DIR}/syntax-output" [[ ! -e "${CASE_DIR}/syntax-output" ]] || fail 'syntax failure left partial output behind' +# A prepared fixture must build successfully before corrupting a runtime file. +node_source="${CASE_DIR}/node-source" +prepare_source_copy "$node_source" +"${node_source}/scripts/build-release.sh" "${CASE_DIR}/node-valid" >/dev/null +printf '\nconst = ;\n' >>"${node_source}/scripts/scrub-npm-lockfile.mjs" +expect_build_failure "${node_source}/scripts/build-release.sh" "${CASE_DIR}/node-invalid" +[[ ! -e "${CASE_DIR}/node-invalid" ]] || fail 'npm syntax failure left output behind' + expected="${CASE_DIR}/expected" actual="${CASE_DIR}/actual" grep -Ev '^[[:space:]]*(#|$)' "${ROOT}/release-manifest.txt" | LC_ALL=C sort >"$expected" @@ -84,7 +92,7 @@ grep -Ev '^[[:space:]]*(#|$)' "${ROOT}/release-manifest.txt" | LC_ALL=C sort >"$ ) >"$actual" diff -u "$expected" "$actual" -for forbidden in README.md package.json package-lock.json reports tools .github scripts/configure.test.sh scripts/teardown.test.sh scripts/scrub-lockfile.test.sh scripts/build-release.sh scripts/build-release.test.sh scripts/publish-release.sh scripts/publish-release.test.sh; do +for forbidden in README.md package.json package-lock.json reports tools .github scripts/configure.test.sh scripts/teardown.test.sh scripts/scrub-lockfile.test.sh scripts/scrub-npm-lockfile.test.mjs scripts/build-release.sh scripts/build-release.test.sh scripts/publish-release.sh scripts/publish-release.test.sh; do [[ ! -e "${CASE_DIR}/release/${forbidden}" ]] || fail "forbidden release path present: ${forbidden}" done @@ -96,4 +104,13 @@ grep -Fq 'bash "$GITHUB_ACTION_PATH/scripts/configure.sh"' "${CASE_DIR}/release/ grep -Fq 'bash "$GITHUB_ACTION_PATH/../scripts/teardown.sh"' "${CASE_DIR}/release/teardown/action.yml" || fail 'teardown action does not invoke its shipped teardown script' grep -Fq 'bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh"' "${CASE_DIR}/release/lockfile-scrub/action.yml" || fail 'lockfile scrub action does not invoke its shipped script' +# Run the shipped wrapper from the isolated release tree, not the source tree. +mkdir "${CASE_DIR}/npm-workspace" +printf '{"lockfileVersion":3,"resolved":"https://socket-firewall.workos.dev/pkg/-/pkg-1.tgz"}\n' >"${CASE_DIR}/npm-workspace/package-lock.json" +GITHUB_WORKSPACE="${CASE_DIR}/npm-workspace" GITHUB_OUTPUT="${CASE_DIR}/npm-output" \ + SFW_SCRUB_MODE=apply SFW_SCRUB_LOCKFILE=package-lock.json \ + bash "${CASE_DIR}/release/scripts/scrub-lockfile.sh" +grep -Fq 'https://registry.npmjs.org/pkg/-/pkg-1.tgz' "${CASE_DIR}/npm-workspace/package-lock.json" || fail 'shipped npm scrub failed' +grep -Fxq 'changed=true' "${CASE_DIR}/npm-output" || fail 'shipped npm scrub output missing' + printf 'release tree tests passed\n' diff --git a/scripts/scrub-lockfile.sh b/scripts/scrub-lockfile.sh index aeb772f..5e91125 100755 --- a/scripts/scrub-lockfile.sh +++ b/scripts/scrub-lockfile.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" readonly SFW_HOST='https://socket-firewall.workos.dev/' fail() { - printf '::error::Socket Firewall Bun lockfile scrub failed: %s\n' "$1" >&2 + printf '::error::Socket Firewall lockfile scrub failed: %s\n' "$1" >&2 exit 1 } @@ -25,32 +26,58 @@ esac workspace="${GITHUB_WORKSPACE:-}" [[ -n "$workspace" && -d "$workspace" && ! -L "$workspace" ]] || fail 'GITHUB_WORKSPACE must name a real directory' [[ -n "${GITHUB_OUTPUT:-}" ]] || fail 'GITHUB_OUTPUT is required' +workspace="$(cd "$workspace" && pwd -P)" -lockfile="${workspace}/bun.lock" -[[ -f "$lockfile" && ! -L "$lockfile" ]] || fail 'root bun.lock must be a regular file' - -if ! grep -Fq "$SFW_HOST" "$lockfile"; then - printf 'changed=false\n' >>"$GITHUB_OUTPUT" - exit 0 -fi +relative="${SFW_SCRUB_LOCKFILE:-bun.lock}" +case "$relative" in + /* | *$'\n'* | *$'\r'* | */ | *//* | . | .. | ./* | ../* | */./* | */../*) + fail 'lockfile must be a relative workspace path without dot or empty components' + ;; +esac +case "$(basename "$relative")" in + bun.lock) format=bun ;; + package-lock.json | npm-shrinkwrap.json) format=npm ;; + *) fail 'supported lockfiles are bun.lock, package-lock.json, and npm-shrinkwrap.json' ;; +esac -printf 'changed=true\n' >>"$GITHUB_OUTPUT" -if [[ "$mode" == 'check' ]]; then - exit 0 -fi +# Reject symlinked parent directories as well as symlinked lockfiles. Never +# follow a contributor-controlled path outside the checked-out workspace. +IFS='/' read -r -a components <<<"$relative" +lockfile="$workspace" +for component in "${components[@]}"; do + lockfile="${lockfile}/${component}" + [[ ! -L "$lockfile" ]] || fail 'lockfile path must not contain symbolic links' +done +[[ -f "$lockfile" ]] || fail 'selected lockfile must be a regular file' +# Stage the transformation before publishing output or replacing any file. +# Check mode uses the same transform, so changed only means a supported repair. temporary="$(mktemp "${lockfile}.workos-sfw.XXXXXX")" cleanup() { rm -f -- "$temporary" } trap cleanup EXIT -sed 's#"https://socket-firewall\.workos\.dev/[^"]*"#""#g' "$lockfile" >"$temporary" -chmod "$(file_mode "$lockfile")" "$temporary" -mv -f -- "$temporary" "$lockfile" +case "$format" in + bun) + sed 's#"https://socket-firewall\.workos\.dev/[^"]*"#""#g' "$lockfile" >"$temporary" + if grep -Fq "$SFW_HOST" "$temporary"; then + fail 'unrecognized Socket Firewall URL in Bun lockfile; original file was not changed' + fi + ;; + npm) + command -v node >/dev/null 2>&1 || fail 'npm lockfile scrubbing requires Node.js 22 or later' + node "$ROOT/scripts/scrub-npm-lockfile.mjs" "$lockfile" >"$temporary" + ;; +esac -if grep -Fq "$SFW_HOST" "$lockfile"; then - fail 'Socket Firewall URL remains after apply mode' +if cmp -s "$lockfile" "$temporary"; then + printf 'changed=false\n' >>"$GITHUB_OUTPUT" + exit 0 fi -trap - EXIT +if [[ "$mode" == 'apply' ]]; then + chmod "$(file_mode "$lockfile")" "$temporary" + mv -f -- "$temporary" "$lockfile" +fi +printf 'changed=true\n' >>"$GITHUB_OUTPUT" diff --git a/scripts/scrub-lockfile.test.sh b/scripts/scrub-lockfile.test.sh index 2aaaf99..68e6da3 100755 --- a/scripts/scrub-lockfile.test.sh +++ b/scripts/scrub-lockfile.test.sh @@ -26,6 +26,7 @@ new_case() { export GITHUB_OUTPUT="${CASE_DIR}/github-output" mkdir -p "$GITHUB_WORKSPACE" : >"$GITHUB_OUTPUT" + unset SFW_SCRUB_LOCKFILE } cleanup_case() { @@ -119,9 +120,42 @@ test_invalid_mode_is_rejected() { cleanup_case } +test_explicit_nested_bun_lockfile() { + new_case + write_lockfile + cp "${GITHUB_WORKSPACE}/bun.lock" "${CASE_DIR}/before" + mkdir "${GITHUB_WORKSPACE}/nested dir" + cp "${GITHUB_WORKSPACE}/bun.lock" "${GITHUB_WORKSPACE}/nested dir/bun.lock" + SFW_SCRUB_MODE=apply SFW_SCRUB_LOCKFILE='nested dir/bun.lock' bash "$SCRIPT" + assert_contains "$GITHUB_OUTPUT" 'changed=true' + assert_not_contains "${GITHUB_WORKSPACE}/nested dir/bun.lock" 'https://socket-firewall.workos.dev/' + cmp "${CASE_DIR}/before" "${GITHUB_WORKSPACE}/bun.lock" || fail 'unselected root lockfile changed' + : >"$GITHUB_OUTPUT" + SFW_SCRUB_MODE=apply SFW_SCRUB_LOCKFILE='nested dir/bun.lock' bash "$SCRIPT" + assert_contains "$GITHUB_OUTPUT" 'changed=false' + cleanup_case +} + +test_unrecognized_bun_url_fails_without_mutation() { + new_case + write_lockfile + printf 'unquoted https://socket-firewall.workos.dev/broken\n' >>"${GITHUB_WORKSPACE}/bun.lock" + cp "${GITHUB_WORKSPACE}/bun.lock" "${CASE_DIR}/before" + for scrub_mode in check apply; do + if SFW_SCRUB_MODE="$scrub_mode" bash "$SCRIPT" >/dev/null 2>&1; then + fail 'unrecognized Bun URL succeeded' + fi + cmp "${CASE_DIR}/before" "${GITHUB_WORKSPACE}/bun.lock" || fail 'failure modified original lockfile' + [[ ! -s "$GITHUB_OUTPUT" ]] || fail 'failed scrub emitted success output' + done + cleanup_case +} + test_action_wires_changed_output_and_script() { assert_contains "$ACTION" 'value: ${{ steps.scrub.outputs.changed }}' assert_contains "$ACTION" 'run: bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh"' + assert_contains "$ACTION" 'default: bun.lock' + assert_contains "$ACTION" 'SFW_SCRUB_LOCKFILE: ${{ inputs.lockfile }}' } test_clean_lockfile_reports_unchanged @@ -130,6 +164,8 @@ test_apply_mode_restores_native_bun_fields test_apply_mode_rewrites_multiple_sfw_urls test_missing_or_symlink_lockfile_is_rejected test_invalid_mode_is_rejected +test_explicit_nested_bun_lockfile +test_unrecognized_bun_url_fails_without_mutation test_action_wires_changed_output_and_script printf 'scrub lockfile tests passed\n' diff --git a/scripts/scrub-npm-lockfile.mjs b/scripts/scrub-npm-lockfile.mjs new file mode 100644 index 0000000..0200aa4 --- /dev/null +++ b/scripts/scrub-npm-lockfile.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const firewall = 'https://socket-firewall.workos.dev/'; +const registry = 'https://registry.npmjs.org/'; + +export function scrubNpmLockfile(source) { + const lock = JSON.parse(source); + if (!lock || Array.isArray(lock) || ![1, 2, 3].includes(lock.lockfileVersion)) { + throw new Error('expected an npm lockfile with lockfileVersion 1, 2, or 3'); + } + + // Match whole JSON tokens, not substrings inside strings. JSON.parse above + // validates the grammar; retaining the original text avoids formatting churn. + const tokens = /"(?:\\[\s\S]|[^"\\])*"|[{}\[\]:,]|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g; + let previous; + let beforePrevious; + return source.replace(tokens, (token) => { + let replacement = token; + if (token.startsWith('"') && previous === ':' && beforePrevious?.startsWith('"')) { + const key = JSON.parse(beforePrevious); + const value = JSON.parse(token); + if (key === 'resolved' && value.startsWith(firewall)) { + replacement = JSON.stringify(registry + value.slice(firewall.length)); + } + } + beforePrevious = previous; + previous = token; + return replacement; + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + process.stdout.write(scrubNpmLockfile(readFileSync(process.argv[2], 'utf8'))); + } catch { + // Do not echo parser errors: they can include lockfile URLs or credentials. + console.error('npm lockfile scrub failed: expected valid JSON and lockfileVersion 1, 2, or 3'); + process.exitCode = 1; + } +} diff --git a/scripts/scrub-npm-lockfile.test.mjs b/scripts/scrub-npm-lockfile.test.mjs new file mode 100644 index 0000000..56c4e44 --- /dev/null +++ b/scripts/scrub-npm-lockfile.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { scrubNpmLockfile } from './scrub-npm-lockfile.mjs'; + +const script = fileURLToPath(new URL('./scrub-lockfile.sh', import.meta.url)); +const sfw = 'https://socket-firewall.workos.dev/'; +const npm = 'https://registry.npmjs.org/'; +const tarball = '@scope/pkg/-/pkg-1.2.3.tgz?download=1#fragment'; +const descriptor = { version: '1.2.3', resolved: sfw + tarball, integrity: 'sha512-unchanged' }; + +for (const version of [1, 2, 3]) { + test(`npm v${version}: canonical host, nested entries, all other bytes preserved`, () => { + const lock = { lockfileVersion: version }; + if (version < 3) { + lock.dependencies = { pkg: { ...descriptor, dependencies: { nested: descriptor } } }; + } + if (version > 1) { + lock.packages = { '': { name: 'root' }, 'node_modules/@scope/pkg': descriptor }; + } + const input = JSON.stringify(lock, null, '\t').replaceAll('\n', '\r\n') + '\r\n'; + const expected = input.replaceAll(sfw, npm); + const result = scrubNpmLockfile(input); + assert.equal(result, expected); + assert.equal(scrubNpmLockfile(result), result); + }); +} + +test('only resolved string values change; lookalikes and other URLs remain intact', () => { + const lock = { + lockfileVersion: 3, + resolved: 3, + packages: { + one: descriptor, + two: { resolved: 'https://socket-firewall.workos.dev.evil.test/pkg' }, + three: { resolved: 'https://socket-firewall.workos.dev@evil.test/pkg' }, + four: { resolved: 'https://example.test/' + sfw }, + five: { resolved: 'https://user:secret@socket-firewall.workos.dev/pkg' }, + six: { resolved: npm + tarball }, + seven: { resolved: null }, + eight: { resolved: false }, + nine: { resolved: true }, + ten: { resolved: {} }, + }, + metadata: sfw + 'do-not-edit', + version: sfw + 'do-not-edit', + embedded: '\"resolved\": \"' + sfw + tarball + '\"', + }; + const expected = structuredClone(lock); + expected.packages.one.resolved = npm + tarball; + assert.deepEqual(JSON.parse(scrubNpmLockfile(JSON.stringify(lock))), expected); +}); + +test('escaped resolved keys and escaped URL slashes are decoded safely', () => { + const input = '{"lockfileVersion":2,"dependencies":{"pkg":{"resol\\u0076ed":"https:\\/\\/socket-firewall.workos.dev\\/pkg\\/-\\/pkg-1.tgz"}}}'; + const result = scrubNpmLockfile(input); + assert.equal(JSON.parse(result).dependencies.pkg.resolved, npm + 'pkg/-/pkg-1.tgz'); + assert.ok(result.includes('resol\\u0076ed')); +}); + +test('npm omission-config locks are byte-identical no-ops', () => { + const input = '{"lockfileVersion":3,"packages":{"node_modules/pkg":{"integrity":"sha512-test"}}}\n'; + assert.equal(scrubNpmLockfile(input), input); +}); + +for (const input of ['null', '[]', '{}', '{broken', '{"lockfileVersion":4}', '{"lockfileVersion":"3"}', '{"lockfileVersion":3,}']) { + test(`invalid/unsupported npm input is rejected: ${input}`, () => { + assert.throws(() => scrubNpmLockfile(input)); + }); +} + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'sfw-npm-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const workspace = join(root, 'workspace'); + mkdirSync(workspace); + const output = join(root, 'output'); + writeFileSync(output, ''); + return { root, workspace, output }; +} + +function run(f, path, mode = 'apply') { + writeFileSync(f.output, ''); + return spawnSync('bash', [script], { + env: { ...process.env, GITHUB_WORKSPACE: f.workspace, GITHUB_OUTPUT: f.output, SFW_SCRUB_MODE: mode, SFW_SCRUB_LOCKFILE: path }, + encoding: 'utf8', + }); +} + +for (const path of ['package-lock.json', 'npm-shrinkwrap.json', 'nested with spaces/package-lock.json']) { + test(`shell wrapper: check/apply/no-op, mode preservation and selected file only: ${path}`, (t) => { + const f = fixture(t); + const file = join(f.workspace, path); + mkdirSync(dirname(file), { recursive: true }); + const before = JSON.stringify({ lockfileVersion: 3, packages: { pkg: descriptor } }); + writeFileSync(file, before); + chmodSync(file, 0o640); + writeFileSync(join(f.workspace, 'unrelated.txt'), sfw); + for (const mode of ['check', 'apply', 'apply']) { + const previous = readFileSync(file, 'utf8'); + const result = run(f, path, mode); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(f.output, 'utf8'), `changed=${previous.includes(sfw)}\n`); + assert.equal(readFileSync(file, 'utf8'), mode === 'check' ? before : before.replaceAll(sfw, npm)); + assert.equal(statSync(file).mode & 0o777, 0o640); + assert.equal(readFileSync(join(f.workspace, 'unrelated.txt'), 'utf8'), sfw); + assert.ok(!readdirSync(dirname(file)).some((name) => name.includes('.workos-sfw.'))); + } + }); +} + +test('malformed npm input fails without changing file or exposing contents', (t) => { + const f = fixture(t); + const file = join(f.workspace, 'package-lock.json'); + const secret = 'secret-do-not-log'; + writeFileSync(file, '{"lockfileVersion":3,"resolved":"' + sfw + secret); + const before = readFileSync(file); + const result = run(f, 'package-lock.json'); + assert.notEqual(result.status, 0); + assert.deepEqual(readFileSync(file), before); + assert.equal(readFileSync(f.output, 'utf8'), ''); + assert.ok(!result.stderr.includes(secret)); + assert.deepEqual(readdirSync(f.workspace), ['package-lock.json']); +}); + +test('unsupported, missing, absolute, traversal and symlink paths cannot modify files', (t) => { + const f = fixture(t); + const input = JSON.stringify({ lockfileVersion: 3, resolved: sfw + tarball }); + const external = join(f.root, 'package-lock.json'); + writeFileSync(external, input); + symlinkSync(external, join(f.workspace, 'npm-shrinkwrap.json')); + symlinkSync(f.root, join(f.workspace, 'linked')); + for (const path of ['yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'missing/package-lock.json', external, '../package-lock.json', 'x/../../package-lock.json', 'linked/package-lock.json', 'npm-shrinkwrap.json']) { + const result = run(f, path); + assert.notEqual(result.status, 0, path); + assert.equal(readFileSync(external, 'utf8'), input); + assert.equal(readFileSync(f.output, 'utf8'), ''); + } + assert.ok(!existsSync(join(f.workspace, 'package-lock.json'))); +}); From dde85ff7a5b58c76c0ac8b1fd26f9f458c0e54a8 Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Mon, 14 Sep 2026 08:40:32 -0400 Subject: [PATCH 3/6] fix: make lockfile action repair the PR branch itself Default to guarded GITHUB_TOKEN repair using atomic createCommitOnBranch. No checkout or caller-owned commit/push is required. Include a temporary PR-10-only live trial to verify branch updates. Spec: docs/ideation/bun-lockfile-socket-firewall-scrub/spec-phase-1.md --- .github/fixtures/lockfile-repair/bun.lock | 6 + .github/workflows/ci.yml | 29 +-- .github/workflows/verify-lockfile-repair.yml | 41 +++++ CONTRIBUTING.md | 6 +- README.md | 49 ++--- lockfile-scrub/action.yml | 38 ++-- release-manifest.txt | 1 + scripts/build-release.sh | 1 + scripts/build-release.test.sh | 7 +- scripts/fix-lockfile.mjs | 177 +++++++++++++++++++ scripts/fix-lockfile.test.mjs | 172 ++++++++++++++++++ scripts/scrub-lockfile.test.sh | 7 +- 12 files changed, 475 insertions(+), 59 deletions(-) create mode 100644 .github/fixtures/lockfile-repair/bun.lock create mode 100644 .github/workflows/verify-lockfile-repair.yml create mode 100644 scripts/fix-lockfile.mjs create mode 100644 scripts/fix-lockfile.test.mjs diff --git a/.github/fixtures/lockfile-repair/bun.lock b/.github/fixtures/lockfile-repair/bun.lock new file mode 100644 index 0000000..ba4d33e --- /dev/null +++ b/.github/fixtures/lockfile-repair/bun.lock @@ -0,0 +1,6 @@ +{ + "lockfileVersion": 1, + "packages": { + "left-pad": ["left-pad@1.3.0", "https://socket-firewall.workos.dev/left-pad/-/left-pad-1.3.0.tgz", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c46c260..e7badef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24 - name: Check shell style and test coverage policy run: | set -euo pipefail @@ -36,7 +36,7 @@ jobs: bash scripts/configure.test.sh bash scripts/teardown.test.sh bash scripts/scrub-lockfile.test.sh - node --test scripts/scrub-npm-lockfile.test.mjs + node --test scripts/scrub-npm-lockfile.test.mjs scripts/fix-lockfile.test.mjs bash scripts/build-release.test.sh bash scripts/publish-release.test.sh @@ -76,10 +76,12 @@ jobs: writeFileSync('scrub-smoke/before', text); writeFileSync('scrub-smoke/expected', text.replaceAll('socket-firewall.workos.dev', 'registry.npmjs.org')); JS - - uses: ./lockfile-scrub + - name: Check normalization helper id: check - with: - lockfile: scrub-smoke/${{ matrix.lockfile }} + env: + SFW_SCRUB_LOCKFILE: scrub-smoke/${{ matrix.lockfile }} + SFW_SCRUB_MODE: check + run: bash scripts/scrub-lockfile.sh - name: Check mode preserves bytes env: CHANGED: ${{ steps.check.outputs.changed }} @@ -87,11 +89,12 @@ jobs: run: | test "$CHANGED" = true cmp scrub-smoke/before "scrub-smoke/$LOCKFILE" - - uses: ./lockfile-scrub + - name: Apply normalization helper id: apply - with: - lockfile: scrub-smoke/${{ matrix.lockfile }} - mode: apply + env: + SFW_SCRUB_LOCKFILE: scrub-smoke/${{ matrix.lockfile }} + SFW_SCRUB_MODE: apply + run: bash scripts/scrub-lockfile.sh - name: Install from normalized lockfile without credentials working-directory: scrub-smoke env: @@ -104,10 +107,12 @@ jobs: npm ci --ignore-scripts --no-audit --no-fund --registry=https://registry.npmjs.org/ --cache="$RUNNER_TEMP/scrub-npm-cache" node -e "require('left-pad')" cmp expected "$LOCKFILE" - - uses: ./lockfile-scrub + - name: Recheck normalization helper id: recheck - with: - lockfile: scrub-smoke/${{ matrix.lockfile }} + env: + SFW_SCRUB_LOCKFILE: scrub-smoke/${{ matrix.lockfile }} + SFW_SCRUB_MODE: check + run: bash scripts/scrub-lockfile.sh - name: Assert idempotence env: CHANGED: ${{ steps.recheck.outputs.changed }} diff --git a/.github/workflows/verify-lockfile-repair.yml b/.github/workflows/verify-lockfile-repair.yml new file mode 100644 index 0000000..15721f0 --- /dev/null +++ b/.github/workflows/verify-lockfile-repair.yml @@ -0,0 +1,41 @@ +name: Verify lockfile branch repair (temporary) + +on: + pull_request: + paths: + - .github/workflows/verify-lockfile-repair.yml + - .github/fixtures/lockfile-repair/bun.lock + - lockfile-scrub/action.yml + - scripts/fix-lockfile.mjs + +permissions: + contents: read + +jobs: + repair: + if: github.event.pull_request.number == 10 && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + # Only needed to exercise the unreleased local action. Released consumers + # call the pinned action directly with no checkout step. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: ./lockfile-scrub + id: repair + with: + lockfile: .github/fixtures/lockfile-repair/bun.lock + - name: Verify the action created its own single-file commit + if: steps.repair.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + COMMIT_SHA: ${{ steps.repair.outputs.commit-sha }} + run: | + set -euo pipefail + test -n "$COMMIT_SHA" + gh api "repos/$GITHUB_REPOSITORY/commits/$COMMIT_SHA" --jq '.files | map(.filename)' > "$RUNNER_TEMP/changed-files.json" + jq -e '. == [".github/fixtures/lockfile-repair/bun.lock"]' "$RUNNER_TEMP/changed-files.json" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ec5117..c70d0b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thanks for helping improve the WorkOS Socket Firewall GitHub Action. ## Development requirements - Bash on Linux or macOS -- Node.js 22 or later, for npm lockfile transformation, tests, and release validation +- Node.js 24 or later, matching the branch-repair JavaScript action runtime - Go, for the pinned `shfmt` check - ShellCheck - Passwordless `sudo` and a disposable Linux runner for integration testing that modifies `/etc/hosts` @@ -23,12 +23,12 @@ bash -n scripts/*.sh bash scripts/configure.test.sh bash scripts/teardown.test.sh bash scripts/scrub-lockfile.test.sh -node --test scripts/scrub-npm-lockfile.test.mjs +node --test scripts/scrub-npm-lockfile.test.mjs scripts/fix-lockfile.test.mjs bash scripts/build-release.test.sh bash scripts/publish-release.test.sh ``` -CI runs the same static and unit checks on every pull request. The npm scrub smoke matrix runs the composite action and `npm ci --ignore-scripts` against both npm filenames and lockfile versions 1–3 without credentials. Token-backed GitHub-hosted smoke jobs additionally exercise every supported package manager and a scrubbed npm lockfile with public registry DNS blocked. +CI runs the same static and unit checks on every pull request. The npm scrub smoke matrix tests the internal normalizer and `npm ci --ignore-scripts` against both npm filenames and lockfile versions 1–3 without credentials. Branch-repair tests exercise the action controller against a simulated GitHub API, asserting the target branch, single-file commit, expected-head race protection, no-op, fork/default-branch guards, and denied writes. Release-tree tests run those same controller tests using the packaged runtime. Token-backed GitHub-hosted smoke jobs additionally exercise every supported package manager and a scrubbed npm lockfile with public registry DNS blocked. The minimum test-coverage policy is one shell test suite for every executable shell source file. Changes to supported package-manager behavior must also include a token-backed frozen-lockfile smoke test. diff --git a/README.md b/README.md index 7719c2f..2a66f04 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This repository exposes three action entrypoints from the same action-only relea - `/` — configure protected dependency downloads. - `/teardown` — remove only SFW-owned configuration before an npm/pnpm/Yarn/Bun publish in the same job. -- `/lockfile-scrub` — detect or normalize Socket Firewall registry URLs in Bun and npm lockfiles. +- `/lockfile-scrub` — normalize a Bun or npm lockfile and commit the repair directly to its pull request branch. It does not route package publication or Python, Java, Go, Ruby, Rust, .NET, private-registry, or other dependency ecosystems through the WorkOS SFW instance. @@ -92,30 +92,39 @@ Registry URLs saved in lockfiles can bind future installations to Socket Firewal The npm transform preserves tarball paths, query strings, fragments, versions, integrity hashes, formatting, and unrelated values. It does not remove npm `resolved` fields or blank their URLs. Malformed or unsupported-version npm JSON fails without changing the selected file. -The action handles **one explicitly selected lockfile** per invocation. `lockfile` is a workspace-relative path and defaults to `bun.lock`, retaining the Bun-only caller behavior. Nested paths such as `apps/site/package-lock.json` are supported. Missing files, unrecognized filenames, traversal, and symlinked files or directories fail. There is no automatic scan of other lockfiles, `node_modules`, or repository history; use separate invocations for multiple locks. Yarn, pnpm, and binary `bun.lockb` are not supported by this scrub action. - -`mode: check` is the default: the file stays byte-identical and `changed=true` means a supported repair is needed. Detecting a supported repair does **not** fail the step; a caller enforcing check-only behavior must fail when that output is true. An unrecognized Bun SFW URL that cannot be normalized fails in both modes without replacing the original file. `mode: apply` performs the same repair and reports whether it changed the file; repeating it is a no-op. The action receives no token, installs nothing, runs no package code, and never commits or pushes. npm scrubbing requires Node.js 22 or later on PATH; Bun retains its Bash/coreutils requirements. Neither mode requires sudo or Socket Firewall configuration. +**The default `mode: fix` repairs the PR branch itself.** The action reads the selected lockfile from the event's committed PR head, normalizes it in an isolated temporary directory, and creates a commit containing only that file on the same PR branch. `token` defaults to `${{ github.token }}`; the job needs `contents: write`. No checkout, package installation, Socket Firewall secret, Git configuration, or separate commit/push step is needed. The action uses GitHub's atomic `createCommitOnBranch` API rather than a local Git push. ```yaml -- uses: actions/setup-node@ - with: - node-version: 22 +name: Repair lockfile registry URLs +on: + pull_request: + paths: [bun.lock] -- name: Check npm lockfile for Socket Firewall URLs - id: scrub - uses: workos/setup-socket-firewall/lockfile-scrub@ # v1 - with: - lockfile: package-lock.json - mode: check - -- name: Reject a lockfile that needs repair - if: steps.scrub.outputs.changed == 'true' - run: | - echo '::error::Normalize Socket Firewall registry URLs before committing this lockfile.' - exit 1 +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: workos/setup-socket-firewall/lockfile-scrub@ # v1 ``` -A same-repository pull-request workflow may instead run `apply`, validate the transformed file, then commit **only that selected lockfile** with its scoped `GITHUB_TOKEN`. Keep Git writes in the caller workflow, restrict the trigger to relevant lockfile changes, and never use `pull_request_target` or write to an external fork. A `GITHUB_TOKEN` push does not trigger another Actions run: validating the transformed file is not a replacement for all CI checks on the new commit. Repositories requiring fresh-head checks need a separately reviewed solution before enabling auto-commits. +For npm, set `with: { lockfile: package-lock.json }` and change the workflow path filter to match. `npm-shrinkwrap.json` and nested paths such as `apps/site/package-lock.json` are also supported. The path is relative to the repository at the PR head, **not** a local checkout; uncommitted local changes are ignored. Only one selected lockfile is processed. Yarn, pnpm, binary `bun.lockb`, symlinks, submodules, executable lockfiles, missing files, and paths outside the repository are rejected. + +The outputs are `changed` (whether the file needed repair) and `commit-sha` (the repair commit, empty for no-op/check mode). A clean file creates no commit. `mode: check` explicitly opts into read-only detection: it reports `changed=true` without committing or failing merely because repair is needed. Malformed npm JSON and unrecognized Bun SFW URLs fail without making a branch change. + +Branch safety is enforced **inside the action**, not left to caller shell guards: + +- Only ordinary `pull_request` events are accepted; `pull_request_target`, push, dispatch, and other triggers are refused. +- Same-repository PR branches can be repaired. Clean external forks pass; dirty forks fail with repair guidance and never receive a write. +- The repository's default branch is never modified. Closed PRs or an event whose PR head has changed are rejected. +- The commit API compares the expected head SHA atomically, refusing concurrent updates. It does not force-push, overwrite newer commits, run Git hooks, or retry denied/ambiguous writes. + +Use this action in a dedicated job on an ephemeral Linux runner supporting Node 24 JavaScript actions, with Bash/coreutils available. GitHub supplies the Node runtime. It never executes target-repository code. The token is used only for GitHub API requests and is not passed to the normalization subprocess. + +`GITHUB_TOKEN` commits do not trigger another Actions run. Normalizing a lockfile does not establish that all application CI checks pass on the new commit. Repositories requiring fresh-head checks need a separately reviewed solution. Branch protection and token-policy denials fail the action; it does not bypass those controls. If several selected locks need repair, each commit advances the head, so use a subsequent PR event for the next repair rather than parallel writers using the same event SHA. ### Package publication diff --git a/lockfile-scrub/action.yml b/lockfile-scrub/action.yml index 74047b8..7d26695 100644 --- a/lockfile-scrub/action.yml +++ b/lockfile-scrub/action.yml @@ -1,29 +1,31 @@ -name: Scrub Socket Firewall lockfile URLs +name: Fix Socket Firewall lockfile URLs description: >- - Detect or normalize Socket Firewall registry URLs in Bun and npm lockfiles - without installing dependencies or committing changes. + Normalize a Bun or npm lockfile and commit the repair directly to the + same-repository pull request branch using GITHUB_TOKEN. inputs: lockfile: description: >- - Workspace-relative path to bun.lock, package-lock.json, or npm-shrinkwrap.json. - Nested paths are supported; symlinks and paths outside the workspace are rejected. + Repository-relative path to bun.lock, package-lock.json, or npm-shrinkwrap.json. + Reads committed PR-head content; no checkout is required. Symlinks are rejected. required: false default: bun.lock mode: - description: Check for supported URL repairs without changing the lockfile, or apply them. + description: >- + fix (default) commits a needed repair to the PR branch; check reports whether + repair is needed without committing. Requires a pull_request event. + required: false + default: fix + token: + description: >- + Repository GITHUB_TOKEN. Automatic repair requires contents:write. + Never writes to a fork or the repository default branch. required: false - default: check + default: ${{ github.token }} outputs: changed: - description: Whether the selected lockfile needs (check) or received (apply) a URL repair. - value: ${{ steps.scrub.outputs.changed }} + description: Whether the selected lockfile needed repair. + commit-sha: + description: The repair commit SHA, or empty if no commit was created. runs: - using: composite - steps: - - name: Scrub lockfile - id: scrub - shell: bash - env: - SFW_SCRUB_MODE: ${{ inputs.mode }} - SFW_SCRUB_LOCKFILE: ${{ inputs.lockfile }} - run: bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh" + using: node24 + main: ../scripts/fix-lockfile.mjs diff --git a/release-manifest.txt b/release-manifest.txt index 4945473..6342986 100644 --- a/release-manifest.txt +++ b/release-manifest.txt @@ -4,5 +4,6 @@ scripts/configure.sh scripts/teardown.sh scripts/scrub-lockfile.sh scripts/scrub-npm-lockfile.mjs +scripts/fix-lockfile.mjs teardown/action.yml lockfile-scrub/action.yml diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 67a1064..354fe23 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -107,6 +107,7 @@ bash -n "${staging}/scripts/configure.sh" bash -n "${staging}/scripts/teardown.sh" bash -n "${staging}/scripts/scrub-lockfile.sh" node --check "${staging}/scripts/scrub-npm-lockfile.mjs" +node --check "${staging}/scripts/fix-lockfile.mjs" if [[ "$OUTPUT_WAS_EXISTING" == 'true' ]]; then [[ ! -L "$OUTPUT" && -d "$OUTPUT" && -z "$(find "$OUTPUT" -mindepth 1 -print -quit)" ]] || { diff --git a/scripts/build-release.test.sh b/scripts/build-release.test.sh index 07d1692..917261e 100755 --- a/scripts/build-release.test.sh +++ b/scripts/build-release.test.sh @@ -24,7 +24,7 @@ prepare_source_copy() { cp "$ROOT/LICENSE" "$ROOT/action.yml" "$ROOT/release-manifest.txt" "$destination/" cp "$ROOT/teardown/action.yml" "$destination/teardown/" cp "$ROOT/lockfile-scrub/action.yml" "$destination/lockfile-scrub/" - cp "$ROOT/scripts/build-release.sh" "$ROOT/scripts/configure.sh" "$ROOT/scripts/teardown.sh" "$ROOT/scripts/scrub-lockfile.sh" "$ROOT/scripts/scrub-npm-lockfile.mjs" "$destination/scripts/" + cp "$ROOT/scripts/build-release.sh" "$ROOT/scripts/configure.sh" "$ROOT/scripts/teardown.sh" "$ROOT/scripts/scrub-lockfile.sh" "$ROOT/scripts/scrub-npm-lockfile.mjs" "$ROOT/scripts/fix-lockfile.mjs" "$destination/scripts/" } "${ROOT}/scripts/build-release.sh" "${CASE_DIR}/release" >/dev/null @@ -92,7 +92,7 @@ grep -Ev '^[[:space:]]*(#|$)' "${ROOT}/release-manifest.txt" | LC_ALL=C sort >"$ ) >"$actual" diff -u "$expected" "$actual" -for forbidden in README.md package.json package-lock.json reports tools .github scripts/configure.test.sh scripts/teardown.test.sh scripts/scrub-lockfile.test.sh scripts/scrub-npm-lockfile.test.mjs scripts/build-release.sh scripts/build-release.test.sh scripts/publish-release.sh scripts/publish-release.test.sh; do +for forbidden in README.md package.json package-lock.json reports tools .github scripts/configure.test.sh scripts/teardown.test.sh scripts/scrub-lockfile.test.sh scripts/scrub-npm-lockfile.test.mjs scripts/fix-lockfile.test.mjs scripts/build-release.sh scripts/build-release.test.sh scripts/publish-release.sh scripts/publish-release.test.sh; do [[ ! -e "${CASE_DIR}/release/${forbidden}" ]] || fail "forbidden release path present: ${forbidden}" done @@ -102,7 +102,8 @@ done grep -Fq 'bash "$GITHUB_ACTION_PATH/scripts/configure.sh"' "${CASE_DIR}/release/action.yml" || fail 'root action does not invoke its shipped configure script' grep -Fq 'bash "$GITHUB_ACTION_PATH/../scripts/teardown.sh"' "${CASE_DIR}/release/teardown/action.yml" || fail 'teardown action does not invoke its shipped teardown script' -grep -Fq 'bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh"' "${CASE_DIR}/release/lockfile-scrub/action.yml" || fail 'lockfile scrub action does not invoke its shipped script' +grep -Fq 'main: ../scripts/fix-lockfile.mjs' "${CASE_DIR}/release/lockfile-scrub/action.yml" || fail 'lockfile action does not invoke its shipped branch fixer' +SFW_TEST_FIX_MODULE="${CASE_DIR}/release/scripts/fix-lockfile.mjs" node --test "${ROOT}/scripts/fix-lockfile.test.mjs" >/dev/null # Run the shipped wrapper from the isolated release tree, not the source tree. mkdir "${CASE_DIR}/npm-workspace" diff --git a/scripts/fix-lockfile.mjs b/scripts/fix-lockfile.mjs new file mode 100644 index 0000000..04224ed --- /dev/null +++ b/scripts/fix-lockfile.mjs @@ -0,0 +1,177 @@ +import { execFileSync } from 'node:child_process'; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const shaPattern = /^[a-f0-9]{40}$/; +const repoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const script = fileURLToPath(new URL('./scrub-lockfile.sh', import.meta.url)); +const commitMutation = `mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } +}`; + +class ActionError extends Error {} + +function requireValue(condition, message) { + if (!condition) throw new ActionError(message); +} + +export function validateLockfile(path) { + requireValue(typeof path === 'string' && !/[\x00-\x1f\x7f\\]/.test(path), 'Invalid lockfile path.'); + requireValue(path.split('/').every((part) => part && part !== '.' && part !== '..'), 'Use a repository-relative lockfile path without dot or empty components.'); + requireValue(['bun.lock', 'package-lock.json', 'npm-shrinkwrap.json'].includes(basename(path)), 'Supported filenames: bun.lock, package-lock.json, npm-shrinkwrap.json.'); +} + +// Normalize only API-supplied lockfile bytes in an isolated directory. Never +// execute code from the target checkout or pass the GitHub token to a child. +export function normalizeLockfile(source, lockfile) { + validateLockfile(lockfile); + const work = mkdtempSync(join(tmpdir(), 'sfw-fix-')); + try { + const path = join(work, lockfile); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, source); + try { + execFileSync('bash', [script], { + env: { + PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ''}`, + GITHUB_WORKSPACE: work, + GITHUB_OUTPUT: join(work, 'output'), + SFW_SCRUB_LOCKFILE: lockfile, + SFW_SCRUB_MODE: 'apply', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { + throw new ActionError('Lockfile normalization failed; no branch changes were made. Check the lockfile format and supported URL fields.'); + } + return readFileSync(path, 'utf8'); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +// Nonrecursive tree traversal preserves file modes and refuses symlinks or +// submodules rather than following the Contents API's symlink resolution. +async function readLockfile(api, repository, sha, lockfile) { + const prefix = `repos/${repository}/git`; + const commit = await api(`${prefix}/commits/${sha}`); + requireValue(shaPattern.test(commit.tree?.sha), 'GitHub returned an invalid commit tree.'); + let treeSha = commit.tree.sha; + const parts = lockfile.split('/'); + for (let i = 0; i < parts.length; i++) { + const tree = await api(`${prefix}/trees/${treeSha}`); + requireValue(tree.truncated === false && Array.isArray(tree.tree), 'GitHub tree listing is incomplete.'); + const entry = tree.tree.find((item) => item.path === parts[i]); + requireValue(entry && shaPattern.test(entry.sha), 'Selected lockfile does not exist at the PR head.'); + if (i < parts.length - 1) { + requireValue(entry.type === 'tree' && entry.mode === '040000', 'Lockfile parents must be directories, not symlinks or submodules.'); + treeSha = entry.sha; + continue; + } + requireValue(entry.type === 'blob' && entry.mode === '100644', 'Selected lockfile must be a regular, non-executable file.'); + const blob = await api(`${prefix}/blobs/${entry.sha}`); + requireValue(blob.encoding === 'base64' && typeof blob.content === 'string', 'GitHub did not return lockfile content.'); + const bytes = Buffer.from(blob.content, 'base64'); + requireValue(bytes.length === blob.size, 'GitHub lockfile content is incomplete.'); + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + requireValue(!source.includes('\0'), 'Binary lockfiles are not supported.'); + return source; + } +} + +export async function fixPullRequest({ eventName, repository, event, mode = 'fix', lockfile = 'bun.lock' }, api) { + requireValue(['fix', 'check'].includes(mode), 'mode must be fix or check.'); + validateLockfile(lockfile); + requireValue(eventName === 'pull_request', 'lockfile-scrub requires a pull_request event; pull_request_target and other triggers are refused.'); + requireValue(repoPattern.test(repository), 'Invalid GitHub repository context.'); + const expected = event?.pull_request; + requireValue(expected && Number.isSafeInteger(expected.number) && expected.number > 0, 'Missing pull request context.'); + requireValue(expected.base?.repo?.full_name === repository && shaPattern.test(expected.head?.sha), 'Invalid pull request repository or head SHA.'); + + const pr = await api(`repos/${repository}/pulls/${expected.number}`); + requireValue(pr.state === 'open' && pr.base?.repo?.full_name === repository, 'Pull request is no longer open in this repository.'); + requireValue(pr.head?.sha === expected.head.sha && pr.head?.ref === expected.head.ref && pr.head?.repo?.full_name === expected.head.repo?.full_name, 'PR head changed; use the newest pull_request run. No branch was modified.'); + const sourceRepository = pr.head.repo.full_name; + requireValue(repoPattern.test(sourceRepository), 'Invalid PR source repository.'); + const sameRepository = sourceRepository === repository; + const metadata = await api(`repos/${repository}`); + requireValue(typeof metadata.default_branch === 'string' && metadata.default_branch, 'Cannot determine the default branch.'); + requireValue(mode !== 'fix' || !sameRepository || pr.head.ref !== metadata.default_branch, 'Refusing to modify the default branch.'); + + const source = await readLockfile(api, sourceRepository, pr.head.sha, lockfile); + const normalized = normalizeLockfile(source, lockfile); + const changed = normalized !== source; + if (!changed || mode === 'check') return { changed, commitSha: '' }; + requireValue(sameRepository, 'Fork lockfile needs repair. Regenerate it using the public registry and commit it to your fork; this action never writes to forks.'); + + // Atomic compare-and-swap: GitHub refuses if the branch advanced after the + // read. No force push, branch reset, retry, checkout, staging, or Git hooks. + const response = await api('graphql', { + query: commitMutation, + variables: { + input: { + branch: { repositoryNameWithOwner: repository, branchName: pr.head.ref }, + expectedHeadOid: pr.head.sha, + message: { headline: 'chore: normalize lockfile registry URLs' }, + fileChanges: { additions: [{ path: lockfile, contents: Buffer.from(normalized).toString('base64') }] }, + }, + }, + }); + const commitSha = response.data?.createCommitOnBranch?.commit?.oid; + requireValue(!response.errors?.length && shaPattern.test(commitSha), 'GitHub rejected the repair commit. Check contents:write, branch protection, or a newer PR head; no retry or force push was attempted.'); + return { changed: true, commitSha }; +} + +export function githubApi(token, apiUrl, graphqlUrl) { + requireValue(token, 'GITHUB_TOKEN is required. Allow contents:write for automatic branch repair.'); + const base = new URL(apiUrl); + const graphql = new URL(graphqlUrl); + requireValue(base.protocol === 'https:' && graphql.protocol === 'https:' && base.origin === graphql.origin, 'GitHub API endpoints must share an HTTPS origin.'); + return async (path, body) => { + const url = path === 'graphql' ? graphql : new URL(`${base.href.replace(/\/$/, '')}/${path}`); + let response; + try { + response = await fetch(url, { + method: body ? 'POST' : 'GET', + headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, + body: body ? JSON.stringify(body) : undefined, + redirect: 'error', + signal: AbortSignal.timeout(30000), + }); + } catch { + throw new ActionError('GitHub request failed; inspect the PR branch before retrying. A commit request may already have succeeded.'); + } + requireValue(response.ok, `GitHub request failed (${response.status}); check token permissions and branch protection. No automatic retry was attempted.`); + return response.json(); + }; +} + +async function main() { + try { + requireValue(process.env.GITHUB_OUTPUT, 'GITHUB_OUTPUT is required.'); + appendFileSync(process.env.GITHUB_OUTPUT, ''); + const result = await fixPullRequest({ + eventName: process.env.GITHUB_EVENT_NAME, + repository: process.env.GITHUB_REPOSITORY, + event: JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')), + mode: process.env.INPUT_MODE || 'fix', + lockfile: process.env.INPUT_LOCKFILE || 'bun.lock', + }, githubApi(process.env.INPUT_TOKEN, process.env.GITHUB_API_URL, process.env.GITHUB_GRAPHQL_URL)); + appendFileSync(process.env.GITHUB_OUTPUT, `changed=${result.changed}\ncommit-sha=${result.commitSha}\n`); + const summary = result.commitSha + ? `Committed lockfile repair to the PR branch: ${result.commitSha}. GITHUB_TOKEN commits do not trigger another Actions run.` + : result.changed ? 'Lockfile needs repair (check mode; no commit created).' : 'Lockfile is already clean; no commit created.'; + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } catch (error) { + // Only our own controlled errors are shown. Parser/filesystem errors can + // echo lockfile content, URLs or caller-supplied paths. + const safe = error instanceof ActionError ? error.message : 'Invalid action context or lockfile data.'; + console.error(`::error::${safe.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')}`); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main(); diff --git a/scripts/fix-lockfile.test.mjs b/scripts/fix-lockfile.test.mjs new file mode 100644 index 0000000..ddbe06f --- /dev/null +++ b/scripts/fix-lockfile.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +const { fixPullRequest, githubApi, validateLockfile } = await import(process.env.SFW_TEST_FIX_MODULE || './fix-lockfile.mjs'); + +const repository = 'workos/example'; +const head = 'a'.repeat(40); +const next = 'b'.repeat(40); +const rootTree = 'c'.repeat(40); +const nestedTree = 'd'.repeat(40); +const blobSha = 'e'.repeat(40); +const firewall = 'https://socket-firewall.workos.dev/'; +const npm = 'https://registry.npmjs.org/'; +const npmSource = JSON.stringify({ lockfileVersion: 3, packages: { 'node_modules/pkg': { version: '1.0.0', resolved: firewall + 'pkg/-/pkg-1.0.0.tgz', integrity: 'sha512-retain' } } }, null, 2); +const bunSource = '{\n"packages": {"pkg": ["pkg@1.0.0", "' + firewall + 'pkg/-/pkg-1.0.0.tgz", {}, "sha512-retain"]}\n}\n'; + +function fixture({ source = npmSource, lockfile = 'package-lock.json', sourceRepository = repository, branch = 'fix-lock', mode = 'fix' } = {}) { + const event = { pull_request: { number: 10, base: { repo: { full_name: repository } }, head: { sha: head, ref: branch, repo: { full_name: sourceRepository } } } }; + const config = { eventName: 'pull_request', repository, event, lockfile, mode }; + const state = { pr: { ...structuredClone(event.pull_request), state: 'open' }, source, calls: [], mutations: [], fileMode: '100644', parentMode: '040000', truncated: false, beforeCommit: undefined, refuseCommit: false }; + const api = async (path, body) => { + state.calls.push(path); + if (path === `repos/${repository}/pulls/10`) return structuredClone(state.pr); + if (path === `repos/${repository}`) return { default_branch: 'main' }; + if (path === `repos/${sourceRepository}/git/commits/${state.pr.head.sha}`) return { tree: { sha: rootTree } }; + if (path === `repos/${sourceRepository}/git/trees/${rootTree}`) { + const nested = lockfile.includes('/'); + return { truncated: state.truncated, tree: [{ path: lockfile.split('/')[0], mode: nested ? state.parentMode : state.fileMode, type: nested ? 'tree' : 'blob', sha: nested ? nestedTree : blobSha }] }; + } + if (path === `repos/${sourceRepository}/git/trees/${nestedTree}`) return { truncated: false, tree: [{ path: lockfile.split('/')[1], type: 'blob', mode: state.fileMode, sha: blobSha }] }; + if (path === `repos/${sourceRepository}/git/blobs/${blobSha}`) return { encoding: 'base64', size: Buffer.byteLength(state.source), content: Buffer.from(state.source).toString('base64') }; + if (path === 'graphql') { + state.mutations.push(body); + state.beforeCommit?.(); + if (state.refuseCommit || state.pr.head.sha !== body.variables.input.expectedHeadOid) return { errors: [{ message: 'denied or stale head' }] }; + state.source = Buffer.from(body.variables.input.fileChanges.additions[0].contents, 'base64').toString(); + state.pr.head.sha = next; + return { data: { createCommitOnBranch: { commit: { oid: next } } } }; + } + throw new Error(`Unexpected request ${path}`); + }; + return { config, state, api }; +} + +for (const lockfile of ['bun.lock', 'package-lock.json', 'npm-shrinkwrap.json', 'nested path/package-lock.json']) { + test(`default fix owns normalization AND branch commit: ${lockfile}`, async () => { + const source = lockfile === 'bun.lock' ? bunSource : npmSource; + const f = fixture({ lockfile, source }); + delete f.config.mode; + const result = await fixPullRequest(f.config, f.api); + assert.deepEqual(result, { changed: true, commitSha: next }); + assert.equal(f.state.mutations.length, 1); + const request = f.state.mutations[0].variables.input; + assert.deepEqual(request.branch, { repositoryNameWithOwner: repository, branchName: 'fix-lock' }); + assert.equal(request.expectedHeadOid, head); + assert.equal(request.fileChanges.additions.length, 1); + assert.equal(request.fileChanges.additions[0].path, lockfile); + assert.equal(request.fileChanges.deletions, undefined); + const expected = lockfile === 'bun.lock' ? source.replace(`"${firewall}pkg/-/pkg-1.0.0.tgz"`, '""') : source.replace(firewall, npm); + assert.equal(f.state.source, expected); + assert.equal(f.state.pr.head.sha, next); + // New event at the repaired head produces no follow-up commit. + f.config.event.pull_request.head.sha = next; + assert.deepEqual(await fixPullRequest(f.config, f.api), { changed: false, commitSha: '' }); + assert.equal(f.state.mutations.length, 1); + }); +} + +test('explicit check mode reports dirty without writing', async () => { + const f = fixture({ mode: 'check' }); + assert.deepEqual(await fixPullRequest(f.config, f.api), { changed: true, commitSha: '' }); + assert.equal(f.state.mutations.length, 0); + assert.equal(f.state.source, npmSource); +}); + +test('clean default fix creates no commit', async () => { + const f = fixture({ source: npmSource.replace(firewall, npm) }); + assert.deepEqual(await fixPullRequest(f.config, f.api), { changed: false, commitSha: '' }); + assert.equal(f.state.mutations.length, 0); +}); + +for (const dirty of [false, true]) { + test(`fork is read-only, ${dirty ? 'dirty fails with repair guidance' : 'clean succeeds'}`, async () => { + const f = fixture({ sourceRepository: 'contributor/example', source: dirty ? npmSource : npmSource.replace(firewall, npm) }); + if (dirty) await assert.rejects(fixPullRequest(f.config, f.api), /never writes to forks/); + else assert.deepEqual(await fixPullRequest(f.config, f.api), { changed: false, commitSha: '' }); + assert.equal(f.state.mutations.length, 0); + assert.ok(f.state.calls.some((path) => path.startsWith('repos/contributor/example/git/'))); + }); +} + +for (const eventName of ['push', 'workflow_dispatch', 'pull_request_target', 'workflow_run']) { + test(`unsupported trigger ${eventName} fails before API access`, async () => { + const f = fixture(); + f.config.eventName = eventName; + await assert.rejects(fixPullRequest(f.config, f.api), /pull_request event/); + assert.equal(f.state.calls.length, 0); + }); +} + +test('default branch, closed PR, or stale event cannot be modified', async () => { + for (const kind of ['default', 'closed', 'stale', 'retargeted']) { + const f = fixture({ branch: kind === 'default' ? 'main' : 'fix-lock' }); + if (kind === 'closed') f.state.pr.state = 'closed'; + if (kind === 'stale') f.state.pr.head.sha = next; + if (kind === 'retargeted') f.state.pr.head.ref = 'another-branch'; + await assert.rejects(fixPullRequest(f.config, f.api)); + assert.equal(f.state.mutations.length, 0); + assert.equal(f.state.source, npmSource); + } +}); + +test('head advancing during transformation is rejected atomically, never retried', async () => { + const f = fixture(); + f.state.beforeCommit = () => { f.state.pr.head.sha = next; }; + await assert.rejects(fixPullRequest(f.config, f.api), /rejected the repair commit/); + assert.equal(f.state.mutations.length, 1); + assert.equal(f.state.source, npmSource); + assert.equal(f.state.pr.head.sha, next); +}); + +test('denied writes fail without pretending repair succeeded', async () => { + const f = fixture(); + f.state.refuseCommit = true; + await assert.rejects(fixPullRequest(f.config, f.api), /contents:write/); + assert.equal(f.state.mutations.length, 1); + assert.equal(f.state.source, npmSource); +}); + +for (const mode of ['120000', '160000', '100755']) { + test(`file mode ${mode} cannot be repaired`, async () => { + const f = fixture(); + f.state.fileMode = mode; + await assert.rejects(fixPullRequest(f.config, f.api), /regular, non-executable/); + assert.equal(f.state.mutations.length, 0); + }); +} + +test('symlinked parent and truncated tree fail without commits', async () => { + const f = fixture({ lockfile: 'nested/package-lock.json' }); + f.state.parentMode = '120000'; + await assert.rejects(fixPullRequest(f.config, f.api), /parents must be directories/); + f.state.parentMode = '040000'; + f.state.truncated = true; + await assert.rejects(fixPullRequest(f.config, f.api), /incomplete/); + assert.equal(f.state.mutations.length, 0); +}); + +test('malformed lockfile is not committed and error does not echo content', async () => { + const f = fixture({ source: '{"lockfileVersion":3,"secret":"do-not-log' }); + await assert.rejects(fixPullRequest(f.config, f.api), (error) => /normalization failed/.test(error.message) && !error.message.includes('do-not-log')); + assert.equal(f.state.mutations.length, 0); +}); + +for (const path of ['/package-lock.json', '../bun.lock', 'x/../bun.lock', 'x//bun.lock', 'bun.lock\n', 'x\\bun.lock', 'bun.lockb', 'yarn.lock', 'pnpm-lock.yaml']) { + test(`invalid path ${JSON.stringify(path)} is rejected`, () => assert.throws(() => validateLockfile(path))); +} + +test('action defaults to branch repair and supplies repository token, no checkout needed', () => { + const action = readFileSync(new URL('../lockfile-scrub/action.yml', import.meta.url), 'utf8'); + assert.match(action, /default: fix/); + assert.match(action, /default: \$\{\{ github.token \}\}/); + assert.match(action, /using: node24/); + assert.match(action, /main: ..\/scripts\/fix-lockfile.mjs/); + assert.doesNotMatch(action, /actions\/checkout/); +}); + +test('API rejects missing token, plaintext or cross-origin endpoints', () => { + assert.throws(() => githubApi('', 'https://api.github.com', 'https://api.github.com/graphql')); + assert.throws(() => githubApi('token', 'http://api.github.com', 'http://api.github.com/graphql')); + assert.throws(() => githubApi('token', 'https://api.github.com', 'https://elsewhere.test/graphql')); +}); diff --git a/scripts/scrub-lockfile.test.sh b/scripts/scrub-lockfile.test.sh index 68e6da3..256e2f4 100755 --- a/scripts/scrub-lockfile.test.sh +++ b/scripts/scrub-lockfile.test.sh @@ -152,10 +152,11 @@ test_unrecognized_bun_url_fails_without_mutation() { } test_action_wires_changed_output_and_script() { - assert_contains "$ACTION" 'value: ${{ steps.scrub.outputs.changed }}' - assert_contains "$ACTION" 'run: bash "$GITHUB_ACTION_PATH/../scripts/scrub-lockfile.sh"' + assert_contains "$ACTION" 'main: ../scripts/fix-lockfile.mjs' + assert_contains "$ACTION" 'using: node24' assert_contains "$ACTION" 'default: bun.lock' - assert_contains "$ACTION" 'SFW_SCRUB_LOCKFILE: ${{ inputs.lockfile }}' + assert_contains "$ACTION" 'default: fix' + assert_contains "$ACTION" 'default: ${{ github.token }}' } test_clean_lockfile_reports_unchanged From c66640c310f9c5e82acf11075ec130c828c74926 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:40:47 +0000 Subject: [PATCH 4/6] chore: normalize lockfile registry URLs --- .github/fixtures/lockfile-repair/bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/fixtures/lockfile-repair/bun.lock b/.github/fixtures/lockfile-repair/bun.lock index ba4d33e..28567e4 100644 --- a/.github/fixtures/lockfile-repair/bun.lock +++ b/.github/fixtures/lockfile-repair/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, "packages": { - "left-pad": ["left-pad@1.3.0", "https://socket-firewall.workos.dev/left-pad/-/left-pad-1.3.0.tgz", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="] + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="] } } From 89b8252c60e92a58435e83c930c186acf1dc0b6f Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Mon, 14 Sep 2026 08:41:43 -0400 Subject: [PATCH 5/6] test: remove verified live branch-repair trial The action created verified github-actions[bot] commit c66640c310f9c5e82acf11075ec130c828c74926 on PR #10, changing only the synthetic Bun lockfile. Live run 34844728085 passed. Keep the permanent controller, normalization and release tests. --- .github/fixtures/lockfile-repair/bun.lock | 6 --- .github/workflows/verify-lockfile-repair.yml | 41 -------------------- 2 files changed, 47 deletions(-) delete mode 100644 .github/fixtures/lockfile-repair/bun.lock delete mode 100644 .github/workflows/verify-lockfile-repair.yml diff --git a/.github/fixtures/lockfile-repair/bun.lock b/.github/fixtures/lockfile-repair/bun.lock deleted file mode 100644 index 28567e4..0000000 --- a/.github/fixtures/lockfile-repair/bun.lock +++ /dev/null @@ -1,6 +0,0 @@ -{ - "lockfileVersion": 1, - "packages": { - "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="] - } -} diff --git a/.github/workflows/verify-lockfile-repair.yml b/.github/workflows/verify-lockfile-repair.yml deleted file mode 100644 index 15721f0..0000000 --- a/.github/workflows/verify-lockfile-repair.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Verify lockfile branch repair (temporary) - -on: - pull_request: - paths: - - .github/workflows/verify-lockfile-repair.yml - - .github/fixtures/lockfile-repair/bun.lock - - lockfile-scrub/action.yml - - scripts/fix-lockfile.mjs - -permissions: - contents: read - -jobs: - repair: - if: github.event.pull_request.number == 10 && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - steps: - # Only needed to exercise the unreleased local action. Released consumers - # call the pinned action directly with no checkout step. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - uses: ./lockfile-scrub - id: repair - with: - lockfile: .github/fixtures/lockfile-repair/bun.lock - - name: Verify the action created its own single-file commit - if: steps.repair.outputs.changed == 'true' - env: - GH_TOKEN: ${{ github.token }} - COMMIT_SHA: ${{ steps.repair.outputs.commit-sha }} - run: | - set -euo pipefail - test -n "$COMMIT_SHA" - gh api "repos/$GITHUB_REPOSITORY/commits/$COMMIT_SHA" --jq '.files | map(.filename)' > "$RUNNER_TEMP/changed-files.json" - jq -e '. == [".github/fixtures/lockfile-repair/bun.lock"]' "$RUNNER_TEMP/changed-files.json" From dfe43cb2b98490d108eecea0b5e13369b6fb254b Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Mon, 14 Sep 2026 08:45:13 -0400 Subject: [PATCH 6/6] docs: reflect observed bot-commit CI approval behavior --- README.md | 2 +- scripts/fix-lockfile.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a66f04..aa2f46c 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Branch safety is enforced **inside the action**, not left to caller shell guards Use this action in a dedicated job on an ephemeral Linux runner supporting Node 24 JavaScript actions, with Bash/coreutils available. GitHub supplies the Node runtime. It never executes target-repository code. The token is used only for GitHub API requests and is not passed to the normalization subprocess. -`GITHUB_TOKEN` commits do not trigger another Actions run. Normalizing a lockfile does not establish that all application CI checks pass on the new commit. Repositories requiring fresh-head checks need a separately reviewed solution. Branch protection and token-policy denials fail the action; it does not bypass those controls. If several selected locks need repair, each commit advances the head, so use a subsequent PR event for the next repair rather than parallel writers using the same event SHA. +Do not rely on `GITHUB_TOKEN` commits to run follow-up CI automatically: GitHub can suppress those workflows or hold them for approval. The live branch-repair trial created `action_required` follow-up runs with no jobs executed. Normalizing a lockfile does not establish that all application CI checks pass on the new commit. Repositories requiring fresh-head checks need a separately reviewed solution. Branch protection and token-policy denials fail the action; it does not bypass those controls. If several selected locks need repair, each commit advances the head, so use a subsequent PR event for the next repair rather than parallel writers using the same event SHA. ### Package publication diff --git a/scripts/fix-lockfile.mjs b/scripts/fix-lockfile.mjs index 04224ed..6287df8 100644 --- a/scripts/fix-lockfile.mjs +++ b/scripts/fix-lockfile.mjs @@ -161,7 +161,7 @@ async function main() { }, githubApi(process.env.INPUT_TOKEN, process.env.GITHUB_API_URL, process.env.GITHUB_GRAPHQL_URL)); appendFileSync(process.env.GITHUB_OUTPUT, `changed=${result.changed}\ncommit-sha=${result.commitSha}\n`); const summary = result.commitSha - ? `Committed lockfile repair to the PR branch: ${result.commitSha}. GITHUB_TOKEN commits do not trigger another Actions run.` + ? `Committed lockfile repair to the PR branch: ${result.commitSha}. Do not assume follow-up CI runs automatically: GitHub may suppress it or require approval for a GITHUB_TOKEN commit.` : result.changed ? 'Lockfile needs repair (check mode; no commit created).' : 'Lockfile is already clean; no commit created.'; console.log(summary); if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);