0x00
Node.js · module loading · distribution

Ship the Tree

node:vfs zip in node:zlib --vfs-load 🌳 @pipobscure/bundle

Philipp Dunkel  ·  @pipobscure.com  ·  pip@pip.fyi

github.com/pipobscurepress ? for controls
Where this comes from

What kept going wrong.

Registry instability

Something you don't run, in the path of every deploy.

Version ≠ code

Knowing what you asked for is not knowing what you got.

Supply-chain compromise

Routine now. Not exotic, not rare, not somebody else's problem.

Distribution friction

Shipping a tool means shipping an install procedure, and then supporting it.

Fixes in the wrong place

A library only reaches people who already knew they had the problem.

Picked up over the years, in a few different jobs. The next few slides are where some of them came from.

the recurring ones
durchblicker.atCTO · 2009–2014

Our deploys depended on
somebody else's uptime.

The pipeline ran npm install against the public registry at deploy time. So shipping was gated on a service we didn't run, with an SLA that wasn't ours.

# deploying, 16:40 on a Tuesday $ npm install --production npm ERR! fetch failed https://registry.npmjs.org/... npm ERR! 503 Service Unavailable # the code hadn't changed. the build had.
npm incidents by month — from npm's own status page
npm incidents by month, Oct 2023 to Jul 2026 0 2 4 2023-10: 1 minor 2023-10: 1 major/critical 2023-11: 3 minor 2023-12: 1 minor 2023-12: 1 major/critical 2024-01: 1 minor 2024-04: 1 minor 2024-05: 1 minor 2024-06: 2 minor 2024-06: 2 major/critical 2024-08: 1 minor 2024-09: 1 minor 2024-10: 1 minor 2024-11: 1 minor 2024-12: 1 major/critical 2025-01: 1 minor 2025-04: 2 minor 2025-04: 1 major/critical 2025-06: 1 minor 2025-06: 1 major/critical 2025-08: 2 minor 2025-10: 3 minor 2025-11: 2 minor 2025-12: 1 major/critical 2026-01: 1 minor 2026-02: 2 minor 2026-03: 4 minor 2026-03: 1 major/critical 2026-04: 4 minor 2026-04: 1 major/critical 2026-06: 1 minor 2026-07: 2 minor 2026-07: 1 major/critical 2023 2024 2025 2026 minor major / critical
The 50 most recent incidents npm publishes, Oct 2023 – Jul 2026: one every 20.4 days, 11 of them major or critical. source: status.npmjs.org/api/v2/incidents.json
01The registry is not a build input. Don't reach for the network at deploy time.
02Reproducible, known versions are worth real effort. Resolve once; ship what you resolved.
example β€” the registry in the deploy path01
0x02I Β· where this comes from
Bloomberg LP2014–2026i — provenance

“What is running on that
host?” needs a real answer.

  • Known software. "A dependency tree resolved at some point by some pipeline" is not an answer anyone can act on.
  • Controlled software. Someone is accountable for every byte, and must be able to say who put it there.
03Ship the artifact, not the recipe. The tested bytes are the shipped bytes.
04Supply-chain attacks are real and routine. Knowing the version is not knowing the code.
2016-03left-pad unpublished. Thousands of builds break. Not an attack — a preview.
2018-11event-stream. A helpful new maintainer, a couple of benign PRs, publish rights — then a wallet stealer.
2021-10ua-parser-js. Account takeover. ~8M weekly downloads shipping a miner and a credential stealer.
2024-03xz-utils. A multi-year social-engineering campaign for one backdoor.
2025-09Shai-Hulud. Self-replicating: steals CI credentials, then uses them to publish itself onward.
30,930
and counting

malicious npm package records in OpenSSF's public database. Mostly typosquats and spam — but that is the background rate the five above had to hide in.

example β€” knowing what you run02
0x03I Β· where this comes from
Bloomberg LP2014–2026ii — distribution

A CLI tool is one file,
or it is a support ticket.

# shipping a Node CLI, usually $ git clone … && cd tool $ nvm use 20 # "works on 18?" no $ npm ci # hundreds of MB $ npm link # now it's on PATH. somewhere. # what it should be $ curl -O …/tool && chmod +x tool $ ./tool
05Deploying a CLI is far better when the whole app is a single file.
06Better still if that file executes itself — a shebang, not a wrapper script.

