Skip to content

Icex0/packjail

Repository files navigation

packjail

Release GitHub stars GitHub issues Go version License Status

packjail runs npm, pnpm, yarn, and bun installs inside a sandbox so package install scripts can do the work they need without getting broad access to your machine. It keeps package installs usable while making credential theft, surprise downloads, and outbound callbacks to untrusted hosts fail.

packjail blocking a malicious install script

What it does:

  • Sandboxes install commands for npm, pnpm, Yarn, and Bun.
  • Runs install scripts with project-only filesystem access and a temporary home directory.
  • Denies network egress by default, except for built-in package registry hosts and hosts you explicitly allow.
  • Applies a 48-hour minimum package age gate by default; opt out per install with --skip-min-package-age, or change it with --min-package-age=<duration>.
  • Provides optional runtime containment with packjail run, packjail dev, and packjail shell.
  • Supports host sandboxes on macOS/Linux/WSL2 and an experimental OCI container backend with Docker or Podman.

Why?

npm supply-chain attacks often rely on install scripts: read ~/.npmrc, ~/.ssh, cloud credentials, or wallet files; download a second-stage payload; then beacon to an attacker-controlled host. A tool like packjail would have broken the install-time mechanics used by incidents such as eslint-scope, ua-parser-js, coa/rc, TeamPCP / Mini Shai-Hulud credential-stealing waves such as the May 2026 TanStack compromise, PhantomRaven remote tarballs, and Bun downloader payloads.

packjail is containment, not malware detection. It does not decide whether a package is good or bad; it limits what install scripts can touch and where they can connect.

Example attack flow

flowchart LR
    A["Package request: latest / semver range / lockfile"] --> B{"Version newer than 48h?"}
    B -->|yes, range or latest| B1["Fall back to newest eligible older version"]
    B -->|yes, exact or lockfile| B2["Block install before scripts run"]
    B -->|no| C["Install selected package"]

    C --> D["preinstall / postinstall runs"]
    D --> E["Try to read credentials"]
    D --> F["Try to download second-stage payload"]
    D --> G["Try to contact cloud metadata"]
    D --> H["Try to publish more malware"]
    D --> I["Try to exfiltrate secrets"]

    E -. blocked .-> E1["Real HOME, ~/.npmrc, ~/.ssh, cloud config not mounted"]
    F -. blocked .-> F1["Only registry and explicitly allowed hosts are reachable"]
    G -. blocked .-> G1["169.254.169.254 is unreachable"]
    H -. blocked by default .-> H1["No npm auth token is mounted or forwarded"]
    I -. blocked .-> I1["Attacker hosts are denied by the proxy"]

    subgraph PackJail["packjail controls"]
      B1
      B2
      E1
      F1
      G1
      H1
      I1
    end

    style D fill:#3d1f24,color:#fff,stroke:#ff7b72
    style E fill:#3d1f24,color:#fff,stroke:#ff7b72
    style F fill:#3d1f24,color:#fff,stroke:#ff7b72
    style G fill:#3d1f24,color:#fff,stroke:#ff7b72
    style H fill:#3d1f24,color:#fff,stroke:#ff7b72
    style I fill:#3d1f24,color:#fff,stroke:#ff7b72
    style B fill:#1f2937,color:#fff,stroke:#9ca3af
    style PackJail fill:#0d1117,stroke:#1f6feb,color:#c9d1d9
Loading

Requirements

Platform Requirement
Linux bubblewrap (bwrap), install via your distro's package manager
macOS sandbox-exec (ships with macOS)
Windows WSL2 with packjail installed inside the distro

Optional container/runtime containment:

Feature Requirement
--backend=container, packjail run, packjail dev, packjail shell Docker or Podman

Install the Linux sandbox dependency with your distro package manager:

sudo apt install bubblewrap        # Debian / Ubuntu
sudo dnf install bubblewrap        # Fedora
sudo pacman -S bubblewrap          # Arch

macOS already ships sandbox-exec. On Windows, install WSL2 with wsl --install, then install packjail inside the distro using the Linux instructions above. Without WSL2, packjail refuses to run.

To build from source: Go 1.22 or newer.

Installation

One-line installer

Unix/Linux/macOS:

curl -fsSL https://github.com/Icex0/packjail/releases/latest/download/install-packjail.sh | sh

