Binance Hack Lessons: Sandbox the Take-Home Assignment

SafeDep Team23 min read

A fake recruiter gave a Binance engineer a take-home assignment. Its setup step replaced the pyenv binary under /opt/homebrew on a work laptop, and endpoint security did not detect it. Binance does not say where the code came from. If it came from the assignment’s own setup script, a package check does not see it, because that file does not come from a package registry. SafeDep rebuilt that setup step as a harmless lab and ran it under pmg(opens in new tab) on Linux. With the pmg sandbox on, the same file operations against a dummy Homebrew prefix failed with EACCES. With the sandbox off, the default, all of them succeeded.

The attack path itself is in the earlier breakdown, Binance Hack: How Attackers Stole $6.34 Billion Through Developer Laptops. This post covers one step of it, the fake interview, and what a developer and a security team can do about it.

TL;DR

  • Binance says the assignment ran malicious code during setup on a work laptop. It does not name the ecosystem or the command.
  • With the sandbox off, pmg let the lab’s pyenv swap, ~/.ssh read and secret read through. It checks registry packages, not the project’s own scripts.
  • With the sandbox on, npm install and npm run start could not swap pyenv, edit the startup file, read ~/.ssh, run curl or see AWS_SECRET_ACCESS_KEY. pip install . showed the same file and curl blocks. The default profile still let raw sockets out. A proxy-only profile in enforce mode closed them in the npm runs.

The take-home step in Binance’s report

Binance published its initial report(opens in new tab) on November 3, 2026. A fake recruiter asked Engineer B to download an assignment repository to a work laptop, and the repository ran malicious code during setup. The setup step replaced /opt/homebrew/Cellar/pyenv/2.8.0/libexec/pyenv and re-linked /opt/homebrew/bin/pyenv in six file operations, then started zsh. Endpoint security did not detect it. The breakdown post has the six-step table and the rest of the chain.

The interview pattern matches Contagious Interview. The breakdown post lists the SafeDep cases for that campaign.

Three lessons from the take-home step

  1. Setup code from a stranger runs with your rights. Homebrew on Apple Silicon makes the logged-in user the owner of /opt/homebrew, so code that runs as that user can replace a tool there without a password prompt. The same user owns the shell startup files and ~/.ssh.
  2. A package reputation check covers only what comes from a registry. pmg looks up the name and version of each package that the package manager downloads in SafeDep’s analysis service. An assignment can carry its payload in its own package.json or setup.py, and that file never passes through a registry. The control that fits is to contain the setup step.
  3. A blocked attempt does not look like a failure. In the lab, npm exits 0 after every block. A team needs the denials in one place.

A benign copy of the assignment

The lab copies the shape of the Binance setup step and nothing else. It replaces a dummy pyenv in a fake Homebrew prefix under your home directory and edits a fake shell startup file. It reads a planted canary in ~/.ssh and a fake AWS secret from the environment. Then it runs curl and connects to example.com three ways. Each attempt prints [DONE] or [BLOCKED].

important

Read every file before you run it. The lab files go only to ~/pmg-lab, /tmp/pmg-lab-brew and ~/.ssh/pmg-lab-canary. The network enforcement run also changes your pmg configuration, and the end of the lab shows how to undo it. Run the lab in a VM or as a spare user if you prefer.

Lab environment

SafeDep ran the lab on November 3, 2026, as an unprivileged user on Linux 6.18 (x86_64). The host had Node.js 22.23.4, npm 10.9.9, Python 3.13 and pip 26.2.2. SafeDep built pmg from source. The sandbox driver was Landlock:

$ pmg sandbox doctor
Checking the landlock driver, which PMG uses on this host. Use --driver to check another driver.

STATUS   CHECK                     SUMMARY                                 FIX
OK       Landlock ABI              Landlock ABI V7                         —
SKIPPED  AppArmor user namespaces  AppArmor userns sysctl not present      —
OK       Landlock canary           landlock correctly blocked canary read  —
OK       Exec network path         confined to the proxy (landlock)        —

The assignment repository

Create the lab directories, then save the three files below into them. The start script runs the same payload, to test npm run.

