I maintain dephawk, a runtime tripwire
for npm dependencies. It patches the sensitive Node built-ins — fs, http,
child_process, process.env and friends — captures a stack trace on every
call that touches something it cares about, walks that stack to find the first
node_modules/<package> frame, and checks that package against your policy. In
enforce mode it throws instead of letting the call through.
The pitch is a screenshot: a package reads ~/.ssh/id_rsa, and dephawk names
it.
Last week I stopped reading my own code and started attacking it. It lost to
this:
setTimeout(fs.readFileSync, 0, '/home/you/.ssh/id_rsa');
That is the entire bypass. No Error.prepareStackTrace tricks, no native addon,
no obfuscation. A dependency that runs that line reads your private key, and
dephawk — in enforce mode, with a deny-everything policy — reports it as your
own code and allows it.
Why it works
Look at what is missing. setTimeout is handed fs.readFileSync itself, not
a closure that calls it. So when the timer fires, the stack is:
at readFileSync (node:fs:…)
at listOnTimeout (node:internal/timers:581:17)
at process.processTimers (node:internal/timers:519:7)
There is no frame belonging to the package, because no function of the package
is running. Its involvement ended the moment it scheduled the call.
If you write the same thing as a closure, dephawk catches it fine:
setTimeout(() => fs.readFileSync(secret), 0); // caught — the arrow is yours
setTimeout(fs.readFileSync, 0, secret); // — not caught
The difference is whether a function you wrote appears on the stack. V8 keeps
the source location of the closure, so the first form still points at your file.
The second form hands over a built-in, and built-ins have no node_modules path.
The actual bug: a sentinel that meant two things
Losing the frame should have cost the attacker a name in a report. Instead it
bought them a free pass. Here is why.
The attributor returns null when it finds no dependency frame:
attribute(rawStack: string): Attribution {
let attributed: string | null = null;
// …walk frames, set `attributed` on the first node_modules/<pkg> frame…
return { package: attributed, frames };
}
And the policy engine did this:
evaluate(req: CapabilityRequest): Verdict {
const sensitive = detectSensitive(req);
if (req.package === null) {
return { allowed: true, sensitive }; // ← here
}
const pkg = this.policy.packages[req.package] ?? this.policy.default;
return evaluateCapability(req, pkg, sensitive);
}
Both of those are individually reasonable.
null for "no package frame found" is the obvious return value. And allowing
null unconditionally comes from a real product decision that is right: your
own application code is never flagged — dephawk watches dependencies, not you.
A security tool that screamed every time you read a .env file would be
uninstalled by lunchtime.
The bug lives in the seam between them. null was carrying two meanings:
- "this is the application" — trust it
- "attribution failed" — no idea who this was
and the engine picked the friendlier reading for both. Every capability was
affected — filesystem, network, spawn, environment variables — and
.then(fs.readFileSync) gets you the same result through promises.
The part that actually stings
None of this was hidden. My own architecture decision record, written when
attribution was designed, lists the ways out:
Attribution is high-signal, not tamper-proof. A determined attacker can:
[…] defer work to a detached callback/timer so the originating frame is gone;Async gaps can also drop the originating frame, yielding
package: null
(attributed to "your code"). We accept these limits and state them plainly in
the README.
I had written down the behaviour. I had even written down the consequence —
"attributed to your code" — and then stopped one sentence short of asking what
the policy engine does with that.
Writing the limitation down had made it feel handled.
That is the bit worth stealing from this post. An accepted limitation is a
decision, and decisions age. This one was genuinely fine on the day it was
written, and stopped being fine the moment package: null became load-bearing
for a trust decision. Nobody re-read it, because it was already in the docs,
and things in the docs feel settled.
The fix
Two changes, and only the first one is a security fix.
First: attribution now has three answers, not two
type Origin = 'dependency' | 'application' | 'unknown';
A frame naming a real source file means the application. Runtime internals,
native and anonymous frames only means nobody could be identified. Only
application keeps the unconditional allow — unknown gets evaluated against
the default policy bucket, exactly like an unlisted package.
That alone closes it. With any deny-by-default config, the laundered read is
denied. Losing your name now costs you the benefit of the doubt instead of
granting it.
Second: making the report useful again
Denied-but-anonymous is safe and useless — the report says (unattributed) and
you still don't know which dependency to remove. So a scheduler interceptor
patches setTimeout/setInterval/setImmediate, queueMicrotask,
process.nextTick and Promise.prototype.then. When the callback handed to one
of them is an intercepted built-in, it captures the stack at the scheduling
site — where the culprit is still plainly visible — into an AsyncLocalStorage
context that rides along with the callback.
The cost, measured rather than guessed: stacks are only captured for that
specific shape, so ordinary scheduling pays one WeakSet lookup per callback
argument. On Node 22 that is +22 ns per explicit .then(). And await on a
native promise doesn't route through Promise.prototype.then at all — I checked:
0 interceptions across 10,000 awaits — so async/await-heavy code pays nothing
there.
Two more, once I was looking properly
Finding one bypass by attacking instead of reading strongly suggested doing more
of it. Two more fell out:
The audit log was writable by the thing being audited. dephawk guard
aggregates every process an install spawns into one shared JSONL file, and the
path travels in an env var so it can reach npm's lifecycle scripts. A malicious
postinstall needed one call:
fs.truncateSync(process.env.DEPHAWK_SINK, 0);
Reproduced: a script that read an SSH key, followed by one that wiped the sink,
produced a clean report.
Monitoring spread by inheritance, so it could be declined. dephawk reaches a
process tree via NODE_OPTIONS. Anything you can inherit, you can decline to
pass on:
const clean = { ...process.env };
delete clean.NODE_OPTIONS;
spawnSync(process.execPath, ['payload.js'], { env: clean });
The spawn was recorded. Everything the child did afterwards was invisible.
The fix there is interesting because it isn't to block it: building a clean
environment for a child is completely legitimate — reproducible builds do
exactly this — and nothing distinguishes it from an escape attempt. So dephawk
puts the missing pieces back into every child it lets through, and notes it in
the report.
What I'd take away from this
A sentinel that means two things will eventually mean the wrong one. null
for "the application" and null for "I don't know" were the same value right up
until something important depended on telling them apart. The fix wasn't
cleverness — it was a third case.
Fail closed on ignorance. "I couldn't figure out who did this" should cost
trust, not grant it. When your system can't answer a question it needs answered,
the safe default is the answer that assumes the worst, not the one that's
quietest.
Re-read your own accepted limitations. This was documented as acceptable in
the file whose entire job is recording that kind of decision. Nothing prompts you
to revisit something that already looks settled.
Attack it, don't audit it. All three of these came from trying to defeat the
tool. None came from reading the code looking for bugs — the code was well
covered by tests, and all three bypasses passed every single one. Tests written
from the design will confirm the design.
Full write-up with the reasoning and the architecture decision records:
Three ways out of a runtime supply-chain monitor
And none of this makes dephawk a sandbox — attribution still rests on stack
traces, native addons run outside the JS surface entirely, and eval() can't be
patched. It's a high-signal tripwire, not a boundary. What changed is that the
cheapest way out, the one that needed no privileged position and read like
perfectly ordinary async code, is closed.

Top comments (0)