Windows PowerShell:

iex (iwr "https://github.com/Icex0/packjail/releases/latest/download/install-packjail.ps1" -UseBasicParsing)

To pin a specific version, replace latest with the version tag:

curl -fsSL https://github.com/Icex0/packjail/releases/download/v0.1.0/install-packjail.sh | sh
iex (iwr "https://github.com/Icex0/packjail/releases/download/v0.1.0/install-packjail.ps1" -UseBasicParsing)

The Unix installer downloads packjail-<os>-<arch> and installs it to /usr/local/bin by default. Set PACKJAIL_INSTALL_DIR to install elsewhere. The PowerShell installer downloads packjail-windows-<arch>.exe, installs it under %LOCALAPPDATA%\Programs\packjail, and adds that directory to the current user's PATH.

After installation, run:

packjail setup

Then restart your shell, or source your shell profile, so normal npm install, pnpm install, yarn install, and bun install commands are wrapped automatically.

Build and install from source

git clone https://github.com/Icex0/packjail.git
cd packjail
make build
sudo install -m 0755 packjail /usr/local/bin/packjail

Or, without make:

go install github.com/Icex0/packjail/cmd/packjail@latest

This puts the binary at $(go env GOPATH)/bin/packjail. Add that directory to your PATH if it isn't already.

Usage

Make installs sandboxed by default

To sandbox normal install commands automatically, run:

packjail setup

This updates ~/.bashrc and ~/.zshrc on macOS/Linux, or the current-user PowerShell profile locations on Windows, with marked shell functions for npm, pnpm, yarn, and bun. Restart your shell afterwards.

The wrapper is deliberately conservative. It auto-sandboxes install-like commands only:

Tool Auto-wrapped commands
npm install, i, ci, add, update, up, rebuild, dedupe
pnpm install, i, add, update, up, ci
yarn bare yarn, install, add, upgrade
bun install, i, add, update, upgrade

When a wrapper fires, npm install ... becomes packjail npm install .... Arguments after npm install still belong to npm, not packjail:

npm install lodash --save-dev
bun install --frozen-lockfile

To pass packjail flags, call packjail explicitly:

packjail --allow download.cypress.io npm install
packjail --allow-registry-auth npm ci
packjail --backend container bun install
packjail --timeout 10m npm install

For project-wide network exceptions, prefer .packjail-allow; wrapped commands read it automatically.

Runtime commands are not wrapped implicitly. Use packjail run ..., packjail dev ..., or packjail shell when you want runtime containment.

To bypass the wrapper for a single call, use command npm ..., command bun ..., and so on. To remove the shell integration:

packjail teardown

Direct invocation

packjail npm ci
packjail npm install lodash
packjail pnpm add react
packjail --allow download.cypress.io npm install
packjail --verbose npm ci
packjail --dry-run npm ci
packjail run bun test
packjail dev --port 3000 bun run dev
packjail shell
packjail setup
packjail teardown

run, dev, and shell use the container backend by default. They are the runtime-sandboxing interface: commands run in the same container sandbox shape as installs. dev publishes only explicitly requested ports, bound to 127.0.0.1 on the host.

setup installs the shell wrappers described above. teardown removes them.

Flags (must come before the package manager command):

Flag Meaning
--allow-user-npmrc Mount ~/.npmrc into the sandbox read-only. Use this if your registry auth token is stored there as a literal.
--allow-registry-auth Pass NPM_TOKEN, NODE_AUTH_TOKEN, GITHUB_TOKEN, and GH_TOKEN through to the sandbox. Required for installs from a private npm registry, GitHub Packages, or git+https private GitHub repos. See "Private registries" below; the token must be read-only.
--allow-insecure-network Disable backend network isolation where supported. Intended for debugging compatibility issues; malicious scripts can ignore proxy environment variables or direct bridge networking.
--backend <name> Select host, container, or auto. auto currently means host. The container backend is experimental but enforces proxy-only networking by default using an internal container network and proxy sidecar.
--allow <hostname> Add a hostname to the proxy allowlist for this invocation only. Repeatable.
--timeout <duration> Kill the install after a duration such as 30m, 10m, or 90s. Defaults to 30m.
--min-package-age <duration> Avoid public npm registry package versions published more recently than this age. Defaults to 48h; use 0 to disable.
--skip-min-package-age Disable minimum package age checks for this invocation.
--verbose Print sandbox setup details (mounts, proxy address, generated profile).
--dry-run Print what would be executed and exit 0.
--version Print the packjail version.
--help Show the help text.