mkdir -p ~/pmg-lab/assignment/scripts ~/pmg-lab/pyassign
~/pmg-lab/assignment/package.json
{
  "name": "trading-dashboard-assignment",
  "version": "1.0.0",
  "description": "Benign lab copy of a take-home assignment with a setup step",
  "private": true,
  "scripts": {
    "postinstall": "node scripts/setup.js",
    "start": "node scripts/setup.js"
  }
}

The payload follows the six steps in Binance’s table. swapPyenv does steps 1 to 4 and relinkPyenv does steps 5 and 6. It runs the same swap against a second prefix under /tmp to show that the sandbox leaves /tmp writable.

// ~/pmg-lab/assignment/scripts/setup.js
// Benign lab copy of the setup step in Binance's report. It changes only
// the dummy files that reset.sh creates, and it connects only to example.com.
const fs = require('fs');
const os = require('os');
const path = require('path');
const cp = require('child_process');
const https = require('https');
const net = require('net');
const tls = require('tls');

const LAB = path.join(os.homedir(), 'pmg-lab');

function report(ok, name, detail) {
  console.log(`${ok ? '[DONE]   ' : '[BLOCKED]'} ${name}${detail ? ' :: ' + detail : ''}`);
}

function attempt(name, fn) {
  try {
    report(true, name, fn());
  } catch (e) {
    report(false, name, `${e.code || ''} ${String(e.message).split('\n')[0]}`.trim());
  }
}

// Steps 1 to 4 of the Binance table: stage pyenv_, delete pyenv, rename.
function swapPyenv(prefix) {
  const real = path.join(prefix, 'Cellar/pyenv/2.8.0/libexec/pyenv');
  fs.writeFileSync(real + '_', '#!/bin/sh\necho "TAMPERED pyenv (lab)"\n', { mode: 0o755 });
  fs.unlinkSync(real);
  fs.renameSync(real + '_', real);
}

// Steps 5 and 6: delete the PATH entry and link it again.
function relinkPyenv(prefix) {
  const link = path.join(prefix, 'bin/pyenv');
  fs.unlinkSync(link);
  fs.symlinkSync('../Cellar/pyenv/2.8.0/libexec/pyenv', link);
}

function connect(name, open) {
  return new Promise((resolve) => {
    const done = (ok, detail) => { report(ok, name, detail); resolve(); };
    const s = open(() => { done(true, `connected to ${s.remoteAddress}`); s.destroy(); });
    s.setTimeout(10000, () => s.destroy(new Error('timeout')));
    s.on('error', (e) => done(false, `${e.code || ''} ${e.message}`.trim()));
  });
}

function httpsGet(name) {
  return new Promise((resolve) => {
    const req = https.get('https://example.com/', (res) => {
      res.resume();
      report(true, name, `HTTP ${res.statusCode}`);
      resolve();
    });
    req.setTimeout(10000, () => req.destroy(new Error('timeout')));
    req.on('error', (e) => { report(false, name, `${e.code || ''} ${e.message}`.trim()); resolve(); });
  });
}

(async () => {
  console.log(`setup.js: uid=${process.getuid()} cwd=${process.cwd()}`);
  attempt('swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4)', () => swapPyenv(path.join(LAB, 'brew')));
  attempt('re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6)', () => relinkPyenv(path.join(LAB, 'brew')));
  attempt('swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)', () => swapPyenv('/tmp/pmg-lab-brew'));
  attempt('re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)', () => relinkPyenv('/tmp/pmg-lab-brew'));
  attempt('append to ~/pmg-lab/fake-zshrc', () => fs.appendFileSync(path.join(LAB, 'fake-zshrc'), 'export PATH="$HOME/pmg-lab/brew/bin:$PATH"\n'));
  attempt('read ~/.ssh/pmg-lab-canary', () => fs.readFileSync(path.join(os.homedir(), '.ssh/pmg-lab-canary'), 'utf8').trim());
  attempt('read env AWS_SECRET_ACCESS_KEY', () => {
    if (!process.env.AWS_SECRET_ACCESS_KEY) throw new Error('not in the environment');
    return 'value visible';
  });
  attempt('write ./build/out.txt in the project', () => {
    fs.mkdirSync('build', { recursive: true });
    fs.writeFileSync('build/out.txt', 'ok\n');
  });
  attempt('spawn a shell (/bin/sh)', () => cp.execFileSync('/bin/sh', ['-c', 'echo "shell ran as uid $(id -u)"'], { encoding: 'utf8' }).trim());
  attempt('exec /usr/bin/curl', () => cp.execFileSync('/usr/bin/curl', ['-sS', '-o', '/dev/null', '-w', '%{http_code}', 'https://example.com/'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000 }));
  await httpsGet('https.get example.com');
  await connect('raw TLS socket to example.com:443', (cb) => tls.connect({ host: 'example.com', port: 443, servername: 'example.com' }, cb));
  if (process.env.LAB_IP) {
    await connect(`raw TCP socket to ${process.env.LAB_IP}:443`, (cb) => net.connect({ host: process.env.LAB_IP, port: 443 }, cb));
  }
})();

