Thompson Edolo

A fake take-home hid an RCE, and npm's takedown was what saved me

At 00:12 on 2026-07-29 I ran npm run dev on a take-home assessment and got:

Cannot find module 'node-path-utils'

Four minutes later I knew it wasn’t a broken dependency. It was a payload, and the only reason it hadn’t executed was that npm had quietly pulled the package out from under it weeks earlier.

The assessment

On Friday, 2026-07-24, a recruiter messaged me on LinkedIn about a Senior React Frontend Engineer role at Zolvo — a real Y Combinator Spring 2026 fintech, three employees, the kind of small verifiable startup that’s easy to check and easy to trust because there’s nothing to distrust. I had an unavoidable family event that weekend, so I told the recruiter I’d get to the take-home the following week. The brief was a PDF, React - Healthcare.pdf, plus a GitHub repo: healthcare-react-test. Part 1 was a git-log assessment — walk the history, answer questions about the commits. Part 2 was a live coding round. I opened the repo on the following Tuesday, 2026-07-28, at around 10:01, worked through Part 1 by 10:40, and left the dependency install for the next session.

The repo itself was a clone of JS Mastery’s public CarePulse tutorial app, an Appwrite healthcare-appointments project, with six commits stacked on top of the upstream history. Nothing about that is unusual for a take-home — recruiters fork a base app and add a scenario. I didn’t think about the commits again until the build broke.

The error, and the four minutes

At 2026-07-29 00:00:59 I ran npm install. It resolved fine, rewrote package.json and package-lock.json, no red flags in the terminal. At 00:12:46 I ran npm run dev and got the module-not-found error above. I opened postcss.config.js to see what was importing a module I’d never heard of, and found this, verbatim:

// postcss.config.js — as delivered
/** @type {import('postcss-load-config').Config} */
const paths = require('node-path-utils')   // ← assigned, never read

const config = { plugins: { tailwindcss: {} } };
module.exports = config;

paths is assigned and never referenced anywhere in the file. The only thing require('node-path-utils') does is execute, once, at build time, with my full user privileges — not sandboxed, not run in a browser, not reviewable by a linter that only checks unused variables inside a config file nobody expects to read. PostCSS config is exactly the kind of file that Next.js loads automatically on every npm run dev and npm run build, and exactly the kind of file no developer, including me, actually diffs before running the toolchain on a stranger’s repo.

By 00:14 I’d confirmed what this was: an unused import of a package I’d never asked for, in a build file that runs unconditionally, in a repo forked from a legitimate tutorial project by someone who wasn’t the tutorial’s author.

Reading the two commits that did it

Once I knew what to look for, git log -p on the six commits on top of upstream told the whole story in under a minute — which is the part that stings, because it would have told the same story that same Tuesday morning, roughly fourteen hours before the npm install that actually triggered it at 00:00:59, if I’d run it before installing anything.

The first of the two relevant commits, e0c3b2c, dated 2026-06-17 08:07 +03:00, is titled “update”. It adds "node-path-utils": "^1.23.2" to package.json and adds const paths = require('node-path-utils') to postcss.config.mjs — the .mjs extension, at that point, meaning ES modules, meaning require is undefined and the build would break immediately.

The second commit, bfce441, is 45 minutes later, at 08:52 +03:00, titled “admin undefined error fix”. It does three things: renames postcss.config.mjs to postcss.config.js — the one change that makes require resolve and the payload actually load — adds .env to .gitignore, and edits app/admin/page.tsx as an unrelated cover change bundled into the same commit.

That 45-minute gap is the whole case for intent. Nobody accidentally debugs a module-system error into a working state and then files the fix under an unrelated admin bug label. The rename from .mjs to .js is the single line of causality that turns a broken build into a working exploit, and it’s hidden inside a commit about something else entirely. The .gitignore addition for .env reads, in hindsight, like housekeeping to make sure a candidate’s real credentials never show up in a diff they’d send back. And package.json also carried "fs": "^0.0.1-security" — junk, since fs is a Node core module, but a version number in the shape of a dependency-confusion probe.

Why nothing happened anyway

Here’s the reversal, because it changes what this incident actually is. node-path-utils had already been pulled from npm by the time I cloned the repo. What ships at version 1.23.2 — the version pinned in package.json — no longer exists. In its place, npm publishes a 0.0.1-security stub: a security-holder package, no code, just a README and a package.json, that npm substitutes automatically when it takes a malicious package down. My npm install fetched that stub instead of the real payload. The cached registry metadata on my machine listed only 0.0.1-security — the 1.x line was already gone from the index. My install logs show the stub being reified and then removed when I stripped the payload; there’s no trace of a 1.x resolution anywhere. The only install hook that fired was @sentry/cli’s, which is legitimate upstream tooling unrelated to any of this.

So the module-not-found error wasn’t the payload succeeding at something subtle. It was the payload failing outright, because there was nothing left on the registry for it to load. postcss.config.js executed, require('node-path-utils') ran, and it hit a dead package. That’s the entire reason this is a postmortem about a near-miss and not an incident report about a compromised laptop: had I taken this assessment in June, while 1.23.2 was still live, the payload runs on the first npm run dev, silently, with my shell’s privileges, and I have no error message pointing me at postcss.config.js at all.

I still ran the machine down properly rather than trust the absence of an error. I swept the codebase for eval(, Function(, atob(, base64 blobs, and outbound webhook calls — clean, the only atob call was the upstream tutorial’s own passkey decode at lib/utils.ts:80. I pulled apart the PDF brief for embedded JavaScript, launch actions, or embedded files — no /JavaScript, /JS, /OpenAction, /Launch, or /EmbeddedFile entries; the three /AA entries it did have were benign, and every decompressed stream matched what you’d expect from a Google Docs export. None of it was necessary in the end, but none of it was optional either — an error message telling you the payload failed is not the same as proof it never ran.

The confrontation, and how fast the identities burned

At 00:19 I messaged the recruiter directly: “The code has a malicious package in it. What gives?”

I want to be precise about what happened next because it’s the strongest piece of evidence in the whole incident. Zolvo itself is a real company — I verified that independently, checked YC’s own directory, confirmed the founders (Isabela Rodriguez as CEO, Tony Montes as CTO) and the headcount. But the recruiter had never posted this role to YC’s own jobs board, which is free and the default channel any portfolio company would use. A team of three would be hiring its first or second engineer, not running a multi-part take-home pipeline. The job description named Redux, React Query, AWS, and GitHub Actions, and described lending; the actual repo was an Appwrite healthcare-appointments app using none of that stack and nothing to do with lending. And the repo itself lived under a personal account, rhondadavid, not a Zolvo organisation. Zolvo was impersonated, not involved — which made it useless as a lead for reporting this anywhere, since the company had nothing to do with it and nothing to secure.

Within the hour, both sides of the identity vanished. By 00:50 the GitHub repo returned a 404. The git commit author on the payload commits, Hephaestus12026 <Hephaestus12026@hotmail.com>, turns out never to have been a real GitHub account at all — just a local git config identity, unverifiable and untraceable through GitHub itself. The actual GitHub host account, rhondadavid, had been created on 2026-06-25 — eight days after the payload commits are dated, zero public repos, zero followers, no display name, clearly provisioned purely to host this one throwaway repo. By 01:00 the LinkedIn recruiter profile was gone too; the thread just shows “LinkedIn Member” where the name used to be.

Confronting someone at 00:19 and watching two independent identities get deleted by 01:00 tells you this wasn’t a lone opportunist checking messages once a day. That’s operational tempo — someone monitoring the channel in real time, ready to burn infrastructure the moment it stopped being useful. I’d preserved a git bundle --all of the repo before any of that happened, 4.3MB, plus the commit log and a patch of the two payload commits, precisely because I assumed evidence has a shelf life measured in minutes once you tip your hand. The LinkedIn side I only have as a screenshot in the thread itself — if I’d waited to capture that, it would already have been gone.

What I’m actually taking from this

The honest version of the lesson is uncomfortable: detection here was luck, not process. A broken build caught this. If npm’s takedown of node-path-utils had landed a month later, I would have run npm run dev clean, the payload would have executed with my shell privileges, and there would have been no error message directing me anywhere. I have no control on this machine that would have caught a payload that actually ran — only forensics after the fact to check whether it had.

Two changes follow directly from that gap, and I’ve made both. Any repository that arrives via a recruiter, a stranger, or a hiring pipeline now goes into a container or a disposable VM before I run npm install on it — not because I think most take-homes are malicious, but because the cost of assuming they’re not is a build script running arbitrary code as me. And git log -p on anything forked from an upstream project happens before any install step, not after. It would have taken under a minute to read those two commits — “update” quietly planting a broken require, “admin undefined error fix” quietly fixing it 45 minutes later under a false label — and it would have cost nothing, because at that point no code had run at all.

postcss.config.js, next.config.mjs, vite.config.ts, tailwind.config.ts — these all execute with full privileges on every build, and I’d never once thought of them as an attack surface before this. They’re not. Everyone treats them as inert scaffolding. That’s exactly what makes them the right place to hide something.