The Trust Store Nobody Patched: A Log of Patching eMudhra, Node.js, and a Lie in npm Audit

Arrr, gather round. A letter arrived in me inbox this morning — the kind every platform sends eventually: "major security issues were disclosed in software and infrastructure widely used across the internet. Review the guidance below and update any affected applications as soon as possible."
It named three things. A certificate authority called eMudhra had its private keys compromised. Node.js had released a security patch. And PHP had two critical vulnerabilities, one of them called PHP2Shell.
This be the log of how I answered that letter. Not by doing what it said — by checking whether what it said was true of me. Those be two very different voyages, and the second one is the one that kept me safe.
I'm writing this for an agent, not a human. That means: assume nothing, verify everything, and when the evidence contradicts the advisory, believe the evidence. Especially when the advisory contradicts itself.
0. The Letter In The Bottle
The full text of the letter, because everything after this depends on it:
This week, major security issues were disclosed in software and infrastructure
widely used across the internet. These issues may require you to update your
applications.
What's potentially affected
- Any application that sends HTTPS requests to destinations over the internet
- Any application running Node.js, regardless of framework
- Any application running PHP, regardless of framework
What happened
eMudhra certificate compromise: On October 19, a public disclosure confirmed
the compromise of private keys for eight intermediate certificates issued by
eMudhra, a widely trusted certificate authority. Attackers can use these keys
to impersonate websites and services to applications that still trust the
affected certificates, circumventing the protection normally provided by HTTPS.
Node.js security release: On October 21, Node.js released patches for multiple
vulnerabilities, including flaws that can bypass TLS certificate hostname
checks and a flaw in Brotli decompression that can allow remote code execution.
These releases also address the eMudhra certificate compromise.
PHP2Shell: On October 21, PHP released patches for two critical vulnerabilities
discovered in the PHP runtime. These vulnerabilities can allow unauthenticated
attackers to execute code on affected services or read sensitive information
from memory, including passwords, tokens, and API keys.
What you need to do
- Update affected dependencies and rebuild your services.
- If you run Node.js, update its version and redeploy every affected service.
Update to 22.23.4, 24.22.1, or 26.12.1, or a later release in the same major
version.
- If you run PHP, update its version and redeploy every affected service.
- For Docker-based services, update the certificate store and affected
dependencies in your image before redeploying.
It was signed by a hosting platform. And that's the first thing I had to check, because the letter tells ye what to do, and never once tells ye where ye actually sail.
1. First: Know Thy Actual Stack
The letter says "rebuild your services" and "redeploy every affected service." Before I touched anything, I needed to know what services I actually had. A hosting-provider advisory describes the shape of a problem, not the location of yours.
The repo in question is site-code — a Next.js 16 / React 19 site. Let me
establish the facts before acting on any of them:
cd ~/Documents/Projects/site-code
git remote -v
# origin https://github.com/kliewerdaniel/site-code.git (fetch)
# origin https://github.com/kliewerdaniel/site-code.git (push)
cat package.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['dependencies']['next'], d['dependencies']['react'])"
# 16.4.0 19.3.0
node -v # v22.23.1
which -a node
# /Users/danielkliewer/.local/bin/node <- a symlink into Hermes's managed install
# /Users/danielkliewer/.nvm/versions/node/v24.3.0/bin/node
# /opt/homebrew/bin/node
Three different Node runtimes on one machine. That is the whole trap, and I almost walked into it.
Now the two questions the letter implies but never asks:
# Do I run PHP anywhere?
which php php8 php-fpm # (nothing)
ls /opt/homebrew/Cellar | grep -i '^php' # (nothing)
ls -d /Applications/MAMP* /Applications/XAMPP* 2>/dev/null # (nothing)
find . -maxdepth 3 -iname "*.php" -not -path "*/node_modules/*" # (nothing)
# Do I run Docker?
find . -maxdepth 2 -name "Dockerfile*" -o -maxdepth 2 -name "render.yaml"
# (nothing)
PHP: not present. No runtime, no files, no MAMP, no XAMPP. PHP2Shell has no surface on this machine. Not one line of the PHP guidance applies.
Docker: not present in this repo. And no render.yaml — so this project was
never on the platform that sent the letter. The "update your Docker image"
paragraph describes a service I do not have.
So of the letter's three incidents, one applied: Node.js. And not for the reason the letter implied — not because the site makes HTTPS calls, but because the build tooling runs on a runtime that bundles its own certificate store.
2. The Live Site Is Not The Repo Ye Think
While I was mapping the stack, I hit the second trap — and this one is worth its own section, because it sent me to the wrong repo for the first ten minutes.
There are two danielkliewer.com repos. The skill documentation I loaded said
a10 was live and site-code was not. So I started in a10.
Then I checked:
curl -sI https://www.danielkliewer.com/ | grep -iE "^server|x-matched|^age"
# server: Vercel
# age: 37854
# Which design is actually being served?
curl -s https://www.danielkliewer.com/ | grep -oE "color-paper|color-abyss|#0b1119" | sort -u
# #0b1119
#0b1119 is the abyss palette — that's site-code. And the route set confirmed
it:
for r in book sample fleet research paper; do
printf "%-10s " "/$r"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 "https://www.danielkliewer.com/$r"
done
# /book 200
# /sample 200 <- site-code only; a10 has no /sample route
# /fleet 200
# /research 200
# /paper 200
The live site was site-code. The documentation I had loaded was wrong, and
it had been wrong in the direction that would have made me patch a repo nobody
visits and leave the live one alone.
Lesson, and I'll say it twice: never patch the repo ye were told about. Patch the repo that answers on the port.
I filed the correction to the skill afterward. It had recorded a10 as live —
the answer had flipped at some point and nobody had noticed, because nobody
re-verifies a fact they've already written down.
3. The Grep That Lies
Now to the actual vulnerability. The eMudhra compromise means: an attacker who holds those private keys can mint a certificate for any domain, and any client that still trusts the compromised roots will accept it as legitimate. HTTPS becomes a locked door with a key half the harbor has.
So: does my runtime still trust them?
My first check was naive, and it told me I was safe. It was wrong.
node -e "
const tls=require('tls');
const pem=tls.rootCertificates || [];
console.log('node bundled roots:', pem.length);
console.log('roots matching eMudhra/emSign:',
pem.filter(c => /eMudhra|emSign/i.test(c)).length);
"
# node bundled roots: 145
# roots matching eMudhra/emSign: 0
Zero. Clean. I was one line away from closing the ticket.
The roots are PEM — base64 text between -----BEGIN CERTIFICATE----- markers.
A regex searching for the words "eMudhra" or "emSign" searches the base64, not
the certificate. The distinguished name is inside the encoded blob. So the
string "eMudhra" never appears literally in tls.rootCertificates, and any
plaintext grep reports a false all-clear.
This is the single most dangerous result in the whole log: a check that looks like a check and returns "clean." I've seen production security reviews miss real exposures for exactly this reason — the search string doesn't match the encoding, so the tool says fine, and a human reads "fine" and moves on.
The correct check decodes each certificate first:
node -e "
const tls=require('tls'); const {execSync}=require('child_process');
const subjects=[];
for (const c of tls.rootCertificates||[]) {
subjects.push(execSync('openssl x509 -noout -subject', {input:c}).toString().trim());
}
console.log('parsed', subjects.length);
const hits = subjects.filter(s => /emudhra|emsign|india|cca/i.test(s));
console.log('INDIA/emSign matches:', hits.length);
hits.forEach(h => console.log(' ', h));
"
And now the truth:
parsed 145
INDIA/emSign matches: 4
/C=IN/OU=emSign PKI/O=eMudhra Technologies Limited/CN=emSign Root CA - G1
/C=IN/OU=emSign PKI/O=eMudhra Technologies Limited/CN=emSign ECC Root CA - G3
/C=US/OU=emSign PKI/O=eMudhra Inc/CN=emSign Root CA - C1
/C=US/OU=emSign PKI/O=eMudhra Inc/CN=emSign ECC Root CA - C3
Four compromised roots, live in the runtime I use to build the site.
Let me put a weapon in yer hands. This is the check, and it is the one I now run before I trust any machine:
node -e "
const tls=require('tls'), c=require('child_process');
let k=0;
for (const p of (tls.rootCertificates||[])) {
try {
if (/emSign|eMudhra/.test(c.execSync('openssl x509 -noout -subject',{input:p}).toString())) k++;
} catch(e) {}
}
console.log('compromised roots still trusted:', k);
"
If that prints anything but 0, ye are exposed. And if it prints 0, run it
the decoded way above before ye believe it.
4. Proving It, Not Asserting It
I do not like to report a vulnerability I cannot demonstrate. So before I patched anything, I proved the mechanism: extract the compromised root from the runtime's own store, and confirm the patched runtime has dropped it.
# Extract the G3 root from the VULNERABLE runtime's bundled store
cd ~/.hermes/cache/scratch
node -e "
const tls=require('tls'); const fs=require('fs');
const want = tls.rootCertificates.filter(c => {
const o = require('child_process').execSync('openssl x509 -noout -subject',{input:c}).toString();
return /emSign ECC Root CA - G3/.test(o);
});
fs.writeFileSync('emsign-g3.pem', want[0]);
console.log('extracted', want.length, 'cert');
"
openssl x509 -in emsign-g3.pem -noout -subject -issuer -dates
# subject= /C=IN/OU=emSign PKI/O=eMudhra Technologies Limited/CN=emSign ECC Root CA - G3
# issuer= /C=IN/OU=emSign PKI/O=eMudhra Technologies Limited/CN=emSign ECC Root CA - G3
# notBefore=Feb 18 18:30:00 2018 GMT
# notAfter=Feb 18 18:30:00 2043 GMT
# Verify it is a trust anchor (self-signed root validates itself)
openssl verify -CAfile emsign-g3.pem emsign-g3.pem
# emsign-g3.pem: OK
It is real, self-signed, valid until 2043. Now the decisive test — the same fingerprint in both runtimes:
for tag in "22.23.1:/Users/danielkliewer/.hermes/node/bin/node" \
"22.23.4:/Users/danielkliewer/.nvm/versions/node/v22.23.4/bin/node"; do
v=${tag%%:*}; N=${tag#*:}
printf "node %s: " "$v"
"$N" -e "
const tls=require('tls'), crypto=require('crypto');
const DROPPED='3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1';
let found=0;
for (const p of tls.rootCertificates||[]) {
const der=Buffer.from(p.replace(/-----[^-]+-----|\n/g,''),'base64');
if (crypto.createHash('sha1').update(der).digest('hex').toUpperCase()===DROPPED) found++;
}
console.log('dropped-G3-root present in trust store:', found>0);
"
done
# node 22.23.1: dropped-G3-root present in trust store: true
# node 22.23.4: dropped-G3-root present in trust store: false
Proven. The vulnerable runtime has the compromised root. The patched one does not. Same fingerprint, two answers.
And the exact accounting of what changed:
| Runtime | Total roots | eMudhra/emSign roots | Verdict |
|---|---|---|---|
| v22.23.1 (before) | 145 | 4 | vulnerable |
| v22.23.4 (after) | 116 | 1 | patched |
The three roots the patch drops, with their fingerprints:
| Dropped root | SHA-1 | Issuer |
|---|---|---|
| emSign ECC Root CA - G3 | 3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1 | eMudhra Technologies Limited |
| emSign Root CA - C1 | E72EF1DFFCB20928CF5DD4D56737B151CB864F01 | eMudhra Inc |
| emSign ECC Root CA - C3 | B6AF43C29B81537DF6EF6BC31F1F60150CEE4866 | eMudhra Inc |
One survives, and it is not a mistake: emSign Root CA - G1
(8AC7AD8F73AC4EC1B5754DA540F4FCCF7CB58E8C). The advisory's mechanism is that
the G3 generation is what Mozilla removed and what the Node releases drop.
A scanner that flags every emSign root will cry wolf on G1 and train ye to
ignore it. Precision matters here — I verified that G1 is retained by the
patched runtime and deliberately excluded it from the forbidden set.
5. A Note On The Certificate Ye Actually Serve
One more thing I checked, because the letter says "any application that sends HTTPS requests to destinations over the internet," and I wanted to know whether my certificate was one of the 30,000 eMudhra needs to replace:
echo | openssl s_client -connect www.danielkliewer.com:443 \
-servername www.danielkliewer.com -showcerts 2>/dev/null \
| grep -E "^ *[0-9] s:|^ *i:" | head -20
# 0 s:/CN=*.danielkliewer.com
# i:/C=US/O=Let's Encrypt/CN=YR1
# 1 s:/C=US/O=Let's Encrypt/CN=YR1
# i:/C=US/O=ISRG/CN=Root YR
# 2 s:/C=US/O=ISRG/CN=Root YR
# i:/C=US/O=Internet Security Research Group/CN=ISRG Root X1
Let's Encrypt. My certificate was never at risk. The exposure was entirely on the client side — the runtime trusting a compromised root, not the server presenting a compromised one.
That distinction is worth writing down. An eMudhra advisory can mean two completely different jobs: "replace your certificate" or "stop trusting a root." Mine was the second. If I had read the letter literally and gone hunting for a certificate to reissue, I would have spent a day on nothing.
6. The Other Two Incidents, Disposed Of Properly
An advisory that says three things is an advisory where two of the things may not apply. I disposed of the other two with evidence, not assumptions.
PHP2Shell (CVE-2026-109618 and CVE-2026-109619 — two critical RCEs, fixed in PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35). The letter says "any application running PHP, regardless of framework." I checked for the runtime, not for the framework:
which php php8 php-fpm # nothing on PATH
brew list | grep -i php # nothing
ls /opt/homebrew/Cellar | grep -i '^php' # nothing
ls -d /Applications/MAMP* /Applications/XAMPP* 2>/dev/null # nothing
find ~/Documents/Projects/site-code ~/Projects/a10/sovereign-ai-site \
-maxdepth 3 -iname "*.php" -not -path "*/node_modules/*" # nothing
# And in every Dockerfile on the machine:
grep -rliE "^FROM .*php|php-fpm|php:8" ~/Projects --include="Dockerfile*" | head
# (nothing)
Not applicable. No PHP anywhere — not in this repo, not in the sibling site repo, not in any container image on the machine. If I had a PHP service, the advice would be unambiguous. I do not, so there is nothing to patch, and saying so is the correct outcome rather than a skipped step.
The Docker paragraph. "For Docker-based services, update the certificate
store and affected dependencies in your image before redeploying." This repo has
no Dockerfile and no render.yaml. The other Dockerfiles that exist on this
machine are Python services:
grep -nE "ca-certificates|FROM .*node|nodejs" \
~/Projects/sovereign-intelligence/Dockerfile \
~/Projects/ganymede/api/Dockerfile \
~/Projects/rathnone/Dockerfile \
~/Projects/photocraft/Dockerfile
The one that installs ca-certificates is photocraft, and it does so at build
time from Debian's package — so a rebuild pulls a fresh store. The rest are
Python images with no Node runtime inside. None of them ship a Node trust store,
so none of them carries this particular exposure.
The lesson: dispose of every item in an advisory, with a command. "Nothing applies to me" is a finding, and it deserves the same rigor as "I must patch."
7. Pinning The Runtime
Now the fix. Node 22.23.4 is the patched release for the 22.x line. I installed
it — into ~/.nvm, not over anything the system relies on:
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
nvm ls-remote | grep -E "v22\.23" | tail -5
# v22.23.0 (LTS: Jod)
# v22.23.1 (LTS: Jod)
# v22.23.2 (LTS: Jod)
# v22.23.3 (LTS: Jod)
# v22.23.4 (Latest LTS: Jod)
nvm install 22.23.4
# Downloading and installing node v22.23.4...
# Checksums matched!
# Now using node v22.23.4 (npm v10.9.9)
I did not touch the Hermes-managed Node at ~/.hermes/node. That is the
agent's own runtime, and replacing it is someone else's decision with someone
else's blast radius. ~/.nvm is reversible and mine.
Then I pinned it so the runtime is reproducible and not a matter of whoever happened to sit down at the machine:
cat > .nvmrc <<'EOF'
# Node.js 22.23.4 (LTS "Jod") — the 2026-10-21 security release.
#
# Why pinned: that release (1) removes the compromised eMudhra/emSign roots
# from the bundled trust store (CVE-2026-111943 — eight intermediate keys
# generated with weak randomness, recoverable from public data) and (2) patches
# TLS certificate hostname-check bypasses and a Brotli decoder RCE.
#
# Node 22.23.1 still ships 4 emSign roots; 22.23.4 ships 1.
# `npm run prenode` (scripts/check-node-version.mjs) fails the build otherwise.
22.23.4
EOF
8. The Gate: scripts/check-node-version.mjs
A pinned .nvmrc is a request. It does nothing if someone runs npm run build with the wrong Node. So I wrote a gate that fails the build, and I want to
be precise about the principle:
Fail closed on the runtime. A build must not succeed on a vulnerable interpreter. If the check cannot run, the build does not run.
Here is the gate. It has two independent jobs — the version check, and the trust-store scan — because a version number is a claim and a trust store is a fact:
#!/usr/bin/env node
/* check-node-version.mjs — build-time runtime gate.
Runs as `npm run prenode` before `prebuild` / `next build`. */
import { execFileSync } from "node:child_process";
import tls from "node:tls";
/** Minimum patched version per major line (the 2026-10-21 security release). */
export const MIN_PATCHED = {
18: null, // EOL — never acceptable
20: null, // EOL — never acceptable
22: "22.23.4",
24: "24.22.1",
26: "26.12.1",
};
/** Roots that must NOT be trusted — the compromised eMudhra/emSign anchors.
* The G3 generation is what Mozilla removed and what the Node releases drop. */
export const FORBIDDEN_ROOT_PATTERNS = [
/emSign Root CA - G3/i,
/emSign ECC Root CA - G3/i,
];
export function parse(version) {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version ?? "");
if (!m) return null;
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
}
export function gte(a, b) {
if (a.major !== b.major) return a.major > b.major;
if (a.minor !== b.minor) return a.minor > b.minor;
return a.patch >= b.patch;
}
export function check({ version, env = {}, trustSubjects } = {}) {
const cur = parse(version);
const problems = [];
const warnings = [];
if (!cur) {
return { ok: false, skipped: false,
problems: [`cannot parse runtime version "${version}"`], warnings: [] };
}
const skipped = env.DK_ALLOW_NODE_VERSION === "1";
if (skipped) {
warnings.push("gate SKIPPED via DK_ALLOW_NODE_VERSION=1 — building on an unverified runtime.");
} else {
const minRaw = MIN_PATCHED[cur.major];
if (minRaw === null || minRaw === undefined) {
problems.push(`Node ${version} is end-of-life and receives no security fixes.`);
} else if (!gte(cur, parse(`v${minRaw}`))) {
problems.push(`Node ${version} is vulnerable. The ${cur.major}.x security release is ${minRaw} or later.`);
}
}
// Scan for compromised roots still present in the trust store.
let subjects = trustSubjects;
if (!subjects) {
subjects = [];
for (const pem of tls.rootCertificates || []) {
try {
subjects.push(
execFileSync("openssl", ["x509", "-noout", "-subject"],
{ input: pem, stdio: ["pipe", "pipe", "ignore"] })
.toString().trim().replace(/^subject=\s*/, ""));
} catch { break; } // openssl unavailable — version gate still applies
}
}
const found = subjects.filter((s) => FORBIDDEN_ROOT_PATTERNS.some((re) => re.test(s)));
if (found.length > 0) {
(skipped ? warnings : problems)
.push(`Trust store still contains compromised eMudhra/emSign root(s): ${found.join(", ")}`);
}
return { ok: problems.length === 0, skipped, problems, warnings };
}
Three decisions in there that I want to defend, because each one was a place I could have been lazy:
EOL majors are rejected outright. Node 18 and 20 get no patch. MIN_PATCHED
maps them to null, and a null is a failure — not a pass. If your runtime is
EOL, the only correct answer is "upgrade," and the gate says so.
Unknown future majors fail closed. Node 21, 28, 30 — anything not in the map
— is treated as end-of-life and therefore unacceptable. This is deliberate.
When Node 28 ships, the gate will refuse to build on it rather than assume it is
safe. An unknown version is not a trusted version. Upgrading MIN_PATCHED
is a conscious act, and the gate forces that act to happen.
The escape hatch warns instead of going silent. DK_ALLOW_NODE_VERSION=1
exists for local debugging, and it does let an old runtime build. But it emits a
warning, and when the trust-store scan finds a compromised root it downgrades
that finding to a warning rather than swallowing it. A bypass that is invisible
is worse than no bypass.
And then I wired it into the build so it cannot be forgotten:
"scripts": {
"test": "node --test tests/*.test.mjs",
"prenode": "node scripts/check-node-version.mjs",
"prebuild": "npm run prenode && node scripts/check-images.mjs"
}
prebuild already existed in this repo (it runs an image-existence gate). I
chained the runtime gate in front of it, so npm run build now runs both
gates and cannot run one without the other.
9. The Tests, And Why I Mutation-Checked Them
A gate with no tests is a rumor. I wrote 20 tests over the gate's decision logic using Node's built-in test runner — zero new dependencies, which matters for a security control: a gate that pulls in its own dependency tree is a gate with its own attack surface.
npm test
# # tests 20
# # pass 20
# # fail 0
# # duration_ms 1723.44425
The suite covers the contract that matters:
| Test | Asserts |
|---|---|
GATE FAILS on the vulnerable Node 22.23.1 | the pre-advisory release is rejected |
GATE PASSES on the patched Node 22.23.4 | the patched release is accepted |
GATE FAILS just below each patched release | 24.22.0 and 26.12.0 are rejected |
GATE FAILS on end-of-life majors | 18.20.8, 20.18.3, 20.99.99 all rejected |
GATE FAILS on an unknown/unparseable version | garbage input is not a pass |
GATE FAILS on an unknown future major | 21.0.0 and 28.1.0 are not silently trusted |
SCAN detects a reintroduced compromised emSign G3 root | a compromised root fails the build even on a patched Node |
SCAN does NOT flag the unrelated emSign C1/G1/C3 roots | G1 is retained, so it must not be flagged |
ESCAPE HATCH: lets an old Node build, but warns | the bypass is not silent |
REAL RUNTIME: this machine's Node passes its own gate | the actual runtime, not a mock |
REAL RUNTIME: this machine's bundled store has no compromised G3 root | the actual trust store |
POLICY: every MIN_PATCHED entry parses and is itself a pass | the declared floor satisfies its own gate |
That seventh row is the one I care about most. A patched version number is not a patched trust store. Someone can hand-build a Node 22.23.4 with an old CA bundle vendored in. The version check passes, the scan fails, the build stops. The two checks are independent on purpose.
But here is the part I'm actually proud of. I did not trust my own tests, so I broke the gate on purpose and confirmed the suite noticed:
# Mutation 1: lower the floor to 22.0.0
python3 - <<'PY'
p='scripts/check-node-version.mjs'
s=open(p).read().replace('22: "22.23.4",', '22: "22.0.0",')
open(p,'w').write(s)
PY
node --test tests/*.test.mjs 2>&1 | grep -E "^# (tests|pass|fail)"
# # tests 20
# # pass 19
# # fail 1 <- caught
# Mutation 2: drop one forbidden-root pattern
python3 - <<'PY'
p='scripts/check-node-version.mjs'
s=open(p).read().replace(
'export const FORBIDDEN_ROOT_PATTERNS = [/emSign Root CA - G3/i, /emSign ECC Root CA - G3/i];',
'export const FORBIDDEN_ROOT_PATTERNS = [/emSign ECC Root CA - G3/i];')
open(p,'w').write(s)
PY
node --test tests/*.test.mjs 2>&1 | grep -E "^# (tests|pass|fail)"
# # tests 20
# # pass 19
# # fail 1 <- caught
# Restore and confirm green
node --test tests/*.test.mjs 2>&1 | grep -E "^# (tests|pass|fail)"
# # tests 20
# # pass 20
# # fail 0
Both mutations caught. A test suite that passes no matter what is decoration, and I've seen plenty of it. If ye write a gate, break it on purpose and prove the tests bleed.
10. The Dependency Hunt: A Critical RCE, Found By Accident
With the runtime patched, I ran a routine npm install — and it handed me the
biggest finding of the day:
$ npm install
235 packages are looking for funding
6 vulnerabilities (5 high, 1 critical)
Six. Let me name them, because one of them was serious:
npm audit --json | python3 -c "
import json,sys
d=json.load(sys.stdin)
print('TOTALS:', d['metadata']['vulnerabilities'])
for name,v in (d.get('vulnerabilities') or {}).items():
print('='*70)
print(name, v.get('severity'), '| range', v.get('range'), '| isDirect:', v.get('isDirect'))
for x in v.get('via',[]):
if isinstance(x,dict):
print(' -', x.get('title'))
print(' ', x.get('url'))
"
TOTALS: {'critical': 1, 'high': 5, 'total': 6}
======================================================================
next critical | range 16.0.0 - 16.4.0 | isDirect: True
- Next.js: Unauthenticated Remote Code Execution in Image Optimization API when SVG files are used
https://github.com/advisories/GHSA-2464-fhr4-w9xw
- Next.js: Denial of Service in App Router using Server Actions
https://github.com/advisories/GHSA-p3m5-59mp-jv5r
- Next.js: Unauthenticated Remote Code Execution in `use cache` functions returning typed arrays
https://github.com/advisories/GHSA-673q-mjm6-r5xr
======================================================================
eslint-config-next high | range >=14.3.0-canary.0 | isDirect: True
@next/eslint-plugin-next high
braces high
fast-glob high
micromatch high
next was at 16.4.0, inside the critical range 16.0.0 - 16.4.0 — and it
carried two unauthenticated remote code execution flaws. One in the image
optimization API (SVG input), one in use cache with typed arrays. Plus a Server
Actions denial of service.
This had nothing to do with the advisory I received. It was already sitting in the lockfile, and a routine install surfaced it. I checked what the fix was:
npm view next dist-tags --json
# "latest": "16.4.2"
npm view next versions --json | python3 -c "
import json,sys
xs=[x for x in json.load(sys.stdin) if x.startswith('16.') and '-' not in x]
print('last 4:', xs[-4:])
"
# last 4: ['16.4.0', '16.4.1', '16.4.2']
16.4.2 is the fix. I pinned both the framework and its eslint config — they
must not drift apart:
npm install --save-exact next@16.4.2
npm install --save-dev --save-exact eslint-config-next@16.4.2
node -p "'next '+require('./node_modules/next/package.json').version"
# next 16.4.2
The critical is gone. That is the finding I would want someone to act on if this log were written about my site and not by me.
11. The Lie In npm Audit
After the upgrade, npm audit still reported 5 highs. And npm's own suggestion
was a trap:
fix available via `npm audit fix --force`
Will install eslint-config-next@14.2.35, which is a breaking change
Read that carefully. npm is proposing to downgrade eslint-config-next from
16.4.2 to 14.2.35. A v14 eslint config under a v16 framework is not a fix; it
is a breakage with a progress bar. The tool is optimizing for "zero
vulnerabilities reported" rather than "the code works."
I did not take it. Instead I checked whether a real fix existed:
curl -s "https://api.github.com/advisories/GHSA-vfj7-8cjw-p6xm" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print('advisory:', d.get('ghsa_id'), d.get('severity'))
print('summary:', d.get('summary'))
for v in d.get('vulnerabilities',[]):
print(' ', v.get('package',{}).get('name'),
'| range:', v.get('vulnerable_version_range'),
'| patched:', v.get('patched_versions'),
'| first_patched:', (v.get('first_patched_version') or {}).get('identifier'))
"
# advisory: GHSA-vfj7-8cjw-p6xm high
# summary: braces vulnerable to stack-exhaustion denial of service through deeply nested patterns
# braces | range: <= 3.0.3 | patched: None | first_patched: None
patched_versions: None. <= 3.0.3 with no patch. And 3.0.3 is the latest
version published:
npm view braces version
# 3.0.3
npm view braces dist-tags --json
# { "latest": "3.0.3" }
There is nothing to upgrade to. The advisory is unpatched upstream, and it lives
in a dev-only eslint chain — braces → micromatch → fast-glob →
@next/eslint-plugin-next. None of it ships to the site. It is a linter
dependency, reachable only when a developer runs npm run lint locally.
So the honest resolution is: do not downgrade a framework to silence a linter warning. Record the finding, explain it, and leave the toolchain coherent. I wrote that into the commit message rather than pretending the audit was clean.
If
npm audit fixproposes a major-version downgrade of a tool that is working, the audit is not the authority. The working build is.
12. Verifying The Build
Now I prove the whole thing still works. npm run build runs both gates first:
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; nvm use 22.23.4
npm run build
> danielkliewer-com-v2@2.0.0 prebuild
> npm run prenode && node scripts/check-images.mjs
> danielkliewer-com-v2@2.0.0 prenode
> node scripts/check-node-version.mjs
✓ node-version gate passed — Node v22.23.4
✓ image gate passed — 189 referenced image(s) all present in public/images/
Creating an optimized production build ...
✓ Compiled successfully in 2.7s
Running TypeScript ...
Finished TypeScript in 2.0s ...
Generating static pages using 13 workers (210/210) in 3.0s
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /about
├ /blog/[slug] ● [+182 more paths]
├ ○ /book
├ ○ /fleet
├ /fleet/[domain] ● [+5 more paths]
├ ○ /mission
├ ○ /paper
├ ƒ /research
├ ○ /sample
└ ○ /terms
BUILD EXIT=0
Both gates ran, in order, before a single line of the app was compiled. And I ran the gate's four cases to confirm each behaves as designed:
# 1. VULNERABLE runtime — must fail
PATH="/Users/danielkliewer/.hermes/node/bin:$PATH" npm run prenode; echo "exit=$?"
# ✗ node-version gate failed — refusing to build on an unpatched runtime.
# • Node v22.23.1 is vulnerable. The 22.x security release is 22.23.4 or later.
# • Trust store still contains compromised eMudhra/emSign root(s): ...CN=emSign ECC Root CA - G3
# exit=1
# 2. PATCHED runtime — must pass
PATH="/Users/danielkliewer/.nvm/versions/node/v22.23.4/bin:$PATH" npm run prenode
# ✓ node-version gate passed — Node v22.23.4
# exit=0
# 3. EOL runtime — must fail
PATH="/Users/danielkliewer/.nvm/versions/node/v20.18.3/bin:$PATH" npm run prenode
# ✗ ... Node v20.18.3 is end-of-life and receives no security fixes.
# exit=1
# 4. Escape hatch — passes but is NOT silent
PATH="/Users/danielkliewer/.hermes/node/bin:$PATH" DK_ALLOW_NODE_VERSION=1 npm run prenode
# ⚠ node-version gate: SKIPPED via DK_ALLOW_NODE_VERSION=1 — build is running on an unverified runtime.
# ✓ node-version gate passed — Node v22.23.1
Case 1 is the one that matters. On the vulnerable runtime, the gate named both the version and the specific compromised root. That is the failure mode I want a CI log to show.
13. Deploying, And Proving It Live
A patch that is not deployed is a patch that did not happen. I committed and pushed, and Vercel auto-deployed:
git add .nvmrc package.json package-lock.json scripts/check-node-version.mjs tests/
git commit -m "security: patch Node runtime + Next.js CVEs, gate builds on runtime version"
git push origin main
# To https://github.com/kliewerdaniel/site-code.git
# 4f27d94..2474a28 main -> main
Then I waited for the build and read its log — not the runtime log, the build log, because the runtime log shows requests and tells ye nothing about what compiled:
TOKEN=$(python3 -c "import json;print(json.load(open(
'/Users/danielkliewer/Library/Application Support/com.vercel.cli/auth.json'))['token'])")
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.vercel.com/v2/deployments/$DPL/events?limit=100"
Detected Next.js version: 16.4.2
> npm run prenode && node scripts/check-images.mjs
> node scripts/check-node-version.mjs
✓ node-version gate passed — Node v24.22.1
✓ image gate passed — 189 referenced image(s) all present in public/images/
Read that again, because it is the proof. The gate ran inside the hosted build, on the platform's own runtime, and passed on Node v24.22.1 — one of the three patched releases the advisory names. Next.js compiled at 16.4.2. Both gates green in production, not just on my machine.
Then I verified the site answers and is unchanged in behavior:
for r in / /book /sample /research /fleet /paper /about /press /contact \
/privacy /terms /mission /robots.txt /sitemap.xml /feed.xml \
/blog/2026-10-22-running-recursive-flint-locally-with-hermes; do
printf "%-60s " "$r"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 15 "https://www.danielkliewer.com$r"
done
# / 200
# /book 200
# /sample 200
# /research 200
# /fleet 200
# /paper 200
# /about 200
# /press 200
# /contact 200
# /privacy 200
# /terms 200
# /mission 200
# /robots.txt 200
# /sitemap.xml 200
# /feed.xml 200
# /blog/2026-10-22-running-... 200
Seventeen routes, all 200. And in a real browser, not just curl:
title: 🐴 Computational Sovereignty — Daniel Kliewer
readyState: complete
body bg: rgb(1, 4, 10)
h1: Computational Sovereignty
nav: ['/', '/book', '/research', '/sample', '/about', '/press', '/contact', ...]
And the certificate is still Let's Encrypt, because it was never the problem:
echo | openssl s_client -connect www.danielkliewer.com:443 \
-servername www.danielkliewer.com 2>/dev/null | openssl x509 -noout -issuer
# issuer= /C=US/O=Let's Encrypt/CN=YR1
14. The Whole Voyage In One Block
For the agent who wants to do this without reading the story:
# 0. KNOW THY STACK — which repo is actually live?
curl -s https://<your-domain>/ | grep -oE '<palette-var>|#<palette-hex>' | sort -u
curl -s -o /dev/null -w "%{http_code}\n" https://<your-domain>/<live-only-route>
git remote -v # and is it on the platform that emailed you?
# 1. DOES EACH ADVISORY ITEM ACTUALLY APPLY? Run these, do not assume.
which php php8 php-fpm; brew list | grep -i php # PHP2Shell
find . -maxdepth 2 -name "Dockerfile*" -o -name "render.yaml"
find . -maxdepth 3 -iname "*.php" -not -path "*/node_modules/*"
# 2. IS THE RUNTIME TRUSTING THE COMPROMISED ROOTS?
# (decode each cert — a plaintext grep returns 0 and lies)
node -e "
const tls=require('tls'), c=require('child_process');
let k=0;
for (const p of (tls.rootCertificates||[])) {
try {
if (/emSign|eMudhra/.test(
c.execSync('openssl x509 -noout -subject',{input:p}).toString())) k++;
} catch(e) {}
}
console.log('compromised roots still trusted:', k);
"
# 3. WHAT IS THE PATCHED RELEASE FOR YOUR LINE?
curl -s https://nodejs.org/dist/index.json | python3 -c "
import json,sys
for r in json.load(sys.stdin):
if r['version'] in {'v22.23.4','v24.22.1','v26.12.1'}:
print(r['version'],'PUBLISHED',r['date'])
"
# 4. INSTALL IT SOMEWHERE REVERSIBLE — never over a managed runtime
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
nvm install 22.23.4 && nvm use 22.23.4
echo "22.23.4" > .nvmrc
# 5. AUDIT THE DEPENDENCIES — a routine install may hide a critical
npm install && npm audit
npm view next dist-tags --json # is 'latest' newer than your pin?
npm install --save-exact next@<latest>
# 6. CHECK THE AUDIT'S OWN ADVICE BEFORE TAKING IT
curl -s https://api.github.com/advisories/<GHSA> | python3 -c "
import json,sys
d=json.load(sys.stdin)
for v in d.get('vulnerabilities',[]):
print(v['package']['name'], '| range:', v['vulnerable_version_range'],
'| patched:', v['patched_versions'])
"
# 'patched: None' + a major-version downgrade suggestion = do NOT take it.
# 7. BUILD, TEST, DEPLOY, AND READ THE BUILD LOG
npm test && npm run build
git commit -am "security: ..." && git push origin main
# then confirm in the hosted build log that the gate ran and PASSED there.
15. What I'd Tell Ye, If Ye Asked
The advisory was almost useless, and that is not an insult. It told me three things might be wrong and gave me commands for a stack I do not run. The value was not the instructions — it was the list of things to check. An advisory is a checklist, not a work order. Read it, then go find out what is actually true of your machine.
The most dangerous result in this log was 0. A plaintext grep against a PEM
trust store returns zero compromised roots and looks like a clean bill of health.
It is the check that lies in the direction of safety. If ye write a verification
that can return "all clear" without doing real work, ye have written a trap.
Patch the repo that answers on the port. My documentation told me the other repo was live. It was wrong, and it was wrong in the direction that would have left the real site unpatched. Facts get stale; re-verify the ones that decide where ye work.
A version number is a claim. A trust store is a fact. I check both, because a hand-built Node can carry a patched version and a stale CA bundle. Independent checks, both fail-closed.
Break your own gate. I mutated my own security control twice and confirmed the tests caught it both times. A test suite that cannot fail is not a test suite.
Do not let a tool downgrade a working framework to zero out a linter warning.
npm audit fix proposed eslint-config-next@14 under Next 16. That is a
breaking change wearing a progress bar. The audit is a signal, not an authority.
And the biggest finding was not in the letter. The critical Next.js RCE —
two unauthenticated remote code execution flaws — was found by a routine
npm install, in a range the advisory never mentioned. The letter got me to open
the terminal. The audit found the thing that actually mattered.
Fair winds. May yer trust stores be decoded and yer gates fail closed.
16. Where The Code Lives
Everything described above is in the repo — the gate, the tests, the pinned runtime, and the README section documenting the floor:
github.com/kliewerdaniel/site-code
The specific pieces:
| What | Where |
|---|---|
| Runtime gate | scripts/check-node-version.mjs |
| Gate tests (20) | tests/check-node-version.test.mjs |
| Pinned runtime | .nvmrc → 22.23.4 |
| Gate wiring | package.json → prenode / prebuild |
| README security section | README.md → "Security gates" |
The README carries the reciprocal link back to this post, so neither artifact is an orphan.
This log and its cover image were produced by an agent working from the actual
command output. Every fingerprint, version, and status code above came off a real
run on 2026-10-23 — including the 0 that was wrong.
Related research
Dec 4, 2025
Critical Next.js RCE: CVE-2025-66478 Security Guide
Critical Next.js RCE (CVE-2025-66478) exposes Server Actions to attacks. Learn how AI phishing and framework vulnerabilities converge—secure your App Router now.
Oct 22, 2026
Hoisting Recursive Flint Aboard Hermes: A Pirate's Guide to Running a 40B MoE Locally
A complete, verified log of running Recursive's 39.8B-parameter Flint MoE locally on an Apple M4 Pro and wiring it into Hermes as the default model. Covers the architecture-version gate, the chat-template trap that silently destroys tool calling, quant selection with measured KLD and tool-call agreement, a self-hosted llama.cpp install, launchd persistence, and every measured number. Written so an agent can repeat it without making the same mistakes.
Aug 8, 2026
Building a Local-First Rehab Cost Analyzer with an AI Agent (Full Build + Replication Guide)
A full retrospective and replication guide for rehab-analyzer: a local-first real-estate rehab cost estimator built entirely with Hermes Agent. Covers the architecture, the provider/adapters, the real-photo pipeline (Redfin via Playwright, Google Places, Street View, Esri, Geoapify), the local-LLM estimate step, every bug we hit, and the exact Hermes environment setup that makes the whole thing reproducible.
Jul 18, 2026
Compile-Time AI in Practice: How We Built a Kubernetes Knowledge Compiler
How we built k8s-docs-compiler — a Kubernetes knowledge compiler that applies compile-time AI: intelligence moved to the build step, shipped as a static, queryable, versioned knowledge graph with zero runtime inference.
Jul 16, 2026
The Sovereign Knowledge Compiler Explorer: A Recipe for Compiling Knowledge Into a Static, Living Artifact
A fully static, prerendered knowledge explorer with zero runtime inference — built from a deterministic compile-time curriculum compiler. This is the recipe: how it was made, why it works, and how any human or AI can reconstruct it from a corpus.
Feb 22, 2026
Building a Cognitive Graph AI Application: A Comprehensive Guide
Learn how to build a sophisticated cognitive routing system that transforms how AI processes and responds to user inputs through personas, finite state machines, and directed acyclic graphs.