Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.

Expand Down
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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@<FULL_40_CHARACTER_V1_SHA> # 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.
Expand Down
31 changes: 31 additions & 0 deletions lockfile-scrub/action.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions release-manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions scripts/build-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)" ]] || {
Expand Down
29 changes: 25 additions & 4 deletions scripts/build-release.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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'
Loading
Loading