“Which Node do I need?”

Bring your own. One file doesn't care where it sits.

“Where do I put it?”

Anywhere. chmod +x. Done.

“Is this the current build?”

One file has one hash. Compare it.

example β€” getting it to people03
0x07II Β· the primitive
Why this needed the runtime at all

An application is a tree.
Not a blob.

Bundlers collapse JavaScript into one file. An application is an entry point, sibling modules, a package.json, templates, static assets, sometimes a native addon.

# survives a bundler import helper from './helper.js' # does not readFileSync(join(__dirname, 'tmpl/mail.html')) require(`./plugins/${name}`) dlopen('./build/Release/addon.node')

Node's module resolution — and every single fs call — assumes that tree is on a real disk. A bundler rewrites its slice of module loading; everything else keeps pointing at directories that aren't there.

Don't teach the bundler about more file types.

Teach the runtime that a file can be a directory.

Then assets work. Addons work. __dirname works. Dynamic require works. Not because anyone special-cased them — because they all resolve against a tree, and the tree is real.

the reframe07
0x04I Β· where this comes from
TC39 · Temporal~10 years

Standardise the primitive.
Never the policy.

Temporal put instants, durations, calendars and the arithmetic between them into the language. It did not put your business rules there.

Nobody standardised what counts as a working day, or which timezone your invoices settle in. Those change. Those are yours.

Ten years also taught me the other half: a standard is slow, and then it is permanent. That's correct for a primitive. It is fatal for anything that has to keep up with an adversary.

07Fundamental concepts work better upstream, as a shared primitive everyone can build on.
08Everything that must evolve stays out of the standard, where it can be replaced.
Applied here

Primitive: an application can be loaded and run out of an archive.

Policy: whether you should trust this particular archive.

The first belongs in Node. The second must not.

example β€” where a fix belongs04
0x05I Β· where this comes from
Prior artjar · apk · zipapp

Everyone built this already.
Study it. Don't copy it.

Java, 1996

.jar

A zip plus a manifest of per-entry checksums, and a signature over those. Not over the archive.

Android

.apk

Inherited jar signing wholesale, and then had to replace it. Three times.

Python

zipapp

A zip with a shebang glued on the front. Runs directly. No integrity story at all.

09Prior art is valuable — the shape is right, and it has been right for thirty years.
10Prior art should not constrain. These are similar to what we want. They are not identical, and their mistakes are instructive.
example β€” what others already tried05
0x06II Β· the primitive
Sorting the takeaways

Which of these belongs
in the runtime?

In Node
  • Run a program out of an archive — entry point, modules, assets, addons.
  • Let an archive live inside another file, after a shebang.
  • Let userland decide what backs a mount.

Mechanism. Stable for decades. Nothing to keep up with.

Not in Node
  • Which hash and which signature algorithm.
  • Where the signature lives in the file.
  • Whose certificates you trust, and how you anchor them.
  • What happens when verification fails.

Policy. Adversarial. Must be replaceable.

The left column is what merged. The right column is a library I wrote, and you could write a different one.

act II β€” mechanism, not policy06
0x08II Β· the primitive
Piece 1 · zip support in node:zlib

baseOffset — a zip that
doesn't start at byte zero.

ONE FILE ON DISK prefix a shebang line a completely valid zip archive members Β· central directory Β· EOCD baseOffset without it every internal offset is off by len(prefix). your unzip refuses it. with it offsets seeded past the prefix. still a valid zip. unzip app.nzip works.
One small primitive. It is the entire reason "append and go" works.

Zip records offsets internally. Seed them with a base offset and the archive stays valid even when it is not at the start of its file.

  • Append it after a shebang line → a self-executing archive.
  • It is still a zip. Every tool you already have still opens it.
  • Read paths enforce size limits and reject malformed records — zip-bomb and corrupt-input guards, with real error codes.

No custom container format. No injection tool. Concatenation.

ZipEntry Β· ZipFile Β· ZipBuffer Β· createZipArchive()08
0x09II Β· the primitive
Piece 2 · mount and load