The victim state

reset.sh builds a fake Homebrew prefix at ~/pmg-lab/brew, with a dummy pyenv in Cellar and a symlink in bin. It builds the same tree under /tmp/pmg-lab-brew, writes a fake shell startup file and plants the canary. On a Mac, /opt/homebrew is the real target. The lab stays out of it.

#!/bin/sh
# ~/pmg-lab/reset.sh
# Build the benign "victim" state for the lab. Run it before each test.
set -e
LAB="$HOME/pmg-lab"
for PREFIX in "$LAB/brew" /tmp/pmg-lab-brew; do
  rm -rf "$PREFIX"
  mkdir -p "$PREFIX/Cellar/pyenv/2.8.0/libexec" "$PREFIX/bin"
  printf '#!/bin/sh\necho "real pyenv 2.8.0 (lab dummy)"\n' > "$PREFIX/Cellar/pyenv/2.8.0/libexec/pyenv"
  chmod +x "$PREFIX/Cellar/pyenv/2.8.0/libexec/pyenv"
  ln -s ../Cellar/pyenv/2.8.0/libexec/pyenv "$PREFIX/bin/pyenv"
done
printf '# fake shell startup file for the lab\n' > "$LAB/fake-zshrc"
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
printf 'LAB-CANARY-NOT-A-REAL-KEY\n' > "$HOME/.ssh/pmg-lab-canary"
rm -rf "$LAB/assignment/node_modules" "$LAB/assignment/package-lock.json" "$LAB/assignment/build"

Each run below starts the same way. LAB_IP holds the IPv4 address of example.com, so the payload can try a raw socket with no DNS lookup:

chmod +x ~/pmg-lab/reset.sh
cd ~/pmg-lab/assignment
export LAB_IP=$(node -e "require('dns').lookup('example.com',{family:4},(e,a)=>console.log(a))")
~/pmg-lab/reset.sh

Lab results

The output below comes from the lab runs. It drops two Node.js EnvHttpProxyAgent warnings and the npm audit summary, and nothing else.

Run A: pmg with the sandbox off

This is pmg npm install with the default configuration. The sandbox is off by default (config/config.template.yml L175(opens in new tab)). The assignment has no dependencies, so pmg has no package to check, and the postinstall script runs with the user’s full rights.

$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg npm install

> trading-dashboard-assignment@1.0.0 postinstall
> node scripts/setup.js

setup.js: uid=30033 cwd=/home/labdev/pmg-lab/assignment
[DONE]    swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4)
[DONE]    re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6)
[DONE]    swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)
[DONE]    re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)
[DONE]    append to ~/pmg-lab/fake-zshrc
[DONE]    read ~/.ssh/pmg-lab-canary :: LAB-CANARY-NOT-A-REAL-KEY
[DONE]    read env AWS_SECRET_ACCESS_KEY :: value visible
[DONE]    write ./build/out.txt in the project
[DONE]    spawn a shell (/bin/sh) :: shell ran as uid 30033
[DONE]    exec /usr/bin/curl :: 200
[DONE]    https.get example.com :: HTTP 200
[DONE]    raw TLS socket to example.com:443 :: connected to 172.66.147.243
[DONE]    raw TCP socket to 172.66.147.243:443 :: connected to 172.66.147.243

  Network: 1 destination(s) observed. Propose a profile with: pmg sandbox profile propose --run 542fe3d51d16