Runtime command options:

Command Meaning
run <command> Run a build/test/runtime command in the container sandbox.
dev --port <port> <command> Run a long-lived dev command and publish a loopback-only port. Repeat --port; use 3000 or 5173:3000.
shell Start a basic shell in the container sandbox.

Configuration

Default-allowed hosts

Network egress is denied by default. Install scripts can only reach hosts that are in packjail's allowlist. The effective allowlist is built from the defaults below, hosts in .packjail-allow, and any --allow <hostname> flags for that invocation.

The canonical npm registry and these ecosystem registry/download hosts are allowed by default:

Host Used for
registry.npmjs.org npm (and pnpm) default
registry.yarnpkg.com Yarn 1 default; Facebook-operated mirror of npm
repo.yarnpkg.com Yarn Berry default
codeload.github.com GitHub's tarball endpoint, used by npm/pnpm for github:org/repo and git+https://github.com/...#commit deps

These are unconditional defaults; you don't need to add them.

Project .npmrc files do not expand the packjail network allowlist. If your project uses a private or custom registry, explicitly trust that host with .packjail-allow or --allow <hostname>.

Minimum package age

By default, packjail avoids public npm registry package versions published in the last 48 hours. This gives newly published malware time to be reported or removed before install scripts can run.

packjail --min-package-age 24h npm install
packjail --min-package-age 0 npm install
packjail --skip-min-package-age npm install

For normal latest and semver-range installs, packjail asks the package manager to resolve only versions older than the configured age, so npm, pnpm, Bun, and Yarn Berry >=4.10 can fall back to the newest eligible version instead of failing. Yarn Classic does not support native age-gated resolution, so packjail checks those range/tag specs itself and blocks if the selected public npm version is too new. For exact versions where there is no safe fallback, packjail preflights command arguments, direct dependencies in package.json, and exact versions found in package-lock.json, pnpm-lock.yaml, yarn.lock, and Bun's text bun.lock. Packages fetched from private/custom registries are not age-checked by this public npm registry preflight; use pinned lockfiles and explicit allowlists for those.

After a successful install, packjail prints a short minimum-age summary. If all checked latest/range requests were eligible, it says so; if a fresh latest was avoided, it names the package and the older version that was installed.

Per-project allowlist

Create a file .packjail-allow at your project root with one hostname per line. # introduces a comment; *.example.com matches any subdomain (but not the bare suffix).

# Cypress downloads its binary from this CDN
download.cypress.io

# Allow any subdomain
*.cypress.io

Hosts you'll commonly need to opt in to

Real-world projects often depend on package-bundled native binaries or browsers that are downloaded at install time from non-registry CDNs. Add to .packjail-allow per project:

# Puppeteer downloads Chromium from Google Cloud Storage. Note: this
# whitelists ALL of GCS, not just Puppeteer's bucket. If you only need
# Puppeteer to work, that is the trade. Otherwise pin to your own bucket.
storage.googleapis.com

# Playwright downloads browser binaries (microsoft.com subdomains)
cdn.playwright.dev
playwright.download.prss.microsoft.com

# Many native modules download a prebuild from GitHub releases
github.com
objects.githubusercontent.com

# Node.js version probes used by some tooling
nodejs.org

# Homebrew formula probes (Sharp, node-gyp build env checks).
# Optional. Blocking it is fine; installs that use it fall back
# to bundled prebuilds.
# formulae.brew.sh

# Used by some yarn/pnpm projects fetching prebuilt binaries from a
# project-specific S3 bucket. Replace with your actual bucket name.
# my-project-binaries.s3.amazonaws.com

An allowed host is fully trusted: a compromised package can POST, PUT, or DELETE to it as easily as GET.

Private registries

packjail strips env vars matching TOKEN/SECRET/KEY/ PASSWORD/CREDENTIAL by default, which breaks the standard ${NPM_TOKEN}-in-.npmrc pattern. Pass --allow-registry-auth to opt four standardized names back in (NPM_TOKEN, NODE_AUTH_TOKEN, GITHUB_TOKEN, GH_TOKEN):