Resolve the program
against the archive.

$ node --experimental-vfs \ --vfs-load=app.zip
  • --vfs-load=src mounts src. The provider comes from the source, not the name: a directory gets RealFSProvider, zip bytes get ZipProvider.
  • And runs it: the entry point comes out of the mount, and every later require / import resolves against it. The archive's own package.json "main" decides what runs.
  • Resolution primitives were rerouted through a VFS-aware path that defers to the real bindings when nothing is mounted — so unmounted behaviour is unchanged.
And then the good bit

--vfs-load takes the entry point from the mount, never from a positional. So a trailing --vfs-load with no value takes whatever the kernel appends —

#!/usr/bin/env -S node \ --experimental-vfs --vfs-load

— which is the script's own path. The file mounts itself and runs its own embedded main.

argv[1] is the source, not the mount point, so the file can still read its own bytes — which is how a launcher verifies itself. Worker threads inherit the mounts.

node:vfs foundation by Matteo Collina09
0x0AII Β· the primitive
Piece 3 · the extension point

registerProvider() — you
decide what backs a mount.

vfs.registerProvider({ name, canHandle(resolvedPath, stats), create(resolvedPath, stats) });
  • Registered providers are consulted before the built-ins — RealFSProvider and ZipProvider are just the last two entries.
  • Selection happens after preload modules have run. So a -r module can install a provider for the source that is about to be mounted.
  • Yours sees every source — directories included — and outranks the built-ins, so it can wrap or vet, not just add formats.
This is the whole trick

Node ships a hole, not a policy. It says: here is where a decision could go, and it is none of my business what you put there.

verify a signature

the rest of this talk

record what's read

build a manifest by observation

decrypt

or fetch, or cache, or audit

something I haven't thought of

which is rather the point

mechanism, supplied. policy, yours.0A
0x0BII Β· the primitive
One of the design decisions

Node defines no signature
format. On purpose.

What happens when the platform owns signing
v1jar signing. Per-entry checksums in a manifest. The archive's own structure isn't covered.
2017Janus. Hide code in the space v1 doesn't hash. It verifies; a different part of Android runs the hidden code.
v2Android 7. Whole-file hash. Fixes it — and needs a new OS release to ship.
v3Android 9. Key rotation. Another OS release.
v4Android 11. Streaming installs. Another one.
Four schemes. Four platform releases. A device that can't take an OS update can't take the fix.

Bake a signature format into the runtime and you have promised to be right about cryptography forever, on the release cadence of a runtime.

  • You get stuck. Node 26's blessed format is still Node 26's blessed format in 2032.
  • There is no single right trust model. An internal CA, Sigstore and OIDC, pinned keys in an air-gapped site — all legitimate, all different.
  • One blessed format crowds out the rest. Whatever ships in core wins by default, however wrong it turns out to be.

Leave the hole, and upgrading your signature scheme is a dependency bump — not an OS release, not a Node major, not a five-year deprecation.

why the format stays in userland
0x0CIII Β· the optional layer
One policy, entirely in userland

So here's the scheme
I happen to like.

prefix shebang members app.js, package.json, assets… AUTHORITY.PEM algorithms + cert chain central directory a content digest per member EOCD SIGNED:<hash>:<sig> the EOCD comment THE HASHED REGION ↑ stops short, so the signature can live here One hash over the whole file β€” prefix, members, the chain, and the central directory including every member's digest.

Learned from jar

The central directory is inside the hash, so the member list is signed. You can't add an entry.

Learned from apk v2

Whole-file, not per-entry. There is no gap between structures to hide in.

Four states, not two

unsigned invalid untrusted valid

a library. not a standard. replaceable.0C
0x0DIII Β· the optional layer
Act III

Demo.

01

Bundle & run

Discover the file list by observation, then ./app.nzip.

02

Sign & verify

Sigstore, and all four states including a flipped byte.

03

Opt in to refusing

Install the provider and watch the mount decline.

04

It's just a zip

Extract it and read what you were actually sent.

05

Audit it with Claude

Verify → extract → review the tree for malicious code.

~7 minutes0D
0x0EIII Β· the optional layer
Demo 01

