diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2516d9c..e7badef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 - name: Check shell style and test coverage policy run: | set -euo pipefail @@ -29,12 +32,92 @@ jobs: - name: Run shell tests run: | set -euo pipefail - bash -n scripts/*.sh + for source in scripts/*.sh; do bash -n "$source"; done bash scripts/configure.test.sh bash scripts/teardown.test.sh + bash scripts/scrub-lockfile.test.sh + 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 + 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 + - name: Check normalization helper + id: check + 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 }} + LOCKFILE: ${{ matrix.lockfile }} + run: | + test "$CHANGED" = true + cmp scrub-smoke/before "scrub-smoke/$LOCKFILE" + - name: Apply normalization helper + id: 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: + 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" + - name: Recheck normalization helper + id: recheck + 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 }} + run: test "$CHANGED" = false + check-secret: name: Check public secret availability runs-on: ubuntu-22.04 @@ -205,6 +288,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' && steps.sfw.outputs.active == 'true' + 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..c70d0b9 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 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` @@ -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 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. 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 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 1ebadae..aa2f46c 100644 --- a/README.md +++ b/README.md @@ -4,10 +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` — 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. @@ -79,6 +80,52 @@ 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 and npm lockfile cleanup + +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. + +| 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 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 +name: Repair lockfile registry URLs +on: + pull_request: + paths: [bun.lock] + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: workos/setup-socket-firewall/lockfile-scrub@ # v1 +``` + +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. + +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 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..7d26695 --- /dev/null +++ b/lockfile-scrub/action.yml @@ -0,0 +1,31 @@ +name: Fix Socket Firewall lockfile URLs +description: >- + Normalize a Bun or npm lockfile and commit the repair directly to the + same-repository pull request branch using GITHUB_TOKEN. +inputs: + lockfile: + description: >- + 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: >- + 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: ${{ github.token }} +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: node24 + main: ../scripts/fix-lockfile.mjs diff --git a/release-manifest.txt b/release-manifest.txt index 5cebab8..6342986 100644 --- a/release-manifest.txt +++ b/release-manifest.txt @@ -2,4 +2,8 @@ LICENSE action.yml 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 7f80590..354fe23 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -105,6 +105,9 @@ 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" +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 6822d34..917261e 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" - cp "$ROOT/action.yml" "$ROOT/release-manifest.txt" "$destination/" + mkdir -p "$destination/scripts" "$destination/teardown" "$destination/lockfile-scrub" + cp "$ROOT/LICENSE" "$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" "$ROOT/scripts/scrub-npm-lockfile.mjs" "$ROOT/scripts/fix-lockfile.mjs" "$destination/scripts/" } "${ROOT}/scripts/build-release.sh" "${CASE_DIR}/release" >/dev/null @@ -74,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" @@ -83,14 +92,26 @@ 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/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 [[ -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 '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" +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/fix-lockfile.mjs b/scripts/fix-lockfile.mjs new file mode 100644 index 0000000..6287df8 --- /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}. 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`); + } 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.sh b/scripts/scrub-lockfile.sh new file mode 100755 index 0000000..5e91125 --- /dev/null +++ b/scripts/scrub-lockfile.sh @@ -0,0 +1,83 @@ +#!/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 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' +workspace="$(cd "$workspace" && pwd -P)" + +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 + +# 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 + +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 cmp -s "$lockfile" "$temporary"; then + printf 'changed=false\n' >>"$GITHUB_OUTPUT" + exit 0 +fi + +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 new file mode 100755 index 0000000..256e2f4 --- /dev/null +++ b/scripts/scrub-lockfile.test.sh @@ -0,0 +1,172 @@ +#!/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" + unset SFW_SCRUB_LOCKFILE +} + +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_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" 'main: ../scripts/fix-lockfile.mjs' + assert_contains "$ACTION" 'using: node24' + assert_contains "$ACTION" 'default: bun.lock' + assert_contains "$ACTION" 'default: fix' + assert_contains "$ACTION" 'default: ${{ github.token }}' +} + +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_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'))); +});