packjail --allow-registry-auth npm ci

If your private registry is not one of the default hosts above, also add it explicitly:

packjail --allow-registry-auth --allow registry.company.example npm ci

or put registry.company.example in .packjail-allow.

Use a read-only token. A leaked publish-capable token in the sandbox is a working npm publish credential.

  • npm: granular access token with Read-only permission, scoped to specific packages.
  • GitHub Packages / git+https: fine-grained PAT with read:packages (and Contents: read for private repos). Not classic PATs.

Threat coverage

Backends

packjail currently has two execution backends:

  • --backend=host (default via auto) runs the host package manager inside the platform sandbox. This produces host-native node_modules and is the most compatible path for normal local development, but it is the harder security boundary: the sandbox must expose just enough of the host toolchain for Node/npm/pnpm/yarn/bun, git, curl, shells, native builds, and package manager helpers to work.

  • --backend=container runs the command in an OCI-compatible container using Docker or Podman. This is the intended direction for stronger install and runtime containment, because the filesystem, process tree, home directory, tmp, and runtime image are explicit.

    In secure mode, the workload container is attached only to an internal container network. A trusted proxy sidecar on that network forwards HTTP_PROXY / HTTPS_PROXY traffic to packjail's host-side filtering proxy; direct sockets from the workload have no routed egress.

    Container runtime and image knobs:

    Setting Meaning
    PACKJAIL_RUNTIME=podman / docker Force a container runtime.
    PACKJAIL_IMAGE Override the workload image.
    PACKJAIL_PROXY_IMAGE Override the proxy sidecar image. Defaults to docker.io/alpine/socat:latest.
    PACKJAIL_VOLUME_LABEL=z, Z, or disable Override SELinux bind-mount labeling behavior.

    Default workload images:

    Command Image
    npm / pnpm / yarn docker.io/library/node:22-bookworm
    bun docker.io/oven/bun:1

    The npm/pnpm/yarn image uses the full Debian Node image rather than node:*-slim because real install scripts commonly assume curl, git, ssh, Python, and a basic native build toolchain exist. Image names are fully qualified so Podman does not need interactive short-name resolution.

    Linux notes:

    • On SELinux-enforcing hosts such as Fedora, Podman bind mounts are labeled with :Z by default so the container can read/write the project directory.
    • The host-side filtering proxy binds a temporary bridge-reachable TCP listener so Docker/Podman containers can reach it via host.docker.internal / host.containers.internal. The proxy still enforces the packjail allowlist, but the container runtime and local host should be treated as trusted for the duration of the command.

The long-term recommended container model is: edit source files on the host, run install/build/test/dev commands through packjail inside the same container environment, and avoid treating container-installed node_modules as host-native artifacts.

The detailed tables below expand on the summary above. packjail does not stop malicious code that later runs in commands you execute outside packjail; use packjail run, packjail dev, or packjail shell when you want build, test, dev, or runtime containment too.

Detailed campaign coverage

The tables below cover real npm campaigns from 2018 through 2026. Each row says what the attack actually did and whether install-time sandboxing stops it.

Stopped