Observe, bundle, execute.

$ npm i -D @pipobscure/bundle # 1. what does this app ACTUALLY read? $ BUNDLE_MANIFEST=app.manifest node --experimental-vfs \ -r @pipobscure/bundle/record \ --vfs-load=./app -- Ada hello, Ada $ cat app.manifest package.json index.js lib/hello.js # lib/never-used.js: absent

Not static analysis. The app runs behind a recording provider — another use of that same extension point — which writes down every file it opens.

# 2. archive exactly that, then sign it $ bundle create --base ./app --launcher \ -f app.manifest -o app.unsigned.nzip + index.js + lib/hello.js … * unsigned archive (3 members) $ bundle sign app.unsigned.nzip * signed: da9e7604113a…
# 3. what did we just make? $ head -c 77 app.nzip #!/bin/sh exec node --experimental-vfs \ --vfs-load="$0" -- "$@" $ ls -l app.nzip -rwxr-xr-x 1894 app.nzip
# 4. it's a program. $ ./app.nzip Grace hello, Grace

Two kilobytes for this one. No install, no node_modules, no wrapper — the file mounts itself and runs its own main.

observe β†’ bundle β†’ ./app.nzip0E
0x0FIII Β· the optional layer
Demo 02 · 03

Sign it. Break it.
Then opt in to caring.

# signed via sigstore β€” identity, not a key file $ bundle verify app.nzip VALID β€” signature good, certificate trusted identity: github.com/pipobscure/… (GitHub Actions)
# flip exactly one byte, anywhere $ printf '\x00' | dd of=app.nzip bs=1 seek=9000 \ conv=notrunc $ bundle verify app.nzip INVALID β€” archive hash does not match the recorded hash
# and Node does not care in the slightest $ ./app.nzip Grace hello, Grace # ← runs fine

That last one is not a bug. Node was asked to run a zip, so it ran a zip. Caring is something you switch on.

# now mount it through a provider that has an opinion # (the same as: node -r @pipobscure/bundle/register \ # --experimental-vfs --vfs-load=app.nzip) $ bundle run app.nzip Grace error: refusing to run 'app.nzip': invalid β€” archive hash does not match the recorded hash

Refused before it becomes a filesystem — the entry point is never resolved, so nothing inside it runs. Not because Node objects, but because you loaded something that does.

policy is a preload away0F
0x10III Β· the optional layer
Demo 04

You can just… open it.

$ unzip -o app.nzip -d ./inspect inflating: inspect/index.js inflating: inspect/lib/hello.js inflating: inspect/package.json inflating: inspect/AUTHORITY.PEM ← who signed it $ head -3 inspect/AUTHORITY.PEM !manifest 2 !hash sha256 !sign sha256

A signed container you cannot inspect asks you to trust the signature and the vendor.

One you can open with unzip lets you trust the code.

Which is why the chain is a real, extractable filename and not a magic footer — a plain zip utility on a machine with none of this installed can pull it out.

inspectability is a property, not a bonus10
0x11III Β· the optional layer
Demo 05

Provenance says who.
Now ask what.

1 Β· OBSERVE run it; write down every file it reads 2 Β· CREATE archive exactly that — unsigned 3 Β· AUDIT — read every member load-time hooks Β· encoded payloads Β· odd network env and credential reads Β· CI tokens Β· eval β†’ a verdict, pinned to the archive's sha256 4 Β· SIGN refuses without a clean verdict over these bytes

A bundle is a closed set. Nothing resolves later; nothing is fetched at install. That is the shape a review can actually be complete over.

So the review is a step, not an afterthought — and it sits before the signature, because a signature is a claim about bytes you stand behind.

$ bundle audit --check app.unsigned.nzip \ && bundle sign … error: app.unsigned.nzip has not been audited

The verdict names the archive's sha256, so rebuilding invalidates it. Otherwise the gate approves any later build on the strength of one earlier approval — which is every “review completed” checkbox ever.

observe β†’ create β†’ audit β†’ sign11
Demo 06

The tool ships as
a bundle of itself.