[exit 0]

$ cat ~/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv
#!/bin/sh
echo "TAMPERED pyenv (lab)"

Plain npm install without pmg gave the same 13 [DONE] lines.

Run B: pmg with the sandbox on

--sandbox turns the sandbox on for one command and selects the built-in npm profile. The network mode stays at audit.

$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg --sandbox npm install

> trading-dashboard-assignment@1.0.0 postinstall
> node scripts/setup.js

setup.js: uid=30033 cwd=/home/labdev/pmg-lab/assignment
[BLOCKED] swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4) :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv_'
[BLOCKED] re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6) :: EACCES EACCES: permission denied, unlink '/home/labdev/pmg-lab/brew/bin/pyenv'
[DONE]    swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)
[DONE]    re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)
[BLOCKED] append to ~/pmg-lab/fake-zshrc :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/fake-zshrc'
[BLOCKED] read ~/.ssh/pmg-lab-canary :: EACCES EACCES: permission denied, open '/home/labdev/.ssh/pmg-lab-canary'
[BLOCKED] read env AWS_SECRET_ACCESS_KEY :: not in the environment
[DONE]    write ./build/out.txt in the project
[DONE]    spawn a shell (/bin/sh) :: shell ran as uid 30033
[BLOCKED] exec /usr/bin/curl :: EACCES spawnSync /usr/bin/curl EACCES
[DONE]    https.get example.com :: HTTP 200
[DONE]    raw TLS socket to example.com:443 :: connected to 172.66.147.243
[DONE]    raw TCP socket to 172.66.147.243:443 :: connected to 172.66.147.243

⊘ Network policy would deny example.com:443  rule *:*
  Set sandbox.network_mode: enforce to deny it.

  Network: 1 destination(s) observed. Propose a profile with: pmg sandbox profile propose --run 4f04bdc9e3ea
[exit 0]

$ cat ~/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv
#!/bin/sh
echo "real pyenv 2.8.0 (lab dummy)"

The first write of the swap, the staging file pyenv_, failed, so the real file stayed in place. The symlink in bin also survived. Each block traces to a line in the profile:

The network lines show the gap in the default profile. The profile denies *:* outbound, but the default network_mode is audit, so pmg records the match and lets the connection through (config.template.yml L183-186(opens in new tab)). The raw sockets do not use the pmg proxy at all. A payload that sends stolen data to its own server over a raw socket still gets out.

Run C: the sandbox with network enforcement

Two settings close the network gap. Two more turn the sandbox on for every run and for every npm command. A child profile of npm adds network_via_proxy_only: true, which makes the sandbox refuse every connection that does not go to the pmg proxy. network_mode: enforce makes the proxy refuse what the profile denies.

pmg sandbox profile init npm-lockdown --from npm --package-manager npm
echo "network_via_proxy_only: true" >> ~/.config/safedep/pmg/sandbox/profiles/npm-lockdown.yml
pmg sandbox profile lint npm-lockdown
pmg config set sandbox.policies.npm.profile npm-lockdown
pmg config set sandbox.network_mode enforce
pmg config set sandbox.enabled true
pmg config set sandbox.enforce_always true

pmg sandbox profile init prints the path of the new file. On macOS it is under ~/Library/Application Support, so use the printed path in the echo line. The lint printed OK no issues found. With the sandbox on in the configuration, the run needs no flag:

$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg npm install

> trading-dashboard-assignment@1.0.0 postinstall
> node scripts/setup.js