Campaign Year Mechanism it relies on Why we stop it
eslint-scope 2018 Maintainer with no 2FA; postinstall fetched a Pastebin script and eval'd it; the script read _authToken from ~/.npmrc and exfil'd via the Referer header to histats / statcounter ~/.npmrc not mounted; pastebin / histats / statcounter all blocked by the proxy
crossenv typosquat 2017–2018 Mimicked cross-env; install script base64'd process.env and POSTed it to an attacker server Names matching *TOKEN*/*SECRET*/*KEY*/*PASSWORD*/*CREDENTIAL* are stripped from the env passed to the child; the POST destination isn't in the allowlist anyway
ua-parser-js hijack 2021 Account hijack; pre/postinstall script downloaded an XMRig Monero miner from an attacker host and ran it Miner download blocked by the proxy. Even if bundled, mining-pool egress is blocked too
coa / rc hijacks 2021 Account hijacks; identical password-stealer payloads via install scripts Browser profiles, keychain, ssh, env-var secrets, none mounted. Exfil destinations not in allowlist
Dependency confusion (Birsan) 2021 Internal package names registered on public npm; npm pulled the public attacker version; PoC payload was a postinstall HTTP callback Stops the install-time PoC: the callback is blocked by proxy egress filtering. (See "Not stopped"; the broader class isn't fully covered if the payload defers to app runtime)
Lazarus npm campaigns 2023+ Typosquat packages steal credentials, download InvisibleFerret, target id.json (Solana) and exodus.wallet files Wallet directories not mounted; backdoor download and credential exfil both blocked
Nx s1ngularity Aug 2025 Postinstall steals GitHub/SSH/npm tokens + invokes claude --dangerously-skip-permissions, gemini --yolo, q to dump filesystem secrets via the AI CLIs Same credential paths blocked. AI CLIs run inside the sandbox with $HOME pointing at a fresh tmpfs, so they have no config / no auth and can't read /Users/... either
PhantomRaven (Remote Dynamic Dependencies) Aug 2025 → ongoing package.json declares a dep as "https://attacker.com/x.tgz"; npm fetches the tarball; postinstall runs the payload Proxy denies the fetch; the attacker host isn't a registry, isn't in .packjail-allow. npm install fails before the payload runs
Shai-Hulud / TeamPCP / Mini Shai-Hulud family, including the May 2026 TanStack npm compromise Sept 2025 – May 2026 Install-time malware reads ~/.npmrc, ~/.ssh, ~/.aws, GitHub/CI/cloud credentials, Kubernetes/Vault tokens, and cloud metadata; exfils to attacker-controlled or anonymous file-upload infrastructure; self-propagates by using stolen package-manager or CI credentials to publish more compromised packages Fresh malicious versions are blocked by the default 48-hour minimum package age policy. $HOME is a fresh temporary directory, so normal user credentials are not visible. 169.254.169.254 is unreachable. Exfil hosts such as GitHub API endpoints or Session/Oxen upload infrastructure are not in the default allowlist. ~/.npmrc is not mounted, so npm publish has no auth token. (If you opt in to --allow-registry-auth for private installs, use a read-only granular token; see the flag's documentation above)
Bun "Ghost Runtime" 2025–2026 Postinstall downloads Bun from bun.sh, executes payload via Bun (AV monitoring node.exe misses it) bun.sh isn't in the allowlist; proxy blocks the download. Even if Bun were bundled in the package, it inherits the same filesystem and network restrictions
Axios RAT (Sapphire Sleet / DPRK) Mar 2026 Injected plain-crypto-js dep with a postinstall that drops a cross-platform RAT and beacons to C2 C2 host is not in the allowlist; the proxy blocks the CONNECT. RAT can't call home
36-package Redis/Postgres implant campaign Apr 2026 Postinstall connects to local 127.0.0.1:5432 / :6379 to plant persistent SQL/Redis implants Sandbox network policy only permits 127.0.0.1:<proxy-port>. Other localhost ports are unreachable
Generic install-time credential theft via env vars continuous Reads AWS_SECRET_ACCESS_KEY, GH_TOKEN, NPM_TOKEN, … from process.env Env names matching *TOKEN* / *SECRET* / *KEY* / *PASSWORD* / *CREDENTIAL* are stripped before the child runs, regardless of allowlist

Not stopped

The package was installed through us, but the malicious behavior happens somewhere our sandbox isn't watching. These are the cases where install-time containment alone is insufficient.

Campaign / class Year Why we don't help
event-stream / flatmap-stream 2018 The malicious dep did nothing at install. It activated only when imported by Copay's bundler at build time and rewrote the bundle to harvest >100 BTC wallet keys at app runtime. The code lived in node_modules/ (writable by design), fired during npm run build (passthrough in the README's shell snippet), and the actual theft happened in end-user wallets. The canonical deferred-payload attack; threat-intel scanners are the right tool
node-ipc / peacenotwar 2022 Maintainer protestware. Geolocated by IP; on Russian/Belarusian addresses, wiped files at app runtime when the package was imported. Same deferred-payload class as event-stream. (The peacenotwar sub-dep's desktop-write is blocked at install; ~/Desktop is not mounted; but that's the harmless half)
chalk / debug wallet drainer Sept 2025 Malicious code runs in your end users' browsers when they load your shipped frontend. Nothing executes at install time. Sandboxing the install is irrelevant to a payload that activates weeks later in someone else's browser
Deferred runtime payloads (general class) continuous A postinstall can write into node_modules/foo/index.js or your project source. If you later run npm run build, npm start, npm test, or anything else outside packjail, that code executes unrestricted. Use packjail run ... / packjail dev ... to sandbox build, test, and dev commands too.
Slopsquatting / typosquatting / "the malware is just regular code" continuous If a package's malice lives in its normal index.js and only fires at app runtime, install-time sandboxing doesn't see it. Threat-intel territory
Project-local secret exfil to an allowed host continuous Files in your project root (.env, terraform.tfstate, etc.) are fully readable inside the sandbox. The proxy blocks attacker hosts, but any host you put in .packjail-allow is a permitted exfil channel for project-local data. Treat the allowlist as a trust decision
Compromise of the package manager, registry, or sandbox host tools (bwrap, sandbox-exec) continuous Out of scope. We assume npm/pnpm/yarn/bun and the OS sandbox are honest
Trusted Publisher / OIDC bypass attacks (e.g., the GHA path used in Axios) continuous Happens upstream, before the package reaches the registry
Sandbox escapes via kernel vulnerabilities continuous Out of scope; same risk class as any other OS sandbox

Limitations

Show limitations and compatibility notes
  • ~/.npmrc is not mounted by default. If your auth token lives there, either move the relevant lines into a project-local .npmrc, pass --allow-user-npmrc, or use --allow-registry-auth with NPM_TOKEN/NODE_AUTH_TOKEN set in the environment. Custom registry hosts still need .packjail-allow or --allow; .npmrc does not grant network egress by itself.

  • Project directory is fully readable inside the sandbox (it has to be; the install writes into it). .env, terraform.tfstate, and any other project-local secrets are visible to postinstall scripts. Egress filtering is the thing preventing exfiltration.

  • Native modules that compile from source need a build toolchain we don't fully expose. Packages like re2, sqlite3, better-sqlite3, and some node-gyp builds fall back to compiling from source when no platform prebuild is available for your machine. The sandbox lacks the full Xcode / build-essential environment those compiles need, so they fail. Workarounds:

    • Prefer packages that ship prebuilds for your platform (most popular native modules do; Sharp, fsevents, @next/swc worked cleanly in our reliability harness).
    • For specific projects, run the affected install outside packjail once to populate node_modules/, then use packjail for subsequent installs (the prebuild is now cached).
  • Postinstall scripts that don't honor HTTP_PROXY can't reach upstream. We set HTTP_PROXY/HTTPS_PROXY (both cases) and YARN_HTTP_PROXY/YARN_HTTPS_PROXY in the child env, and the OS-level sandbox blocks any direct socket egress. Tools that build their own HTTP client and skip the env var (notably napi-postinstall used by some Rust-prebuild packages like unrs-resolver, electron's prebuild downloader, and Puppeteer's newer browser-fetch path in some versions) call getaddrinfo directly and fail with ENOTFOUND. There's no way to fix this from the packjail side without weakening the network policy; the fix has to land in the affected upstream tools.

  • Linux uses a relay for proxy-only networking. Bubblewrap's --unshare-net creates a private network namespace whose loopback is separate from the host's loopback. packjail starts a tiny relay inside that namespace on 127.0.0.1 and exposes only the filtered proxy over a private Unix socket mounted from the sandbox home. Passing --allow-insecure-network restores the old env-var-only behavior for debugging, but malicious scripts can ignore proxy env vars and open direct sockets.

  • Yarn Classic cannot fall back to older versions for minimum package age. npm, pnpm, Bun, and Yarn Berry >=4.10 have native age-gated resolution, so latest and semver ranges can select the newest eligible older version. Yarn Classic 1.x has no equivalent knob; packjail therefore blocks too-new range/tag resolutions instead of silently installing the fresh version.

License

packjail is licensed under the PolyForm Internal Use License 1.0.0.

Internal business use is permitted. Redistribution, sublicensing, or inclusion in a distributed product requires a separate license.

About

Secure npm, pnpm, Yarn, and Bun installs by running install scripts in a sandbox with restricted filesystem access, filtered network egress, and a temporary home directory.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages