DEV Community

Papan Sarkar
Papan Sarkar

Posted on Originally published at papansarkar.com

How a Clean Next.js Repo Ran Malware on Every npm run dev

A codebase I got through Fiverr looked like an ordinary Next.js invoice app. It wasn't.

Two dependencies deep, gated behind an npm auth token committed in the project's own .npmrc, sat a private package carrying roughly 180 KB of heavily obfuscated JavaScript — wired to execute every time the dev server starts.

I caught it before running it. Here is the short version.

Every standard check passed

package.json scripts held only dev, build, start and lint. No preinstall, no postinstall, no prepare.

A grep for the usual suspects came back empty:

grep -rnE "eval\(|new Function|child_process|execSync|spawn\(|atob\(|fromCharCode|base64" \
  --include=*.js --include=*.mjs --include=*.cjs --include=*.ts --include=*.jsx --include=*.tsx . \
  | grep -v node_modules
Enter fullscreen mode Exit fullscreen mode

A long-line grep, which is how whitespace-padded payloads usually surface, matched only SVG path data and Tailwind class strings.

The repo was clean. The malicious code was never in it.

Three things that didn't add up

1. The project shipped an npm auth token.

$ cat .npmrc
//registry.npmjs.org/:_authToken=npm_HIswo…[redacted]
Enter fullscreen mode Exit fullscreen mode

An npm access token carries "the right to install and/or publish your modules", including read access to private packages that account can see. A stranger's deliverable has no reason to include one. Its only function here was to let npm install fetch something you otherwise could not.

2. A Tailwind plugin I'd never heard of. devDependencies had "tailwind-animationlab": "^2.0.6". The real ones cluster around similar names — tailwindcss-animate, tailwindcss-animated, tailwind-animations. At the bottom of tailwind.config.js:

plugins: [
  require('tailwind-animationlab')({
    duration: '500ms',
    delay: '1s',
    iterationCount: '2'
  })
],
Enter fullscreen mode Exit fullscreen mode

Nothing about that looks wrong — and that's the point. Tailwind's own docs register plugins exactly this way, with require() inside the plugins array. The config file was padded with friendly comments explaining unused theme values, which made one more require easy to skim past.

3. The timestamps. Nearly every file was dated Sep 13, 13:29. Three were not: .npmrc (Sep 13, 14:15), tailwind.config.js (Sep 28, 09:49), package.json (Sep 28, 09:50). Exactly the three files needed to authorize, inject and execute a dependency.

Following the chain

From my home directory, so the project's token wasn't picked up:

cd ~ && npm view tailwind-animationlab
Enter fullscreen mode Exit fullscreen mode

One version, numbered 2.0.6 — new packages don't start at 2.0.6; it was chosen to satisfy the ^2.0.6 already in the project. Registry creation date 2026-09-28, the same day those config files were edited. npm maintainer adriannamrr, but author and homepage pointing at sericpieap. And one dependency I didn't recognise: @jasperquinn/postcss-motion-helpers.

I unpacked the decoy without executing it. Clean. It was built to survive a code review, because that's the layer a careful developer actually inspects.

Then:

npm view @jasperquinn/postcss-motion-helpers
# npm error 404 Not Found
Enter fullscreen mode Exit fullscreen mode

A public package depending on a private one. Private npm packages "always have a scope" and are readable only by the owner and granted collaborators. So for anyone else on earth, installing tailwind-animationlab fails. It resolves only on a machine holding the attacker's token — which is to say, on a victim's machine, supplied through that .npmrc.

With the token, inside a throwaway Codespace on a burner GitHub account, still executing nothing, the private tarball's src/config.js was a wall of machine-generated obfuscation: an encoded blob, _0x hex identifiers, a large switch-based dispatch loop, heavy Proxy and Reflect use. A PostCSS helper has no reason to look like that.

Why --ignore-scripts wouldn't have helped

Most npm hardening advice is about install hooks. But npm's config reference is explicit that npm run and npm start "will still run their intended script if ignore-scripts is set" — they only skip pre- and post-scripts.

This attack never wanted an install hook. Tailwind v3 loads tailwind.config.js whenever it generates CSS, so that require() evaluates on every npm run dev and every npm run build. The trigger was the developer doing their job.

Same logic covers a neighbouring trick: VS Code's runOn: folderOpen means "the task will be run when the containing folder is opened". Clicking Trust on a stranger's folder is a code-execution decision.

Is this a known campaign?

The tradecraft matches Contagious Interview. Socket counted more than 338 malicious npm packages with over 50,000 cumulative downloads in a single wave, tied to more than 180 fake maintainer personas, attributed to "North Korean state-sponsored actors" — and its package list includes names impersonating tailwindcss, vite, eslint and next. Elastic documented a tampered Next.js e-commerce template carrying a credential and wallet stealer plus a Socket.IO RAT.

To be precise: I have not confirmed this sample is Contagious Interview. My delivery channel was a paid Fiverr gig, not a fake job interview, and I didn't reverse the payload. What matches is the playbook.

What I check now

  • ls -la — .npmrc with an _authToken, a custom registry=, a .vscode/tasks.json
  • scripts in every package.json, not just the root one
  • *.config.js for Tailwind, PostCSS, Next, ESLint, Vite — any require() you don't recognise
  • npm view <pkg> run from ~: single version, starting at 2.x+, published recently, maintainer ≠ author
  • npm view <pkg> dependencies: unknown scoped packages, or a 404 on the public registry
  • ls -la again, for a handful of config files edited separately from everything else

Two rules carry most of the weight: read unknown code in a disposable environment with no real credentials, and treat any .npmrc you didn't write as hostile.

Status as of writing

I reported both packages and both maintainer accounts to npm, the seller to Fiverr, and the GitHub account to GitHub. The sericpieap GitHub account and its homepage now return 404. But tailwind-animationlab@2.0.6 is still installable from the public registry, and @jasperquinn/postcss-motion-helpers still 404s publicly — the delivery mechanism is intact.

Indicators: tailwind-animationlab@2.0.6, shasum 9636e080056e59024e3e0d70f998d8f80e9129a9, maintainer adriannamrr, author/homepage sericpieap, created 2026-09-28. Private dependency @jasperquinn/postcss-motion-helpers@1.0.3, referenced as ^1.0.1.

If you've seen these package names in a dataset, I'd like to hear about it.

Top comments (0)