setup.js: uid=30033 cwd=/home/labdev/pmg-lab/assignment
[BLOCKED] swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4) :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv_'
[BLOCKED] re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6) :: EACCES EACCES: permission denied, unlink '/home/labdev/pmg-lab/brew/bin/pyenv'
[DONE]    swap pyenv in /tmp/pmg-lab-brew/Cellar (steps 1-4)
[DONE]    re-link /tmp/pmg-lab-brew/bin/pyenv (steps 5-6)
[BLOCKED] append to ~/pmg-lab/fake-zshrc :: EACCES EACCES: permission denied, open '/home/labdev/pmg-lab/fake-zshrc'
[BLOCKED] read ~/.ssh/pmg-lab-canary :: EACCES EACCES: permission denied, open '/home/labdev/.ssh/pmg-lab-canary'
[BLOCKED] read env AWS_SECRET_ACCESS_KEY :: not in the environment
[DONE]    write ./build/out.txt in the project
[DONE]    spawn a shell (/bin/sh) :: shell ran as uid 30033
[BLOCKED] exec /usr/bin/curl :: EACCES spawnSync /usr/bin/curl EACCES
[BLOCKED] https.get example.com :: ERR_PROXY_TUNNEL Failed to establish tunnel to example.com:443 via http://127.0.0.1:37343: HTTP/1.1 403 Forbidden
[BLOCKED] raw TLS socket to example.com:443 :: EAI_AGAIN getaddrinfo EAI_AGAIN example.com
[BLOCKED] raw TCP socket to 104.20.23.154:443 :: ECONNREFUSED connect ECONNREFUSED 104.20.23.154:443

⊘ Network policy denied example.com:443  rule *:*
  Allow it with pmg sandbox allow net-connect=example.com:443.

  Network: 1 destination(s) observed. Propose a profile with: pmg sandbox profile propose --run 5f98a17daa1f
[exit 0]

$ cat ~/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv
#!/bin/sh
echo "real pyenv 2.8.0 (lab dummy)"

DNS for the raw TLS socket failed because direct DNS is off under network_via_proxy_only. The raw TCP connect to the IP address hit the seccomp supervisor. The proxy refused the CONNECT. The Allow it with line of the pmg summary shows the developer how to allow a real host that the assignment needs.

pmg keeps a local record of each run in the sandbox:

$ pmg sandbox violations list
RECORDED              SANDBOX   PROFILE       KIND             TARGET
2026-11-03T19:26:40Z  proxy     npm-lockdown  network_connect  example.com:443
2026-11-03T19:26:40Z  landlock  npm-lockdown  fs_read          /home/labdev/.ssh/pmg-lab-canary
2026-11-03T19:26:08Z  landlock  npm           fs_read          /home/labdev/.ssh/pmg-lab-canary

$ pmg sandbox explain --last
Sandbox: proxy  Profile: npm-lockdown  Recorded: 2026-11-03T19:26:40Z
--------------------------------------------------------

Reason: Connection to example.com:443 denied by the network policy (rule *:*). Override: --sandbox-allow net-connect=example.com:443

The JSON report under ~/.cache/safedep/pmg/sandbox/violations/ lists the canary read, the curl exec, three direct connects and the removed AWS_SECRET_ACCESS_KEY. It does not list the pyenv or startup file writes, because Landlock does not report operations outside the allow-list (docs/sandbox.md L358-365(opens in new tab)).

Runs D to F: npm run start, sandbox exec and pip install

A take-home assignment also asks the candidate to start the app. pmg npm run start (Run D, same configuration as Run C) gave the same 13 lines as Run C. The start script ran inside the npm-lockdown profile.

pmg sandbox exec -- node scripts/setup.js (Run E) runs the payload under the built-in exec profile, which pmg marks as experimental. Two lines differ from Run C:

$ AWS_SECRET_ACCESS_KEY=lab-fake-secret pmg sandbox exec -- node scripts/setup.js
...
[DONE]    exec /usr/bin/curl :: 200
[DONE]    https.get example.com :: HTTP 200
[BLOCKED] raw TLS socket to example.com:443 :: EAI_AGAIN getaddrinfo EAI_AGAIN example.com
[BLOCKED] raw TCP socket to 104.20.23.154:443 :: ECONNREFUSED connect ECONNREFUSED 104.20.23.154:443
[exit 0]

The exec profile has no deny_exec for curl, and it allows every destination through the proxy (sandbox/profiles/exec.yml(opens in new tab)). It still confines writes, credential reads, secret variables and raw sockets. The pyenv swap failed the same way.

