See β and block β what your dependencies actually do at runtime: reading your SSH keys, phoning home, spawning shells.
You install one package. It has 40 transitive dependencies. Any one of them can
read ~/.ssh/id_rsa, grab your NPM_TOKEN, and POST it to a server you've never
heard of β and you'll never know.
dephawk circles above your code and watches every dependency. The moment one
touches your filesystem, opens a socket, spawns a process, or reads a secret from
the environment, dephawk records it, attributes it to the exact package, and β
if you want β blocks it.
npx dephawk run npm testThose twenty-five seconds are the pitch. One dependency lists your ~/.ssh, goes
for Chrome's saved passwords (Login Data and the Local State key that
decrypts them) and a wallet key, reads your NPM_TOKEN, grabs Node's raw
internal bindings, runs a WebAssembly payload, opens a backdoor port,
shells out with a bearer token and exfiltrates over a raw socket to a
hardcoded IP. Twelve calls, and dephawk names the package behind every single
one β not one line says "unattributed" β redacts the token out of its own
report, then on the second run blocks all of them and fails the build with exit
code 2.
πΊ Run it yourself:
npm run demo(observe) andnpm run demo:enforce(block). The recording above is that same demo, made withvhs:vhs assets/demo.tape. The sample dependency simulates an attack and exfiltrates nothing β every path is a made-up filename that does not exist, the backdoor binds loopback on an OS-assigned port and closes immediately, and the two exfil targets are a.invalidhost (RFC 6761) and a203.0.113.xdocumentation address (RFC 5737) that routes nowhere (see for yourself).
π‘οΈ Hardened release by release. dephawk watches 11 capability classes across 18 interceptors, and every version closes another real bypass β 50 reproduced attack techniques blocked, and counting. Each was demonstrated against a published build before it was fixed; the running list is in the CHANGELOG. Recent additions:
import('data:β¦')attribution laundering, hard-link/symlink secret aliases,console.log(process.env)dumps, shell-rc persistence, and tamper-proofing dephawk's own audit log.
π― New in 0.7 β dephawk recognises attacks, not just capabilities. It names the concrete moves of the 2025-2026 npm worms (Shai-Hulud, ChainDrop, the axios RAT): cloud instance-metadata SSRF (
169.254.169.254& co, evasion-resistant to decimal/hex/IPv6 spellings), CI-workflow persistence (.github/workflowswrites), registry self-replication (npm publish), and β the signature every stealer shares β likely credential exfiltration: the same dependency read a secret and then reached the network. Each finding says, in one plain line, what it is and what to check.
Supply-chain attacks on npm are now routine: typosquats, hijacked maintainer
accounts, malicious post-install scripts. Static scanners (Socket, npm audit)
help, but they can't see what obfuscated or dynamically-loaded code does when it
runs. dephawk is the runtime tripwire: it doesn't guess from the source, it
watches the actual behavior.
Observe mode (default β records everything, blocks nothing):
npx dephawk run npm test
npx dephawk run node ./build.jsEnforce mode (block anything not explicitly allowed):
npx dephawk run --enforce npm start
# or: DEPHAWK_MODE=enforce npx dephawk run npm startOr wire it into any Node process directly (honours DEPHAWK_MODE):
node --import dephawk/register ./your-app.jsOn exit, dephawk prints the summary above and writes a shareable, self-contained
.dephawk/report.html.
Guard your install (the attack surface that runs before your code):
npx dephawk guard npm ciguard runs the install and watches every Node process it spawns β the
package manager itself and each dependency's preinstall/postinstall/install
lifecycle script β then prints one aggregated report attributing any
capability use to the exact package. This is where a huge share of real
supply-chain attacks fire: a malicious postinstall reads your ~/.ssh key or
NPM_TOKEN and phones home the moment you npm install, long before your app
ever starts. Add --enforce to block it. (Under the hood, run monitors one
process; guard aggregates across the whole spawned tree via a shared sink.)
The sink is the one thing dephawk defends in both modes: a lifecycle script
that tries to write to it β the obvious move for erasing its own tracks β is
refused even under observe, and the attempt is reported. Everything the
monitored program does to its own files still merely gets recorded in observe
mode. See
docs/adr/0005.
Two lines, and every install in the repository is watched:
- uses: actions/checkout@v4
- uses: KirtashDev/dephawk@v0That runs dephawk guard npm ci, attributes anything sensitive to the
dependency that did it, and writes the report to the job summary β no
permissions, no extra steps. It cannot turn a passing build red on its own: the
default fail-on is blocked, which observe mode never triggers.
When you want a gate, add the two inputs that make one:
- uses: KirtashDev/dephawk@v0
with:
command: npm ci # or: npm test, with subcommand: run
fail-on: violation # fail on what policy denies, blocked or not
upload-sarif: true # annotations on the pull requestupload-sarif needs permissions: { security-events: write } on the job, which
is why it is opt-in. Every input, including mode: enforce, a config path and
a working-directory, is documented in action.yml; the reasoning
is in docs/adr/0007.
On pinning. The action runs the dephawk release its own reference names, so the tag you choose picks the tool version too, at whatever precision you ask for:
| reference | runs | means |
|---|---|---|
@v0 |
newest 0.x |
fixes yes, major bumps no β recommended |
@v0.6 |
newest 0.6.x |
patches only |
@v0.6.3 |
exactly 0.6.3 |
fully reproducible |
| a branch/SHA | newest release | nothing to pin to |
@v0 is the right default for a security tool: an exact pin means keeping the
bypasses a later version fixed, and a floating major still never carries you
across a breaking change to the action's inputs. Pin exactly only when you need
a reproducible build, and then treat it like any other dependency β something to
update, not to forget.
Observe mode records everything and blocks nothing β which used to mean it could
never fail a build. --fail-on gives it a verdict, and --sarif turns the
findings into annotations on the pull request:
npx dephawk run --fail-on violation --sarif dephawk.sarif npm test--fail-on violation fails when policy denied anything, whether or not the
call was actually blocked β so a pull request that introduces a dependency
reading ~/.ssh goes red without you having to enforce (and break) the build
first. Levels, loosest last:
--fail-on |
fails when |
|---|---|
none (default) |
never; the exit code is the command's own |
blocked |
a call was actually prevented (enforce mode only) |
violation |
policy denied a call, blocked or not |
sensitive |
anything sensitive was touched, even if permitted |
Exit code 2 means findings reached the threshold. If the command itself failed, its own exit code is returned instead β that is the more immediate thing to fix.
Wire the SARIF into GitHub code scanning:
- run: npx dephawk run --fail-on violation --sarif dephawk.sarif npm test
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: dephawk.sarifcontinue-on-error lets the upload run even when dephawk fails the job, so the
annotations appear on the pull request that caused them. (The action above does
this for you.)
| Capability | Examples caught |
|---|---|
fs.read |
reading, listing, globbing, copying, watching or blob-opening ~/.ssh, keychains, wallets, .env |
fs.write |
overwriting or deleting ~/.npmrc, authorized_keys, other secret files |
net.connect |
http/https/fetch/http2, plus raw net/tls sockets β even a bare new Socket().connect(port, ip) β and UDP (dgram) |
net.resolve |
dns.lookup/resolve* β recon and DNS-tunnel exfil (no TCP to see) |
net.listen |
net/http/http2 servers and dgram.bind β an inbound backdoor/C2 listener |
process.spawn |
child_process.exec/spawn/fork, and worker_threads (the curl-pipe-sh) |
process.native |
process.dlopen and process.binding β raw runtime power outside the JS sandbox |
code.eval |
vm.* (incl. SourceTextModule), WebAssembly, the module-loader (_compile/require.extensions/module.register), and node:inspector β staged payloads, code injected into another package, a debugger backdoor |
process.memory |
v8.writeHeapSnapshot/getHeapSnapshot and process.report.getReport β dumping every in-memory secret and env var at once |
env.read |
a dependency reading NPM_TOKEN, AWS_SECRET_ACCESS_KEY, MYSQL_PWD, a DATABASE_URL password, β¦ β including console.log(process.env) whole-env dumps |
os.info |
os.userInfo/networkInterfaces/hostname host profiling |
Each event is attributed to the specific package that triggered it, so you know exactly who's misbehaving.
What counts as sensitive β credentials and key material, by location and by
name: ~/.ssh, ~/.aws, ~/.azure, ~/.gnupg, ~/.kube, ~/.docker,
~/.config/gcloud, ~/.config/gh (a GitHub token in hosts.yml), OS credential
stores (macOS keychains, GNOME Keyring, Windows DPAPI), crypto wallets (geth
keystores, Electrum, Solana, NEAR, Exodus, wallet.dat, browser-extension
vaults like MetaMask β the payload of choice in recent npm compromises),
browser credential stores (Chrome/Brave/Edge Login Data + Local State,
Firefox logins.json + key4.db, cookie DBs β what the 2025-26 stealer
campaigns went after), .npmrc, .netrc, .env*, .git-credentials,
.pypirc, .pgpass, .vault-token, ~/.terraform.d, *.pem/*.key/*.p12/*.kdbx
outside node_modules, /etc/passwd, and /proc/*/(environ|mem|maps) (every env
var, or the whole process memory, in one read).
Matching is case-insensitive, and listing one of those directories counts as
reading it β readdir('~/.ssh') names every key on the machine without opening
one, and readlink says where a key really lives.
Aliases don't help either: a hard link or symlink that points a mundane name
at a secret is judged by what it really resolves to, caught at the moment the
alias is made or read. And writing a shell startup file (~/.bashrc,
~/.zshrc, β¦) is flagged as persistence β a payload that would run on every
future shell β even though reading those files is left alone.
A policy answers "is this permitted?". It cannot answer the question you actually have when a pull request bumps a dependency: is this new? The interesting change is rarely a denial β it is a package that used to resolve one host and now resolves two, both of which your rules happily allow.
Record what your dependencies do today, and commit it like a lockfile:
npx dephawk run --record .dephawk/baseline.json npm testThen have CI say what changed:
npx dephawk run --replay .dephawk/baseline.json --fail-on new npm testπ¦
dephawk baseline β 2 new behaviours since the baseline was recorded
+ httpclient β read ./.npmrc
+ httpclient β dns telemetry.vendor.example
Details are canonicalised β the project root becomes ., your home directory
~ β and counts, timestamps and ordering are left out, so a run on someone
else's machine or in CI produces the same file. Without --fail-on new it
reports and exits 0; the gate is the same --fail-on used everywhere else
rather than a second way to turn a build red.
A baseline records what happened, not what is safe. Recording a tree that is already compromised makes that behaviour the norm, and every later run will agree nothing changed. Re-record deliberately, and read the diff when you do.
Enforcing is easy to describe and miserable to start: a real project has
dependencies that legitimately reach the network, shell out and read .npmrc,
and every one of them needs a rule before the first green run. So let dephawk
write the first draft from a run it watched:
npx dephawk init npm testThat writes dephawk.config.js granting exactly what happened β then
--enforce passes, and anything new a dependency starts doing gets caught.
Read it before you trust it. The draft grants what the run did, not what
is safe: dephawk cannot tell a legitimate API call from exfiltration, so if
something malicious is already installed, its behaviour is in the file too.
Every entry carries a comment saying what produced it, and grants that hand over
open-ended power (spawn, native, eval) are listed at the top for review.
Calls dephawk could not attribute to a package are reported but never granted β
the only place to put them is the default bucket, which would weaken it for
everything at once.
Paths under your home directory are written as ~/..., so the file works on
someone else's machine and in CI.
Allow only what a package legitimately needs. dephawk looks for
dephawk.config.js in the working directory (or pass --config <path>):
// dephawk.config.js
export default {
mode: 'observe', // or 'enforce'
// applied to any package not listed below
default: { net: { connect: [] }, spawn: false, env: false },
packages: {
'image-optimizer': { spawn: true }, // it genuinely shells out
'@sentry/node': { net: { connect: ['*.sentry.io'] }, env: ['SENTRY_DSN'] },
bcrypt: { native: true }, // legitimately loads a native addon
},
};net.connectβ allowlist of hosts.*.sentry.iomatches the apex and any subdomain; an exact host matches only itself. The same list gates DNS resolution (net.resolve): a host you may connect to, you may resolve.net.listenβtrueto permit opening an inbound listener (server.listen,dgram.bind). Off by default; a dependency binding a port is a backdoor, so set it only for a package whose job is to run a server. Written asnet: { connect: [...], listen: true }.spawnβtrueto permit child processes and worker threads.nativeβtrueto permit raw runtime power outside the JS sandbox: loading native addons (.nodeviaprocess.dlopen) and reaching internal C++ bindings viaprocess.binding. Off by default; set it for packages likebcrypt,sharp.evalβtrueto permit dynamic code execution: thevmmodule, WebAssembly compile/instantiate, and opening thenode:inspectordebugger. Off by default β most dependencies should never need it. (Some packages ship WASM codecs; those are the ones you may need to grant.)envβtrue(any secret),false(no secrets), or an array of allowed secret var names. Non-secret vars (e.g.NODE_ENV) are always allowed.fsβ{ read: [...], write: [...] }path prefixes for sensitive paths.
The default bucket applies to any package not listed and to calls dephawk
cannot attribute to anyone β so a deny-by-default default is what makes
laundered calls fail closed.
Your own application code is never flagged β dephawk watches dependencies, not you.
At startup (--import dephawk/register) dephawk monkey-patches the sensitive
Node built-ins β fs, http/https/fetch, raw net/tls/dgram sockets,
inbound net/http/http2 server listen, dns, child_process,
worker_threads, process.dlopen, process.binding, vm, WebAssembly,
node:inspector, node:sqlite (a database opened at a sensitive path β how the
browser-password stealers read Chrome's Login Data), heap snapshots and
diagnostic reports (v8.writeHeapSnapshot, process.report β they dump every
in-memory secret and env var at once), os, and process.env. Each patched
call captures a stack
trace, walks it to find the first node_modules/<package> frame, checks it
against your policy, and records the event. On exit it prints a summary and
writes the HTML report.
Children stay monitored. Monitoring reaches a process tree by inheritance
(NODE_OPTIONS, DEPHAWK_*), so a dependency could once blind dephawk for a
whole subtree by spawning with those stripped out. dephawk now puts them back
into every child it lets through, and the report notes when it had to
β node payload.js [dephawk re-attached: NODE_OPTIONS]. The same holds for
worker threads, which declined monitoring through { execArgv: [] } or
{ env: {} } until 0.4.3, and β for { eval: true } workers, which ignore
--import β through the --require form since 0.6.12. See
docs/adr/0006.
Deferred calls still count. A dependency cannot shed responsibility by
scheduling a built-in instead of calling it β setTimeout(fs.readFileSync, 0, '~/.ssh/id_rsa'). When the stack names nobody, the call is (unattributed) and
held to your default policy rather than treated as your own code, and
dephawk separately records where the call was scheduled so the report can still
name the package that armed it. (Before 0.3 that pattern was allowed outright,
even under --enforce β see
docs/adr/0004.)
This is an honest threat model. dephawk is a high-signal tripwire and policy layer, not an unbreakable sandbox:
- Attribution uses stack traces, and dephawk assumes a dependency will attack
the trace itself. Installing a hostile
Error.prepareStackTrace, replacingError.captureStackTrace(or theErrorglobal), settingstackTraceLimit = 0, or evaluating code with a//# sourceURLnaming another package β or naming avmscript after one β are all defeated: dephawk holds its own reference toErrortaken before any dependency loads, forces V8's own formatter and frame budget for the duration of each capture, and refuses to attribute a call to a location that evaluated code declared for itself. Losing a frame another way (native code, freezing theErrorglobals non-configurable) no longer buys trust β the call is held to the default bucket β but it can still cost you the culprit's name. - Tampering with dephawk itself is anticipated too: events are written to the
shared sink as they happen, so removing the exit handler cannot erase them,
and an inherited
DEPHAWK_MODEcan only make a child stricter, never looser. - Paths are judged by what they actually point at: a link at a mundane name pointing at a secret is resolved and caught, and so is a write into a directory that links to one.
- Native addons and internal bindings run outside the JS sandbox: dephawk flags
the
process.dlopenload and anyprocess.binding(process.native), but what native code does afterwards is invisible. eval()andnew Function()are language primitives and can't be patched; thevmmodule,WebAssembly, andnode:inspectorβ the deliberate paths for staged code and debugger backdoors β are covered. Node's own bundled WASM (undici'sllhttp, loaded on firstfetch) is recognised as runtime plumbing and left alone, so covering WASM does not breakfetch.- HTTP resolves and connects internally, so one request may surface both a
net.resolveand anet.connect; identical rows collapse in the report. - Named imports captured before startup (
import { readFileSync } from 'fs') can slip past patching; namespace/requireaccess is covered. process.envinterception is best-effort; some native reads slip through.- The report is itself an artifact worth handling carefully. It records what
was touched, not contents: env var names (never values), paths, hosts, and a
spawn's full command line. Values that look like secrets are redacted
(
--token=***,Bearer ***,ghp_***), by name and by known token shape β a heuristic, so treat absolute paths, hostnames and stack frames in.dephawk/report.html, the SARIF and the job summary as sensitive anyway.
For a hardened boundary you'd combine it with OS-level isolation (containers,
node --permission, seccomp). dephawk's job is to make the common attacks loud
and cheap to catch. That's what stops most real-world incidents. See
docs/adr/0002 for the full analysis.
We went looking for these rather than waiting to be told: Three ways out of a runtime supply-chain monitor is the write-up of attacking dephawk with its own threat model in hand β a one-line attribution bypass, a writable audit log, and monitoring that could simply be declined.
import { buildMonitor, resolveEnvPolicy } from 'dephawk';
const monitor = buildMonitor({ policy: resolveEnvPolicy(process.env) });
monitor.start();
// β¦ run code β¦
monitor.stop();
await monitor.report();Every collaborator (interceptors, reporters, attributor, clock, sink) is overridable β compose your own.
Built and tested on Node 20 & 22. Bun/Deno provide most of the same built-ins; dephawk degrades gracefully when a built-in is missing, but full coverage there is not guaranteed.
- Shareable HTML report artifact (
.dephawk/report.html) - Async config loading + host/path glob matching
-
fs.writecoverage andos.userInfo/networkInterfacesinterception - DNS (
net.resolve), raw socket/TLS/UDP, native addon (process.native),vmcode-eval (code.eval) andworker_threadsinterception - Internal bindings (
process.binding), inbound listeners (net.listen),WebAssemblyandnode:inspectorinterception β closing the raw-runtime, backdoor-port, staged-WASM and debugger escape hatches -
postinstallscript guard (dephawk guardβ catch install-time attacks before your code even runs) - CI gating:
--fail-onexit codes and SARIF output for code scanning - A published GitHub Action, so adopting it in CI is two lines
-
--record/--replayof dependency behavior for CI diffs - Policy bootstrap (
dephawk init) β draft a policy from an observed run, so enforcing does not start with a wall of hand-written denials
Published from a tagged release workflow with npm provenance, and with no publishing credential in existence: npm trusts the repository and that workflow file directly over OIDC, so there is no token to leak. Every tarball carries a signed attestation tying it to the commit and the workflow run that built it. A tool that asks you to distrust your dependencies should be checkable itself β so check it:
npm audit signaturesFound a way past dephawk? Report it privately through
GitHub's private vulnerability reporting
β please not a public issue. A fixture package that performs the abuse and gets
past the real CLI makes a report immediately actionable; that is how most of the
bypasses fixed so far were found. What counts as in scope, what is a documented
limitation, and how fixes ship: SECURITY.md.
dephawk is written and maintained by one person, so treat response times as
best-effort. Issues, security reports and real-world attack samples are all
welcome β for anything larger than a bug fix, open an issue first. See
CONTRIBUTING.md.
dephawk is free, MIT-licensed and has no paid tier β that does not change. If it caught something before it cost you an incident, you can buy me a coffee. Entirely optional; it buys no priority over anyone else's issue.
MIT Β© Alberto (KirtashDev)