# package.json β€” the whole bin entry "bin": { "bundle": "bundle.nzip" } # so npm links the artifact itself $ ls -l node_modules/.bin/bundle bundle -> ../@pipobscure/bundle/bundle.nzip $ head -c 77 node_modules/.bin/bundle #!/bin/sh exec node … --vfs-load="$0" -- "$@"

No wrapper script. 904 members, sigstore included — the dependency tree stops being something you fetch and becomes a fixed set /audit-bundle can read in full.

If the model doesn't hold for this tool, it doesn't hold for anything. So it doesn't get an exemption.

# version N verifies version N+1 $ bundle verify \ --identity '…/publish.yml@refs/heads/main' \ --issuer 'token.actions.githubusercontent.com' \ bundle.nzip

Pinned to the release workflow, not to me. Running it doesn't check it — a #! carries no preload. Checking is a separate act, with a copy you already trust.

no lock file. there is no install.
Demo 6 · shipping it to someone else

One signed file,
every machine that has Node.

# publish: the release *is* the file $ bundle sign app.unsigned.nzip # -> app.nzip $ gh release create v1.0.0 app.nzip
# install: fetch, verify, then put on PATH $ node --vfs-load ./bundle.nzip install # -> bundle $ bundle install https://…/download/app.nzip VALID — signed by …/release.yml@refs/heads/main $ app # and later: bundle update

Nothing runs to install it. Nothing lands on disk that did not verify. The signer is recorded, and an update that changes identity is refused.

Why this is now portable

JavaScript in the archive is architecture-neutral. Native code is not — so carry all of them, and bind the one this machine needs at startup. Still one file, one signature, one audit.

lib/linux-x64/core.so lib/darwin-arm64/core.dylib # dlopen(`lib/${process.platform}-${process.arch}/…`)

The extension is load-bearing on Windows only: install registers .nzip so the name alone runs it. Unix uses the #! prefix and ignores the extension. Same bytes everywhere.

no installer. no install step.
0x12IV Β· close
Status

This is in Node.

Work packageWhereWho
node:vfs, and module loading
the subsystem, and resolving a program out of a mount
v26.4Matteo Collina
Zip support in node:zlib
ZipEntry / ZipFile / ZipBuffer, baseOffset
v26.8Philipp Dunkel
Zip VFS provider
mount an archive as a file tree
v26.9Philipp Dunkel
--vfs-load, registerProvider
mount it, run the program out of it, choose what backs it
v26.10Philipp Dunkel
Native addons from a mount
dlopen wants an inode; a VFS path has none
v26.9Philipp Dunkel
Signing, verifying, recording
the optional layer — and deliberately so
userland@pipobscure/bundle
SEA assets behind a VFS mount
what makes a self-verifying executable tidy
v26.9Matteo Collina

Still behind --experimental-vfs. All of it is released: a stock Node 26.10 runs every demo in this talk.

mechanism landed. policy is yours.
0x13IV Β· close
Before anyone else says it

What this is not.

VFS is not a sandbox

It redirects fs calls whose resolved path falls under a mount. It does not confine untrusted code. Node's own docs say so.

Verification is not confinement

A checked mount proves the code is what someone you trust signed. Once it runs it has the full authority of the process. Provenance, not privilege.

The check is opt-in — by design

Drop the -r and the archive mounts unverified. That is not a hole to close; it is the consequence of Node not owning your trust policy.

My scheme will age badly

So will yours. That's the whole argument for keeping it out of core, and I'd be a hypocrite to pretend otherwise.

And it is experimental: a flag, an API that can still move, and a userland package at version zero.

honest limitations13
0x14IV Β· close
What I actually want

An application in one file.
Self-executing, checkable,
and fully known.

Node supplies the loading. The archive format, the signing, the verifying and the inspecting are ordinary code on top — which means you can replace any of it, including mine.

Take it and build something. A different signature scheme, an audit step, a provider that does something I haven't thought of.

The tooling

@pipobscure/bundle

The code

github.com/pipobscure/bundles

Me

@pipobscure.com  ·  pip@pip.fyi

Philipp Dunkel14
0x15IV Β· close
Two, if we're lucky

Questions?

🌳 @pipobscure/bundle github.com/pipobscure/bundles @pipobscure.com pip@pip.fyi
thank you15
0:00