---
title: "Binance Hack Lessons: Sandbox the Take-Home Assignment"
description: "A fake take-home assignment replaced a Homebrew binary on a Binance work laptop. SafeDep rebuilt that setup step as a benign lab and ran it with and without the pmg sandbox. The post gives the commands, the real output and the limits of the sandbox."
url: "https://safedep.io/blog/binance-hack-lessons"
author: "SafeDep Team"
published: "2026-11-04"
---

# Binance Hack Lessons: Sandbox the Take-Home Assignment

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`](https://github.com/safedep/pmg) 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](https://safedep.io/blog/binance-hack-developer-supply-chain-attack-path). 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](https://x.com/binance/status/2117626126214504729) 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](https://safedep.io/blog/binance-hack-developer-supply-chain-attack-path) has the six-step table and the rest of the
chain.

The interview pattern matches [Contagious Interview](https://safedep.io/ti/campaigns/contagious-interview). The
[breakdown post](https://safedep.io/blog/binance-hack-developer-supply-chain-attack-path#contagious-interview) 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:

```console
$ 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`.

```bash
mkdir -p ~/pmg-lab/assignment/scripts ~/pmg-lab/pyassign
```

```json title="~/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.

```js
// ~/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.

```sh
#!/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:

```bash
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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/config/config.template.yml#L175)).
The assignment has no dependencies, so `pmg` has no package to check, and the postinstall script runs with the user's
full rights.

```console
$ 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`.

```console
$ 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:

- Writes outside the project fail because the npm profile grants writes only to the temporary directories, the
  project directory and the package manager caches
  ([`sandbox/profiles/npm-restrictive.yml` L43-72](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/sandbox/profiles/npm-restrictive.yml#L43-L72)).
  The profile denies everything else.
- `~/.ssh` is on the mandatory deny list for reads and writes, together with `.aws`, `.kube`, `.gnupg`, `.netrc`,
  `.env` and others
  ([`sandbox/util/dangerous.go` L22-37](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/sandbox/util/dangerous.go#L22-L37)).
- `pmg` removes known secret variables, such as `AWS_SECRET_ACCESS_KEY` and `GITHUB_TOKEN`, from the child environment.
- `/usr/bin/curl` and `/usr/bin/wget` are in `deny_exec`
  ([`npm-restrictive.yml` L139-141](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/sandbox/profiles/npm-restrictive.yml#L139-L141)).

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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/config/config.template.yml#L183-L186)).
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.

```bash
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:

```console
$ 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:

```console
$ 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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/docs/sandbox.md#L358-L365)).

### 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:

```console
$ 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`](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/sandbox/profiles/exec.yml)).
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.

**setup.py for the Python run**

```python
# ~/pmg-lab/pyassign/setup.py
# Benign lab copy of the setup step, for a Python take-home assignment.
# It changes only the dummy files that reset.sh creates.
import os
import subprocess
from setuptools import setup

LAB = os.path.expanduser("~/pmg-lab")


def attempt(name, fn):
    try:
        detail = fn()
        print(f"[DONE]    {name}{' :: ' + detail if detail else ''}", flush=True)
    except Exception as e:
        print(f"[BLOCKED] {name} :: {type(e).__name__}: {e}", flush=True)


def swap_pyenv():
    real = os.path.join(LAB, "brew/Cellar/pyenv/2.8.0/libexec/pyenv")
    with open(real + "_", "w") as f:
        f.write('#!/bin/sh\necho "TAMPERED pyenv (lab)"\n')
    os.unlink(real)
    os.rename(real + "_", real)


def relink_pyenv():
    link = os.path.join(LAB, "brew/bin/pyenv")
    os.unlink(link)
    os.symlink("../Cellar/pyenv/2.8.0/libexec/pyenv", link)


def append_rc():
    with open(os.path.join(LAB, "fake-zshrc"), "a") as f:
        f.write('export PATH="$HOME/pmg-lab/brew/bin:$PATH"\n')


def read_canary():
    with open(os.path.expanduser("~/.ssh/pmg-lab-canary")) as f:
        return f.read().strip()


def curl():
    args = ["/usr/bin/curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "https://example.com/"]
    return subprocess.run(args, capture_output=True, text=True, check=True, timeout=15).stdout


attempt("swap pyenv in ~/pmg-lab/brew/Cellar (steps 1-4)", swap_pyenv)
attempt("re-link ~/pmg-lab/brew/bin/pyenv (steps 5-6)", relink_pyenv)
attempt("append to ~/pmg-lab/fake-zshrc", append_rc)
attempt("read ~/.ssh/pmg-lab-canary", read_canary)
attempt("exec /usr/bin/curl", curl)

setup(name="trading-dashboard-assignment", version="1.0.0", py_modules=[])
```

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:

```console
$ 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.

| Action by the setup script              | `pmg`, sandbox off                  | `pmg --sandbox`, default profile       | Sandbox with `network_via_proxy_only` and enforce |
| --------------------------------------- | ----------------------------------- | -------------------------------------- | ------------------------------------------------- |
| Swap `pyenv` in `~/pmg-lab/brew/Cellar` | <Ran />                             | <Blocked>EACCES</Blocked>              | <Blocked>EACCES</Blocked>                         |
| Re-link `~/pmg-lab/brew/bin/pyenv`      | <Ran />                             | <Blocked>EACCES</Blocked>              | <Blocked>EACCES</Blocked>                         |
| Same swap and re-link under `/tmp`      | <Ran />                             | <Ran />                                | <Ran />                                           |
| Append to the fake shell startup file   | <Ran />                             | <Blocked>EACCES</Blocked>              | <Blocked>EACCES</Blocked>                         |
| Read `~/.ssh/pmg-lab-canary`            | <Ran />                             | <Blocked>EACCES</Blocked>              | <Blocked>EACCES</Blocked>                         |
| Read `AWS_SECRET_ACCESS_KEY`            | <Ran>value visible</Ran>            | <Blocked>removed from the env</Blocked> | <Blocked>removed from the env</Blocked>          |
| Write `./build/out.txt` in the project  | <Ran />                             | <Ran />                                | <Ran />                                           |
| Spawn `/bin/sh`                         | <Ran />                             | <Ran inSandbox />                      | <Ran inSandbox />                                 |
| Exec `/usr/bin/curl`                    | <Ran />                             | <Blocked>EACCES</Blocked>              | <Blocked>EACCES</Blocked>                         |
| `https.get` through the `pmg` proxy     | <Ran>HTTP 200</Ran>                 | <Ran>HTTP 200, audited</Ran>           | <Blocked>403 from the `pmg` proxy</Blocked>       |
| Raw TLS socket to example.com:443       | <Ran>connected</Ran>                | <Ran>connected</Ran>                   | <Blocked>DNS lookup failed</Blocked>              |
| Raw TCP socket to the IP address        | <Ran>connected</Ran>                | <Ran>connected</Ran>                   | <Blocked>connection refused</Blocked>             |
| `npm install` exit code                 | 0                                   | 0                                      | 0                                                 |

To remove the lab, delete its files:

```bash
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:

```bash
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.

| Trigger in the assignment | Seen before | `pmg`, sandbox off (default) | `pmg`, sandbox on |
| --- | --- | --- | --- |
| `preinstall` or `postinstall` in its own package file | [hugo-paper `preinstall` dropper](https://safedep.io/blog/hugo-paper-mevn-cli-forged-commit-preinstall-dropper) | <Ran label="Runs">`pmg` analyzes registry packages, not the project</Ran> | <Contained>lab Run B</Contained> |
| Build, start or test script on `npm run` | [antd-img-crop `prebuild`](https://safedep.io/blog/antd-img-crop-forged-commit-overlord-stealer), [PolinRider](https://safedep.io/blog/polinrider-ethereum-c2-github-repositories) | <Ran label="Runs" /> | <Contained>lab Run D</Contained> |
| A malicious dependency from npm or PyPI | [node-env-resolve](https://safedep.io/blog/malicious-npm-node-env-resolve-rat), [ulid-xyz](https://safedep.io/blog/ulid-xyz-transitive-dependency-delivery-chain) | **Blocked** if verified malicious<br />**Prompt** if only inferred<br />**Held** if under 2 days old, unless a lockfile already resolves it to an artifact URL<br />**Allowed** if unknown ([`base_registry.go` L124-131](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/proxy/interceptors/base_registry.go#L124-L131)) | <Contained>same checks as sandbox off, and its install script runs in the sandbox</Contained> |
| `pip install .` runs the assignment's `setup.py` | No public case. Python shows up as a later stage. | <Ran label="Runs" /> | <Contained>lab Run F</Contained> |
| `node index.js` or `python main.py` typed in a terminal | No public case | <Ran label="Not seen by pmg" /> | <Contained>only with `pmg sandbox exec --` (lab Run E)</Contained> |
| Editor folder-open task, git hook, pasted command | [PolinRider `.vscode/tasks.json`](https://safedep.io/blog/astro-config-blockchain-c2-supply-chain), [fake careers portal command](https://safedep.io/blog/antd-img-crop-forged-commit-overlord-stealer) | <Ran label="Not seen by pmg" /> | <Ran label="Not seen by pmg" /> |

`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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/config/config.go#L1237-L1243)).
For npm, `run` and `start` are not on that list
([`packagemanager/npm.go` L52-65](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/packagemanager/npm.go#L52-L65)),
and the `pmg` documentation states that unknown commands and `npm run dev` stay inside the sandbox
([`docs/sandbox.md` L211-215](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/docs/sandbox.md#L211-L215)).
`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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/docs/dependency-cooldown.md#L23-L25)).

## 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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/sandbox/platform/seatbelt_translator_darwin.go#L251)),
  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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/sandbox/platform/seatbelt_translator_darwin.go#L704-L711)).
- **`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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/docs/sandbox.md#L31)).
- **`/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.

```bash
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:

```bash
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](https://github.com/safedep/vet/blob/0b4779fa3972f093b7273b997328f53d08b2863b/docs/controls.md)).
  Install it with `brew install --cask safedep/tap/vet@edge`, after `brew uninstall vet` if you have v1.

Documentation: [`pmg` quickstart](https://docs.safedep.io/package-security/pmg/quickstart),
[network policy](https://docs.safedep.io/package-security/pmg/network-policy) and
[sandbox reference](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/docs/sandbox.md).



## 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](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/docs/config.md#L197-L263)).
A fleet file for this case:

```yaml
# /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](#run-c-the-sandbox-with-network-enforcement) 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](https://docs.safedep.io/package-security/pmg/mdm/overview) 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:

```bash
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`](https://github.com/safedep/pmg/blob/24d7f2be2eae5dadc5dbaffbf9facb26c67ee878/internal/audit/cloud_translate.go)).
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.

![SafeDep Cloud Endpoints page on a demo tenant: five endpoints, two macOS laptops, one Linux laptop, a GitHub Actions runner and a Jenkins agent, each with a blocked count, next to a Live Activity feed of blocked packages, MCP servers and coding agents.](https://safedep.io/_next/static/immutable/media/binance-lessons-endpoints.0-_x2g7451ffs.png)

*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 tab for the demo laptop aditi-macbook-pro: pnpm sessions marked Blocked or Success, a blocked package @acme-intl/build-helpers@9.9.1 with a Report link, a Cooldown hold on next@16.0.3, rows of type Sandbox (sandbox overrides), and a Sandbox violation row for a file read of /Users/dev/.ssh/id_rsa during a session that claude-code started.](https://safedep.io/_next/static/immutable/media/binance-lessons-package-events.1-ifunco5b-d2.png)

*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.

![Sandbox violation drawer in SafeDep Cloud for a demo event: Seatbelt driver, npm-restrictive profile, and denied file reads of /Users/dev/.ssh/id_rsa (3) and /Users/dev/.aws/credentials (1) by the node process.](https://safedep.io/_next/static/immutable/media/binance-lessons-sandbox-violation.1-glugen0iu8f.png)

*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](https://docs.safedep.io/package-security/pmg/sandbox) lists the endpoints where the sandbox
blocked something:

```sql
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](https://docs.safedep.io/governance/cloud/alerts)).

![SafeDep Cloud Query page on a demo tenant, running the sandbox violation query, with one row per endpoint and the number of sessions where the sandbox blocked an operation.](https://safedep.io/_next/static/immutable/media/binance-lessons-query-sandbox-violations.2x72aeld1yj0z.png)

*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](https://docs.safedep.io/governance/cloud/alerts) 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](https://safedep.io/pricing)).



## Related reading

- [Binance Hack: How Attackers Stole $6.34 Billion Through Developer Laptops](https://safedep.io/blog/binance-hack-developer-supply-chain-attack-path)
- [Configuration files that run code](https://safedep.io/blog/config-files-that-run-code)
- [Endpoint Protection for Developer Machines](https://safedep.io/blog/endpoint-protection-developer-security)
- [`pmg` dependency cooldown: wait on fresh npm versions](https://safedep.io/blog/pmg-dependency-cooldown)