For a Python assignment, the same steps go into setup.py, and the candidate runs pip install . in a project virtual environment.

pip ran setup.py three times during the build: to get build requirements, to prepare metadata and to build the wheel. Without pmg every attempt succeeded. Under pmg --sandbox pip install -v . (Run F), the first pass printed this, and the other two passes matched it:

$ cd ~/pmg-lab/pyassign && python3 -m venv .venv && . .venv/bin/activate
$ ~/pmg-lab/reset.sh
$ pmg --sandbox pip install -v .
...
  Running command Getting requirements to build wheel
  [BLOCKED] swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4) :: PermissionError: [Errno 13] Permission denied: '/home/labdev/pmg-lab/brew/Cellar/pyenv/2.8.0/libexec/pyenv_'
  [BLOCKED] re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6) :: PermissionError: [Errno 13] Permission denied: '/home/labdev/pmg-lab/brew/bin/pyenv'
  [BLOCKED] append to ~/pmg-lab/fake-zshrc :: PermissionError: [Errno 13] Permission denied: '/home/labdev/pmg-lab/fake-zshrc'
  [BLOCKED] read ~/.ssh/pmg-lab-canary :: PermissionError: [Errno 13] Permission denied: '/home/labdev/.ssh/pmg-lab-canary'
  [BLOCKED] exec /usr/bin/curl :: PermissionError: [Errno 13] Permission denied: '/usr/bin/curl'
...
Successfully installed trading-dashboard-assignment-1.0.0
[exit 0]

The PyPI profile allows writes only to the project, the temporary directories and the pip, Poetry and uv caches. An install into a Homebrew or system Python fails under the sandbox, so keep the virtual environment in the project. A global npm install, such as npm install -g, also fails under the sandbox.

Results of all runs

Each cell shows whether the setup script’s action ran or the sandbox blocked it, and what the lab observed.

To remove the lab, delete its files:

rm -rf ~/pmg-lab /tmp/pmg-lab-brew ~/.ssh/pmg-lab-canary

Run C changed your pmg configuration. Keep it if you want the hardened setup. To undo it, or to repeat Run A after Run C, run:

pmg config set sandbox.enabled false
pmg config set sandbox.enforce_always false
pmg config set sandbox.network_mode audit
pmg config set sandbox.policies.npm.profile npm

Then delete the npm-lockdown.yml file whose path pmg sandbox profile init printed.

Which setup paths the sandbox covers

SafeDep’s earlier Contagious Interview and PolinRider cases show the triggers that this actor uses, and almost all of them are in Node.js projects. The table maps each trigger to what pmg does. “Contained” means the code runs inside the sandbox profile, so it cannot write outside the project and temporary directories.

pmg applies the sandbox to every package manager command that is not on a short list of commands that never download packages (config/config.go L1237-1243(opens in new tab)). For npm, run and start are not on that list (packagemanager/npm.go L52-65(opens in new tab)), and the pmg documentation states that unknown commands and npm run dev stay inside the sandbox (docs/sandbox.md L211-215(opens in new tab)). npm rebuild is on the list, and it runs install scripts. In the lab, with the Run C setup but enforce_always: false, pmg npm rebuild ran the postinstall outside the sandbox and replaced the dummy pyenv. With sandbox.enforce_always: true, as in Run C, the same command left the dummy pyenv unchanged. The cooldown exception for lockfiles comes from the pmg documentation (docs/dependency-cooldown.md L23-25(opens in new tab)).

