Zero Dependencies: How Bunker Keeps Outside Code Out of the Build

On October 21, 2026, Node.js fixed ten security flaws. The most serious one, CVE-2026-111497, is an out-of-bounds write in the Brotli decoder. Brotli is a compression format for web traffic. The flaw can give an attacker remote code execution when Node.js decompresses hostile Brotli data.

In the same year, attackers took over the accounts of npm maintainers. They published bad versions of popular packages. Some versions spread like a worm: an install script steals tokens and then publishes more bad packages.

For a typical JavaScript project, each event starts a long audit of packages, build computers and Node.js versions.

For Bunker, the check took one test run. Bunker is the free, offline scanner that shows which crypto addresses expose their public key. The project has zero npm packages, and a gate controls the Node.js version of every tool.

This article shows each control of the Bunker supply chain, with the code from the project and real runs.


Two parts, two risks

The supply chain of a project is all the outside code and tools that go into the file that users get. Bunker has two parts, and each part has its own risk:

PartWhere it runsUses Node.js?Risk
The page (one HTML file)In the browser of the userNoA changed file
The tools (build, tests, link check)On the computer of the developer and in CIYesA bad Node.js version or a bad npm package on the build computer

CI means continuous integration: GitHub runs the build and the tests on every push.

The user never runs Node.js. A Node.js flaw can reach a user only if it changes the file that I publish. Every control below protects that one file.

Zero dependencies, enforced by a test

Bunker needs many checksum functions: SHA-256, SHA-512/256, Keccak-256, BLAKE2b, CRC-32 and more. Libraries exist for all of them. Decision record ADR-003 compares three options:

OptionForAgainst
A. npm libraries, for example @noble/hashesWell tested; less code to writeA compromised package can steal data; many packages pull more packages
B. The browser WebCrypto APIBuilt in; fastNo Keccak, BLAKE2b or SHA-512/256
C. Small functions of the project, tested against reference valuesNo supply chain; every line is in the repositoryMore code to write and test

I chose option C. Each function is 15 to 97 lines long, so a reviewer can read it in one sitting. Each one is tested against the Node.js crypto library or against published test values.

A rule that nobody checks does not last. So a test enforces it, in test/supply-chain.test.js:

test('the project has no npm dependencies of any kind', () => {
  for (const k of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'bundleDependencies', 'bundledDependencies']) {
    assert.ok(!(k in pkg), 'package.json has ' + k);
  }
  assert.ok(!existsSync('node_modules'), 'node_modules exists');
  if (existsSync('package-lock.json')) {
    const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'));
    assert.deepEqual(Object.keys(lock.packages || {}).filter(k => k !== ''), [], 'package-lock.json lists packages');
  }
});

The test does three checks, in this order:

  1. The loop looks for six kinds of dependency fields in package.json. If one of them exists, the test fails.
  2. The test fails if a node_modules folder exists. That folder is where npm puts installed packages.
  3. If a lock file exists, it must list no packages. The empty key '' is the project itself, so the filter removes it.

Here is the result on a fresh copy of the repository. I removed the local folder path from the first line:

$ npm ls --all
[email protected]
`-- (empty)

The dependency tree is empty. npm has nothing to install, nothing to update and nothing for an attacker to replace.

A second test, “code imports only Node.js built-ins and project files”, reads every import line in the code. Each import must start with node:, ./ or ../. A hidden package import fails the build.

Four npm settings for a project with no packages

Zero dependencies is the main control. The .npmrc file adds a second control. It acts only if somebody adds a package later:

ignore-scripts=true
engine-strict=true
save-exact=true
fund=false
SettingWhat it does
ignore-scripts=truenpm never runs the install scripts of a package. The npm worm spread through exactly these scripts.
engine-strict=truenpm stops if the Node.js version does not match the engines field in package.json.
save-exact=truenpm records exact versions. A package cannot move to a new version without a commit.
fund=falsenpm does not print funding messages.

The test “npm is set to never run package scripts” fails if the first two lines disappear. A third test fails if package.json gets an install-time script, such as postinstall.

A build script of its own

The page is built from 55 small modules. The user gets one HTML file. Something must join the modules into that file.

Most projects use a bundler such as esbuild, Rollup or Vite. A bundler brings its own dependencies, and its output is hard to read. So tools/build.js does the job in 185 lines, with Node.js built-ins only.

The script also enforces strict rules on module syntax. Here is the loop that reads each module:

  for (const line of text.split('\n')) {
    if (line.startsWith('import ')) {
      const m = IMPORT_LINE.exec(line);
      if (!m) fail(relative(ROOT, file) + ': unsupported import form: ' + line);
      imports.push(resolve(dirname(file), m[2]));
      continue;
    }
    if (/^export (default|\{|\*)/.test(line)) fail(relative(ROOT, file) + ': only "export const|function|let" is allowed: ' + line);
    // Type notes in comments may say import('...'). Real code may not.
    const code = /^\s*(\/\/|\/?\*)/.test(line) ? '' : line.replace(/\/\*.*?\*\//g, '');
    if (/\bimport\s*\(/.test(code)) fail(relative(ROOT, file) + ': dynamic import is not allowed');
    body.push(line.replace(/^export /, ''));
  }

Read the loop from top to bottom:

  1. An import line must have one exact form: import { a, b } from './file.js';. Any other form stops the build.
  2. Only export const, export function and export let are allowed. The forms export default, export { } and export * stop the build.
  3. A dynamic import() stops the build. Code that loads at run time is a security risk, and a reviewer cannot see it in the file.
  4. The loop ignores comments before this check. A type note such as import('../model.js') in a comment does not stop the build.
  5. Every other line goes into the body, without its export keyword.

I tested both rules on a temporary copy of the project. Each time, I added one forbidden line to src/core/crc16.js and ran the build:

$ node tools/build.js
Error: build: src\core\crc16.js: only "export const|function|let" is allowed: export default 42;

$ node tools/build.js
Error: build: src\core\crc16.js: dynamic import is not allowed

The build stops with the file name and the reason, before it writes any file. The script does security work too. It computes SHA-256 hashes of the script and the styles, and writes them into the Content Security Policy of the page. Article 7 explains that policy.

JavaScript with type notes, and BigInt for 64-bit math

The language choice follows the same logic. Decision record ADR-002 compares JavaScript, TypeScript, Rust compiled to WebAssembly, and Python or Go.

I chose JavaScript with type notes in comments. “View source” then shows exactly the code that runs. Editors such as VS Code still check the types, with no compiler.

One detail shows the trade-off. Keccak-256, BLAKE2b and SHA-512 use 64-bit words, but JavaScript numbers are exact only up to 53 bits. Two 32-bit numbers for each word would be faster. That code is also two or three times longer, and its carries are easy to get wrong.

So the hash code uses BigInt, and it follows the specification line by line. BigInt is about 10 to 50 times slower. Each address needs only a few hundred bytes of hashing, so the cost stays below one millisecond.

The file test/core.test.js compares each function with the Node.js crypto library or with published values. On October 27, 2026, all 11 core tests passed. Two examples are “Keccak-256 known values” and “SHA-512 and SHA-512/256 match Node crypto”.

The Node.js version gate

The tools still need Node.js. Before October 2026, package.json said engines: >=20. That setting allowed Node.js 20, which reached end of life in April 2026 and gets no fix for CVE-2026-111497.

Problem P-23 in the build log lists three options:

OptionForAgainst
A. Change engines onlyOne linenpm only warns, and the build script ignores the field
B. The actions/setup-node action in CICommonA third-party action
C. A gate script, engine-strict, .nvmrc, and a hash check of the official build in CIFails early on every computerOne table to update after each release

I chose option C. The gate is tools/check-node.js:

/** The first patched release of each supported line. Update this table after each Node.js security release. */
export const PATCHED = Object.freeze({ 22: '22.23.4', 24: '24.22.1', 26: '26.12.1' });
// ...
export function nodeVersionProblem(version) {
  const v = parts(version);
  if (!v) return 'Unknown Node.js version "' + version + '".';
  const [major] = v;
  const newest = Math.max(...Object.keys(PATCHED).map(Number));
  if (major > newest) return null;
  const fixed = PATCHED[/** @type {22 | 24 | 26} */ (major)];
  if (!fixed) return 'Node.js ' + version + ' is not a supported release line. Install Node.js ' + PINNED + ' (LTS) from https://nodejs.org.';
  const p = parts(fixed);
  if (p && !atLeast(v, p)) return 'Node.js ' + version + ' has known security flaws. Install Node.js ' + fixed + ' or newer, or ' + PINNED + ' (LTS), from https://nodejs.org.';
  return null;
}

The function answers one question: is this version safe for the tools? It works in four steps:

  1. PATCHED lists the first patched release of each supported line: 22.23.4, 24.22.1 and 26.12.1.
  2. A major version newer than the table passes. The test suite uses 28.0.0 as an example.
  3. A line that is not in the table fails. This covers old lines such as 20, and the odd lines 23 and 25.
  4. A version below the patched release of its line fails, with the version to install.

Here is the gate at work. The first line is a real run of the script. The other four lines call the same function with sample versions:

$ node tools/check-node.js
Node.js 22.23.4 is a patched release.

24.22.0 -> Node.js 24.22.0 has known security flaws. Install Node.js 24.22.1 or newer, or 24.22.1 (LTS), from https://nodejs.org.
20.19.5 -> Node.js 20.19.5 is not a supported release line. Install Node.js 24.22.1 (LTS) from https://nodejs.org.
23.11.0 -> Node.js 23.11.0 is not a supported release line. Install Node.js 24.22.1 (LTS) from https://nodejs.org.
24.22.1 -> null

The value null means “no problem”. The commands npm run build and npm run check run the gate first, and so does the link check. The CLI and the MCP server only print a warning.

CI with no third-party actions

GitHub Actions workflows often use actions from a marketplace, such as actions/checkout. A workflow that uses a tag such as @v4 runs whatever code the tag points to on that day. Attackers have changed tags of popular actions to steal secrets.

So the two Bunker workflows use no third-party action. They get the code with plain git. They install Node.js with curl and check its hash. Here is the step from .github/workflows/ci.yml:

      - name: Install Node.js 24.22.1 (official build, SHA-256 pinned, no third-party action)
        run: |
          cd "$RUNNER_TEMP"
          curl -fsSLO https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz
          echo "6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6  node-v24.22.1-linux-x64.tar.xz" | sha256sum -c -
          tar -xJf node-v24.22.1-linux-x64.tar.xz
          echo "$RUNNER_TEMP/node-v24.22.1-linux-x64/bin" >> "$GITHUB_PATH"
  1. curl downloads the official Node.js 24.22.1 build from nodejs.org.
  2. sha256sum -c compares the file with the hash that is written in the workflow. If one byte differs, the step fails and the job stops.
  3. Only after the check does the job unpack Node.js and add it to the path.

Each workflow also sets permissions: contents: read. The job token can read the repository, and it can do nothing more. A test fails if a workflow contains uses:, which is how a workflow calls an action.

A link check that never decompresses

One tool uses the network: the weekly explorer link check. It opens one link for each explorer pattern and reports the broken ones. The page itself never uses the network.

The first version used fetch(), which decompresses Brotli responses. A hostile or hacked explorer could send bad Brotli data to the CI runner. That is the attack path of CVE-2026-111497.

A patched Node.js closes the flaw. Problem P-24 rejects “trust the patched Node.js” as the only control, because it is one defense only. Here is the new request code in tools/check-links.js:

function statusOf(url, hops = 0) {
  return new Promise(done => {
    const req = request(url, { method: 'GET', timeout: 20000, headers: { 'user-agent': 'nosis-bunker-link-check', 'accept-encoding': 'identity' } }, res => {
      const code = res.statusCode || 0;
      const next = res.headers.location;
      res.destroy();
// ...
      done(String(code));
    });
  1. The request function comes from node:https. It never decompresses a response on its own.
  2. The header accept-encoding: identity asks the server to send the data with no compression.
  3. The callback reads two values only: the status code and the redirect address.
  4. res.destroy() closes the response before the code reads the body. The body never reaches a decoder.

A hostile server can ignore the request for no compression. That does not matter here, because nothing reads the body. The test “the tools never decompress data from the network” fails if a tool uses fetch(), node:zlib or a Brotli function.

A website with no build on the host

The page has a public home at bunker.nosistech.com. Most static hosts can run npm run build for you. That runs npm and Node.js on a computer that I do not control. Decision record ADR-025 compares three options:

OptionForAgainst
A. The host runs npm run buildCommon; no built files in the repositoryNode.js and npm run on the host; the published file can differ from the tested file
B. CI builds and uploads with a deploy tokenBuilds in a controlled placeA deploy token in CI, and an upload script or a third-party action
C. Commit the built folder site/; the host only serves filesNo build and no secrets on the host; the published bytes are the tested bytesBuilt files in the repository

I chose option C. The host has an empty build command and the output folder site. Any static host works: Cloudflare Pages, Netlify, GitHub Pages, or a VPS with Caddy.

The same bytes on every build

Option C works only if the build gives the same file every time. This property is called a reproducible build.

On October 27, 2026, I took a fresh copy of the repository. I built the page twice with buildPage(), the function that the tests call. Then I compared the SHA-256 hashes with the hash file on the live site:

modules: 55
run 1: 6cf0be8df6dd52ca4a11287512be3458f08792f10eb046efd229d3f6bbc7e1af
run 2: 6cf0be8df6dd52ca4a11287512be3458f08792f10eb046efd229d3f6bbc7e1af

$ curl -s https://bunker.nosistech.com/SHA256SUMS.txt
6cf0be8df6dd52ca4a11287512be3458f08792f10eb046efd229d3f6bbc7e1af  index.html
6cf0be8df6dd52ca4a11287512be3458f08792f10eb046efd229d3f6bbc7e1af  nosis-bunker.html

All four hashes are the same. CI checks this on every push with git diff --exit-code -- dist/ site/. If the committed files differ from a fresh build, the job fails.

That run used version 1.2.1. Version 1.2.2 shipped later the same day, with the hash 4fb97cc5. I repeated the check on it, and the fresh build again matched the live site.

Anyone can repeat the check. Clone the repository, run npm run build, and compare the hash with the published one.

Ten checks in one test file

All the controls in this article have a test in test/supply-chain.test.js. Here is the real run of October 27, 2026, on Node.js 22.23.4:

$ node --test test/supply-chain.test.js
ok 1 - the project has no npm dependencies of any kind
ok 2 - package.json has no install-time scripts
ok 3 - npm is set to never run package scripts
ok 4 - code imports only Node.js built-ins and project files
ok 5 - the tools never decompress data from the network (CVE-2026-111497 surface)
ok 6 - the Node.js version gate
ok 7 - the Node.js version that runs these tests is patched
ok 8 - CI installs the pinned Node.js build and checks its SHA-256 hash
ok 9 - the committed website matches a fresh build
ok 10 - the website headers are strict
# tests 10
# pass 10
# fail 0

The full suite has 68 tests, and all 68 passed in the same run. The supply-chain file is the part that guards the build itself.

After a new Node.js security release

The gate needs one update after each Node.js security release. Do these steps:

  1. Read the release notes on nodejs.org/en/blog.
  2. Install the new LTS version on your computer.
  3. In tools/check-node.js, change PATCHED and PINNED.
  4. In .nvmrc, write the new version.
  5. In both workflow files, change the version and the SHA-256 hash.
  6. Copy the hash from the official SHASUMS256.txt file on nodejs.org.
  7. Run npm run check.

If you think that a build computer is compromised, do these steps:

  1. Stop, and do not publish.
  2. Rotate the GitHub tokens and SSH keys of that computer.
  3. Build again on a clean computer.
  4. Compare the new SHA-256 hash with the published hash. The two hashes must match, because the build is reproducible.

What these controls do not cover

The threat model of the project names the risk that remains: a compromised GitHub account. The control for it is two-factor authentication on the account.

Signed releases and build provenance are the next level of supply-chain security. SLSA and the OpenSSF Scorecard describe them. The standards document of the project lists them as future work.

Read the code

In the next article, I explain the hard cases: Taproot, the Zcash pools, Canton, and the address formats that several networks share.

Have a question or feedback about Bunker? Contact NosisTech.

Keep reading: Bunker by NosisTech

Previous article: A Web Page That Cannot Send Your Data: The Browser Security of Bunker

Next article: Taproot, Zcash, Canton and Shared Formats: The Hard Cases in Bunker

All articles: NosisTech Writing

This post is general information, not financial, legal or security advice. Bunker is an educational tool with no warranty. Examine each address on a block explorer, and send a small test amount before you move funds.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.