Limits

  • The full lab ran only on Linux. Binance’s laptop was a Mac with /opt/homebrew. On macOS pmg uses Seatbelt. No npm or PyPI profile allows a write under /opt/homebrew (seatbelt_translator_darwin.go L251(opens in new tab)), so Seatbelt blocks the pyenv swap. The error on macOS is EPERM, not EACCES.
  • On macOS, raw sockets need the same hardening. For a profile with any allow_outbound rule and no network_via_proxy_only, Seatbelt allows outbound network in general and leaves host rules to the proxy (L704-711(opens in new tab)).
  • deny_exec names paths. The npm profile blocks only /usr/bin/curl, /usr/bin/wget and /usr/bin/python*. A Homebrew wget under /opt/homebrew/bin, a copy of curl, or Node.js https still runs. Seatbelt allows every other program (docs/sandbox.md L31(opens in new tab)).
  • /tmp and the project stay writable. The /tmp swap succeeded in every run. A payload can stage files there or plant code in the project for a later run without pmg.
  • Reads stay broad. The npm profile allows reads from /. Browser profiles, wallet extension data, shell history and ~/.npmrc stay readable. The sandbox denies reads only for the mandatory list.
  • npm exits 0. The payload caught each error, so the install looked normal. The developer finds the block only in the pmg summary line and the violation log.
  • The shell ran. The sandbox does not stop /bin/sh or zsh. The shell inherits the sandbox, so it has the same limits.
  • The malicious package block is not in this lab. The assignment has no dependencies. The pmg README lists a benign test package, safedep-test-pkg, that SafeDep flags as malicious so that anyone can check the block.
  • pmg does not cover the rest of the Binance chain. The recruiter, Engineer A’s Homebrew install (pmg does not wrap brew), the move through the internal AI tool, the deployment token and the wallet signing are out of scope. Binance stopped 57 other suspected North Korea-linked attempts in 2026. Of those, pmg helps only with compromised packages and fake developer tools that ship through npm or PyPI.

Get started as a developer

Run setup code from a stranger in a throwaway VM or a cloud development environment, never on a laptop with production access or personal credentials. On your own machine, install pmg, which is open source and free.

brew install safedep/tap/pmg     # or: npm install -g @safedep/pmg
pmg setup install                # restart the terminal after this
pmg setup doctor

pmg setup install adds shell aliases and PATH shims for npm, pnpm, yarn, bun, npx, pip, uv, poetry and the other supported tools, so scripts and coding agents go through pmg as well. Then turn on the sandbox with a proxy-only npm profile, enforce the network policy and check the driver:

pmg config set sandbox.enabled true
pmg config set sandbox.enforce_always true
PROFILE=$(pmg sandbox profile init npm-lockdown --from npm --package-manager npm)
echo "network_via_proxy_only: true" >> "$PROFILE"
pmg config set sandbox.policies.npm.profile npm-lockdown
pmg config set sandbox.network_mode enforce
pmg sandbox doctor

The proxy-only profile covers npm only. Repeat the profile init step for each package manager you use, such as pnpm, yarn, npx, pip or uv, and point its sandbox.policies.<name>.profile at the new profile. Expect to allow a few hosts the first time a project builds. pmg sandbox profile propose drafts the rules from what a run reached. sandbox.enforce_always: true also sandboxes the npm commands that pmg treats as non-download, such as npm rebuild, which runs install scripts.

For code from a stranger, two more habits help:

  • Start the app through the package manager (npm run start, uv run, poetry run), or put pmg sandbox exec -- in front of node or python.
  • Scan the repository before you open it in an editor. vet v2 (alpha) flags folder-open editor tasks, git hooks and scripts disguised as fonts with vet scan <url> --fail-on attacks (controls(opens in new tab)). Install it with brew install --cask safedep/tap/vet@edge, after brew uninstall vet if you have v1.

Documentation: pmg quickstart(opens in new tab), network policy(opens in new tab) and sandbox reference(opens in new tab).

Get started as a team

A developer under deadline pressure can turn off a control that gets in the way. A block that exits 0 is easy to miss. A team needs the same configuration on every laptop and the denials in one place. The pmg sandbox and cooldown are open source and work without an account. The Team plan adds central management and reporting of them across endpoints.

Ship one configuration with mobile device management (MDM). pmg reads a global configuration file at /Library/Application Support/safedep/pmg/config.yml on macOS and /etc/safedep/pmg/config.yml on Linux. When the file exists, pmg ignores the per-user file. With global_lockdown: true, flags such as --sandbox=false fail and PMG_INSECURE_INSTALLATION no longer bypasses the malware block (docs/config.md L197-263(opens in new tab)). A fleet file for this case:

# /Library/Application Support/safedep/pmg/config.yml (macOS)
# /etc/safedep/pmg/config.yml (Linux)
global_lockdown: true
sandbox:
  enabled: true
  enforce_always: true
  network_mode: enforce
  policy_templates:
    npm-lockdown:
      path: ./npm-lockdown.yml
  policies:
    npm:
      enabled: true
      profile: npm-lockdown
cloud:
  enabled: true

Ship the proxy-only npm-lockdown.yml profile from the lab’s Run C next to the global file. With global_lockdown: true, pmg resolves profiles only from managed locations, and a user cannot allow a host with pmg sandbox allow. Before you ship the profile, add each host that your projects download from to its allow_outbound list. Keys that the file does not set take the pmg defaults, not the user’s values. SafeDep publishes MDM scripts(opens in new tab) for Jamf, Kandji, Mosyle, JumpCloud and Intune.

Connect each machine to SafeDep Cloud. pmg config set fails under a global file, so the fleet file above sets cloud.enabled. Each user logs in once, or the MDM script passes the credentials:

pmg cloud login        # tenant ID and API key, or --from-env

From pmg v0.34.0, a run with a denial that the sandbox driver records sends a sandbox violation event. The event holds the driver, the profile and up to 100 denied paths, programs or hosts (internal/audit/cloud_translate.go(opens in new tab)). Seatbelt records boundary writes, and Landlock does not. A payload that only swaps pyenv on a Linux machine leaves no violation event. In the lab’s Run C, the local report had the ~/.ssh read and the curl exec, but not the pyenv write. Blocked packages, cooldown holds, network policy decisions and bypasses go to the same place.

The Endpoints page lists every machine with its blocked count and a live feed of events. The screenshots below come from SafeDep’s demo tenant, not from a customer or from Binance.

Endpoints in a demo tenant. Each row is a laptop or CI runner with pmg connected.

The Package Events tab of one laptop shows each pmg session, the packages it blocked or held, and the sandbox events. The Invocation column names the coding agent when an agent ran the install.

Package Events for one demo laptop, with a sandbox violation for a read of ~/.ssh/id_rsa.

A sandbox violation opens a drawer with the driver, the profile and each denial by kind, path, process and count. On a Mac, a Binance-style swap would show here as a filesystem write denial on the pyenv path. That is an inference from the event schema and the Seatbelt documentation, not an observed event.

The sandbox violation drawer for a demo install script that tried to read SSH and AWS keys.

Hunt across the fleet with SQL. This query from the sandbox documentation(opens in new tab) lists the endpoints where the sandbox blocked something:

SELECT endpoints.identifier,
       COUNT(DISTINCT package_guard_events.invocation_id) AS sessions
FROM package_guard_events
JOIN endpoints ON endpoints.id = package_guard_events.invocation_id
WHERE package_guard_events.event_type = 'PMG_EVENT_TYPE_SANDBOX_VIOLATION'
GROUP BY endpoints.identifier

The ON clause is a placeholder. SafeDep Cloud applies the real join between an event and its endpoint (alerts documentation(opens in new tab)).

The sandbox violation query in the Query console of a demo tenant.

When a new campaign report names a command-and-control domain, filter on package_guard_events.hostname. The column holds the destination of network policy and host observation events, so the query finds the laptops where the pmg proxy saw a connection to that host. Filter on PMG_EVENT_TYPE_INSECURE_BYPASS and PMG_EVENT_TYPE_SANDBOX_OVERRIDE to see who turned a control off. The alerts recipe(opens in new tab) shows how to run the query with safedep query exec from a scheduled job and post the rows to a Slack, Teams or PagerDuty webhook.

SafeDep Cloud sees only what ran through pmg. A payload started from an editor task, a git hook or a bare node command leaves no event.

Endpoint Hub and SQL queries need the Team or Enterprise plan, and Cloud sync from an MDM fleet needs Enterprise (pricing).

See sandbox denials across your team

Connect pmg to SafeDep Cloud and see blocked packages, cooldown holds and sandbox violations for every laptop and CI runner.

  • contagious-interview
  • north-korea
  • developer-security
  • pmg
  • sandbox
  • supply-chain

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

Ship Code.Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.