Opening
Around January 2022, the lead maintainer of colors.js and faker.js (Marak Squires) went public with a complaint: large companies were using his open source for free without paying him a cent. As a form of protest, he intentionally broke his own libraries. Both packages were used in thousands of projects, including a number of large companies, and colors.js was downloading at over 20 million per week. A lot of builds and services went down because of it.
There’s a representative package manager for nearly every language. Ruby has gem, Python has pip, JavaScript has npm, Rust has cargo. I’ve shipped services fast on top of these open-source ecosystems, pulled all-nighters chasing dependency conflicts, and at times even monkey-patched open-source libraries for my own use. Most of the time, I was grateful. These libraries let me build quickly, and I was happily inheriting the work of all the Stack Overflow veterans who had suffered before me. After importing one open-source library after another, at some point I started wondering whether coding was just LEGO(?). (Even before the AI era, there was always a recurring jab: “If you’re just gluing open source together, is that really development?”)
In that context, the colors.js incident hit me hard. The line “If you’re just gluing open source together, is that really development?” stopped being a cheap jab and started feeling like an existential question about my own output. What am I actually building? Am I a coder, or an engineer? The old software adage “don’t reinvent the wheel” started to feel less obvious. Mostly true. But really? (My drift toward Go over Python or Node.js, and Swift over Flutter on mobile clients, isn’t unrelated to this. Going fully without package dependencies is hard, but heading in the direction of fewer is something I started to value.)
As it happened, two more incidents hit back-to-back. One was the litellm (PyPI) maintainer-account takeover on March 24, 2026. The other was the axios (npm) OS-specific malicious RAT distribution on March 31.
Both landed close to home for me. litellm shows up in many open-source agent plugins (for Claude Code, Codex, and similar tools), and axios was the library I always reached for when working in React.
Since the LLM/AI era took off, a lot of Korean developers have been releasing open source one after another, and some of those libraries are genuinely good. I haven’t built anything that qualifies yet, but the litellm and axios incidents pushed me to write down what I think about open-source design, and why zero dependency matters.
“Don’t reinvent the wheel” is fine advice. The question is, how far can I trust the wheel?
March 2026: Two Incidents in litellm and axios
litellm: The day an LLM integration library became a credential-stealing tool
litellm is a Python library that unifies more than 100 LLM providers (OpenAI, Anthropic, Bedrock, Vertex AI, and more) behind a single interface. If you run an LLM-based service, chances are you’ve typed pip install litellm at some point. PyPI shows millions of monthly downloads. It’s one of the core packages in the AI infrastructure stack.
On March 24, 2026, litellm’s PyPI maintainer account was hijacked. The attacker pushed a poisoned version through the normal release process. From the outside, it looked like a routine update. Anyone who ran pip install --upgrade litellm got the malicious package with no warning.
The technical structure was clever. The malicious code abused Python’s .pth file mechanism. A .pth file is a path-configuration file that Python runs automatically at startup, and the attacker injected code into one so that the payload would fire the moment Python started, even without import litellm. Install the package, write zero lines of import code, and the next time Python runs, you’re infected.
According to Sonatype’s analysis, the payload ran in stages. Stage one collected system environment information. Stage two scraped API keys, cloud credentials, and database connection strings from environment variables and shipped them to an external server. If you run an LLM service, your environment variables are usually a treasure chest: OpenAI API keys, AWS credentials, database passwords. The attacker knew exactly where to look.
Kaspersky’s report pointed out that this wasn’t litellm-only. It was a coordinated supply-chain campaign that also targeted Trivy, Checkmarx, and other security-tool packages. Security tools as the security hole. You can’t make this up.
The most striking part is the transitive dependency path. Organizations that installed litellm directly weren’t the only victims. Anyone who installed something that depended on litellm (certain Cursor MCP plugins, some agent frameworks) got litellm pulled in alongside, and the infection path opened. According to HeroDevs’ analysis, a large number of vulnerable projects didn’t even use litellm directly.
I had a close call myself. I’d recently installed a Claude Code agent skill called ouroboros, and litellm was somewhere in its dependency tree. Luckily I never actually ran the skill, so litellm never activated in my Python environment. But if I’d run it even once, the API keys in my environment variables could have leaked. It made my stomach drop.
axios: The day an HTTP client became a RAT delivery tool
axios is the most widely used HTTP client library in the JavaScript and TypeScript ecosystem. Over 100 million weekly downloads on npm. If you’re working in React, Vue, or Node.js and you need to call an API, your fingers practically type npm install axios on their own. I reached for it every single React project.
On March 31, 2026, the axios npm package was compromised. According to Elastic Security Labs’ detailed writeup, the attacker used the postinstall script in package.json. That hook runs automatically the moment npm installs a package, and the attacker dropped a malicious script into it. Run npm install, and before you’ve imported a single line of code, the attacker’s code is running.
The cleverness was in shipping different payloads per OS. On Windows, it pulled an executable via PowerShell. On macOS, it combined curl and bash to fetch a binary. Linux had its own payload. Huntress’ report confirmed that the payload was a RAT (Remote Access Trojan). A backdoor that lets an attacker connect to your machine remotely, read files, capture keystrokes, install more malware.
Sophos’ analysis listed the RAT’s capabilities: system information collection, filesystem traversal, keylogging, screen capture, downloading and executing additional payloads. Effectively, full remote control of an infected developer machine.
This incident made it painfully clear why npm’s postinstall hook is dangerous. npm runs preinstall, install, and postinstall scripts in order during package installation, and most developers have no idea this is happening. Type npm install axios and all you see is the install progress bar. Almost nobody checks what scripts ran behind it. Even the official npm docs recommend minimizing postinstall use, but countless packages still rely on it.
Look at the two incidents side by side and the pattern emerges. Both compromised a maintainer account or release pipeline. Both infected you just by installing the package, with zero lines of code executed. And both targeted core packages that sit deep inside millions of projects’ dependency trees.
litellm hit the spine of AI infrastructure. axios hit the spine of web development. One week apart.
Ten Years of Pattern: From Disruption, to Damage, to Infiltration
Open-source dependency incidents didn’t suddenly start in 2026. There’s a ten-year arc, and it’s been moving in one direction.
2016, left-pad: the disruption era
In March 2016, a developer named Azer Koçulu deleted his left-pad package from npm. It was an 11-line function. Pad a string on the left with spaces or a specific character. That was it. When those 11 lines vanished, builds broke across thousands of projects, including React and Babel.
David Haney asked at the time on his blog, “Have we forgotten how to program?” The fact that we’d reach for a package instead of writing 11 lines of string padding ourselves was funny and a little sad at the same time.
The essence of left-pad was disruption. When the dependency disappears, the build breaks. That’s all there was to it. No malice, no intent to harm. It started with “I’m deleting my own package, what’s the problem?” and the damage stopped at failed builds and delayed deploys. npm tightened its unpublish policy afterward, people thought about dependencies for a minute, and then forgot.
2022, colors.js: the damage era
Six years later, Marak Squires injected an infinite loop into his own colors.js and faker.js. This time it wasn’t a mistake. It was deliberate sabotage. The motive was anger that big companies were making money off his open source while none of it came back to him. Every application that imported colors.js stalled while spamming “LIBERTY LIBERTY LIBERTY” to the console.
The shift from disruption to damage had begun. left-pad was a failure of absence. colors.js was code that was actively present and doing harm. The problem wasn’t the missing package. The problem was the package that was there. The vector flipped.
Even so, colors.js still had a “human face.” The attacker was the original author, not an anonymous hacker. The motive (whether you agreed with it or not) was a comprehensible grievance. The damage was service disruption, not data exfiltration or credential theft.
2026, litellm and axios: the infiltration era
The two incidents in March 2026 crossed into different territory. Not a maintainer’s personal protest, but the planned infiltration of an organized attacker. Auto-execution via .pth, the postinstall hook, OS-specific RATs. The technical sophistication is on another level. So is the goal. Not stopping the service, but stealing credentials, planting backdoors, and securing persistent access.
Here’s the ten-year arc in one table.
| Year | Incident | Type | Motive | Damage scope |
|---|---|---|---|---|
| 2016 | left-pad | Disruption | Personal decision | Build failures |
| 2022 | colors.js | Damage | Protest | Service outages |
| 2026 | litellm/axios | Infiltration | Organized attack | Credential theft, remote control |
Russ Cox warned about this in 2019 in Our Software Dependency Problem: “Software dependencies are part of the software supply chain, and supply-chain security is determined by the weakest link.” It sounded like a theoretical warning back then. Seven years on, the warning is reality.
I used to fear a dependency breaking on me. Now I fear a dependency breaking me.
My Tools: Superpowers and Zero Dependency
4-1. Why I picked Superpowers
I introduced this in a previous post, but Superpowers is a skill framework that gives AI coding agents like Claude Code or Codex a systematic development workflow. It enforces the flow of question → design → plan → TDD → code review automatically, without any commands. I adopted it because it bundles together the interview command, TDD skill, and code-review skill I’d been building separately into a single structure.
Honestly, “zero dependency” wasn’t on the list of reasons I picked Superpowers. The workflow matched how I work, the skill structure was intuitive, and the brainstorm → plan → execute flow lined up with the development process I aim for. After the litellm/axios incidents I went back into the Superpowers code, and there it was: a concrete answer to the question “how should you design good open source?“
4-2. Proof in code: 354 lines of server.cjs
Superpowers ships with a local web server used during brainstorming sessions. It serves HTML to a browser, sends real-time updates over WebSocket, and watches files for changes. Anyone who’s done web work knows the usual stack for this kind of feature: Express (HTTP server), ws (WebSocket), chokidar (file watcher). And installing those three drags hundreds of sub-packages into node_modules.
That’s exactly how this server started out. The v5.0.2 release notes record what happened next.
Zero-Dependency Brainstorm Server Removed all vendored node_modules — server.js is now fully self-contained. Replaced Express/Chokidar/WebSocket dependencies with zero-dependency Node.js server using built-in
http,fs, andcryptomodules. Removed ~1,200 lines of vendorednode_modules/,package.json, andpackage-lock.json.
Express, chokidar, and ws plus their transitive dependencies amounted to about 1,200 lines of vendored code, all stripped out. The whole server was rewritten using only Node.js built-ins. The result is a single file, server.cjs, 354 lines long.
The Superpowers package.json makes the choice obvious.
{
"name": "superpowers",
"version": "5.0.7",
"type": "module",
"main": ".opencode/plugins/superpowers.js"
}
No dependencies field. No devDependencies. As an npm package, the external dependency count is literally zero. Run npm install on this project and not a single external package is added to node_modules.
Now let’s look at how those 354 lines actually break down.
1) WebSocket protocol: implementing RFC 6455 directly
Instead of using ws, this code implements the WebSocket protocol (RFC 6455) by hand. The core of WebSocket is frame encoding and decoding. Wrapping the data the server sends into binary frames, and unmasking the masked frames the client sends.
const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0a };
const WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function computeAcceptKey(clientKey) {
return crypto
.createHash("sha1")
.update(clientKey + WS_MAGIC)
.digest("base64");
}
This is the WebSocket handshake. Take the Sec-WebSocket-Key the client sends, append the magic string, run SHA-1, and produce Sec-WebSocket-Accept. Exactly what RFC 6455 Section 4.2.2 specifies. That’s what the ws library does internally. Written by hand, it’s five lines.
Frame encoding is the same story.
function encodeFrame(opcode, payload) {
const fin = 0x80;
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2);
header[0] = fin | opcode;
header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = fin | opcode;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = fin | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
return Buffer.concat([header, payload]);
}
The logic picks a 2-byte, 4-byte, or 10-byte header depending on payload length. The whole core of the WebSocket frame format fits in this one branch. Under 126, the length is packed into 7 bits. Between 126 and 65536, a 16-bit extension. Above that, a 64-bit extension.
Decoding is the reverse. Client frames must be masked (RFC 6455 requires it), so a 4-byte mask key is XOR’d back through the payload.
function decodeFrame(buffer) {
if (buffer.length < 2) return null;
const secondByte = buffer[1];
const opcode = buffer[0] & 0x0f;
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7f;
let offset = 2;
if (!masked) throw new Error("Client frames must be masked");
// ... length parsing, mask key extraction ...
const mask = buffer.slice(maskOffset, dataOffset);
const data = Buffer.alloc(payloadLen);
for (let i = 0; i < payloadLen; i++) {
data[i] = buffer[dataOffset + i] ^ mask[i % 4];
}
return { opcode, payload: data, bytesConsumed: totalLen };
}
The ws library also gives you permessage-deflate compression, fragmentation, subprotocol negotiation, and more. Does the Superpowers brainstorm server need any of that? Does sending an HTML reload message over localhost need compression? It does not. So the implementation only covers what’s actually needed. TEXT, CLOSE, PING, PONG. Four opcodes is enough.
2) HTTP server: no Express
Express is gone, replaced by Node.js’s built-in http module. There are exactly three routed paths, so a middleware stack adds nothing.
function handleRequest(req, res) {
touchActivity();
if (req.method === "GET" && req.url === "/") {
const screenFile = getNewestScreen();
let html = screenFile
? (raw => (isFullDocument(raw) ? raw : wrapInFrame(raw)))(
fs.readFileSync(screenFile, "utf-8")
)
: WAITING_PAGE;
if (html.includes("</body>")) {
html = html.replace("</body>", helperInjection + "\n</body>");
} else {
html += helperInjection;
}
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
} else if (req.method === "GET" && req.url.startsWith("/files/")) {
// static file serving
const fileName = req.url.slice(7);
const filePath = path.join(CONTENT_DIR, path.basename(fileName));
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end("Not found");
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
res.end(fs.readFileSync(filePath));
} else {
res.writeHead(404);
res.end("Not found");
}
}
Serve the most recent HTML on /. Serve static resources on /files/*. Everything else, 404. That’s all there is. What app.get(), app.use(), and app.static() do under the hood is exactly this. Does a server with three routes really need a framework’s middleware chain?
3) File watching: no chokidar
Node.js’s fs.watch() is famous for behaving differently per platform. On macOS, the rename event fires for both new file creation and overwriting an existing file. That’s why most projects reach for chokidar, which gives you cross-platform compatibility, recursive watching, stable event debouncing, and so on.
But the Superpowers server has exactly one watch target. Changes to .html files in the CONTENT_DIR folder. One directory, one extension. In that case, attaching your own debounce timer to fs.watch() is plenty.
const debounceTimers = new Map();
const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {
if (!filename || !filename.endsWith(".html")) return;
if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
debounceTimers.set(
filename,
setTimeout(() => {
debounceTimers.delete(filename);
const filePath = path.join(CONTENT_DIR, filename);
if (!fs.existsSync(filePath)) return;
touchActivity();
if (!knownFiles.has(filename)) {
knownFiles.add(filename);
// reset event file when a new screen is added
const eventsFile = path.join(STATE_DIR, "events");
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
}
broadcast({ type: "reload" });
}, 100)
);
});
A Map tracks per-file debounce timers, and a Set tracks known files. The macOS rename double-fire problem dissolves naturally with a 100ms debounce. Recursive watching, glob patterns, symlink tracking. chokidar gives you those, and none of them are needed here.
4) WebSocket upgrade handshake
The HTTP-to-WebSocket protocol upgrade is also handled by hand.
function handleUpgrade(req, socket) {
const key = req.headers["sec-websocket-key"];
if (!key) {
socket.destroy();
return;
}
const accept = computeAcceptKey(key);
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: " +
accept +
"\r\n\r\n"
);
// ... frame-based communication after this
}
A four-line handshake that writes an HTTP 101 directly to the socket. That’s what ws’s WebSocket.Server does inside.
4-3. Cost and value: 1,200 lines down to 354
The previous version’s vendored node_modules was about 1,200 lines. Express’s middleware chain, chokidar’s cross-platform abstraction, ws’s full WebSocket spec implementation, all included. After the refactor, 354 lines. The 846 lines that disappeared weren’t useless code. They were “code for features this project doesn’t need.”
Rob Pike put it this way in Go Proverbs:
“A little copying is better than a little dependency.”
The first time I read that, my reaction was “yeah, in theory, but realistically…” After looking at server.cjs, I can put a number on what “a little copying” actually means. WebSocket handshake, 5 lines. Frame encoding, 20 lines. Frame decoding, 30 lines. HTTP routing, 25 lines. File watching, 15 lines. Total, about 100 lines of “stuff the library used to do.” The other 254 lines are business logic (screen management, event handling, lifecycle management), code you’d write yourself regardless of which library you picked.
100 lines of “copying” replaced 1,200 lines of dependency. And those 100 lines are readable. If you’ve read RFC 6455, you can verify this WebSocket implementation yourself. That’s a different level of transparency from chasing through Express’s internals to confirm the middleware chain is correct.
The value of zero dependency isn’t only security. It’s readability. 354 lines is something one person can sit down and read end to end. You can understand every behavior the code has. You know exactly what code runs in your project. Throw in 1,200 lines of vendored dependencies and you start leaning on the assumption that “most of this is fine, no need to look.” That assumption shatters when litellm or axios happens.
What Good Open-Source Design Looks Like: Code-Level Patterns
Superpowers’ server.cjs is one example. There are more zero-dependency projects than you might think, and they share a common set of code-level patterns. I see three layers.
Layer 1: Keep the core value outside the code
The core value of good open source isn’t the code itself. It’s the mental model the code implements. Superpowers’ core value isn’t the 354 lines of server.cjs. It’s the workflow mental model: question → design → plan → TDD → review. That model lives in markdown skill files. The code is just a helper that supports it.
In a structure like that, dependencies can’t help shrinking. If the core value isn’t in code execution, the executable code only needs to do the minimum supporting work. Not putting complex dependency-heavy features into the “core value” is the first design principle.
Layer 2: Keep the executable code small
When code does need to run, narrow the scope of what it does. server.cjs covers exactly one scope: a local brainstorming server. It’s not trying to be a general-purpose web framework. It only implements what this single use case needs. Narrow scope means fewer features. Fewer features means a higher chance you can build it without external libraries.
Layer 3: Separate install from execution
The scariest part of the litellm incident was the auto-execution via .pth. Code runs at install time. axios’s postinstall hook is the same shape. Good design draws a clear line between “install” and “execute.” Installing a package should mean copying files to disk, nothing more. No code should run automatically. Code should run only when the user explicitly imports it or runs a command.
Why zero dependency is possible
Let’s see how those three layers play out in real projects, and what technical strategies make zero dependency possible.
zod: TypeScript schema validation library. Over 30 million weekly downloads on npm. Zero dependencies. zod can run dependency-free because the TypeScript type system itself is enough to express validation logic. Runtime checks are built from plain JavaScript conditionals and type guards. No external parser engine, no code generator. Schema definition and runtime checking both ride on TypeScript’s type inference. The library’s job is “take a JavaScript value, run conditionals on it, narrow the type if it matches,” and there’s no part of that which needs outside help.
nanoid: unique ID generator. Zero dependencies. One file, 130 bytes gzipped. nanoid’s core is a single web standard API: crypto.getRandomValues(). It’s built into both browsers and Node.js, gives you cryptographically secure randomness, and nanoid just encodes the output as a URL-safe string. UUID libraries often need dependencies because they have to polyfill older environments. nanoid sidesteps that by supporting only modern runtimes.
picocolors: terminal coloring library. The zero-dependency alternative to chalk. One file, 0.1KB bundle size. Terminal coloring is fundamentally about wrapping strings with ANSI escape sequences (\x1b[31m for red, \x1b[0m for reset). chalk had over 10 dependencies for color-space conversion, 256-color support, Windows console compatibility, and other extras, but most CLI tools really only use red, green, yellow, and bold. picocolors decided to support only that “most,” and ended up with zero dependencies.
Hono: web framework. Multi-runtime support across Node.js, Deno, Bun, Cloudflare Workers. Zero dependencies. Hono can stay 0-dep because it uses only the Web Standard API (Request, Response, URL, Headers). Express built its own abstraction layer over Node.js’s http module. Hono runs on the web standard interface that all runtimes already implement. The standard, not the library, papers over runtime differences.
chi: Go HTTP router. Zero dependencies. Go’s standard library net/http already provides a strong HTTP server, and chi just adds one routing tree (a radix tree) on top. Zero dependency is unusually common in the Go ecosystem partly because the standard library covers so much already, and partly because Rob Pike’s “a little copying is better than a little dependency” runs through the community.
sqlc: a tool that compiles SQL queries into Go code. Zero runtime dependencies. ORMs generating queries and mapping results at runtime usually need reflection, code generation, and connection pooling, all of it complicated runtime machinery. sqlc flips the approach. It parses SQL at build time and generates type-safe Go code, so at runtime the standard database/sql package is plenty. The complexity moved from runtime to build time.
The shared traits across these projects:
- Narrow scope. They do one thing well. They don’t try to cover every case.
- Lean on platform built-ins. Don’t reimplement what the runtime already provides.
- Don’t confuse extras with the core. The courage to leave out “nice to have” features.
Vendoring and runtime evolution: structural change driven by tech
Google uses a vendoring strategy in its large monorepos. They copy external dependency source code directly into their own repository and patch it independently afterward. The point is securing “control” over the dependency. Even if a package is tampered with on npm or PyPI, the vendored copy is unaffected.
Bun and Deno are worth watching too. Bun bundles the bundler, transpiler, and package manager into the runtime itself. Work that used to need three external tools (webpack, babel, npm) and all their dependency trees collapses into a single runtime. Deno introduced a URL-based module system without npm from day one, and its native TypeScript support removed the need for tools like ts-node.
The same pattern shows up in Node.js itself. Node.js 18 added a built-in fetch(), which trimmed the need for an HTTP client package. Node.js 20 stabilized a built-in test runner, which trimmed the reason to install jest or mocha for simple tests. The bigger the runtime gets, the less need there is for external dependencies. And the fewer external dependencies, the smaller the attack surface. Regardless of any individual developer’s intent, the technical infrastructure itself is evolving in the direction of fewer dependencies.
Developer Behavior Has to Change
Up to here it’s been a story about design and technology. What good open source looks like, how runtimes are evolving. But no matter how many times the runtime adds fetch() or how many projects pursue zero dependency, a developer’s hand still types npm install. Tech opens the door. Behavior actually has to walk through it.
The three layers of dependency cost
The cost of adding a dependency comes in three layers.
Layer 1, surface cost (the visible stuff). Bundle size growth, longer install times, larger node_modules folders. Most developers see this cost, and it’s the most discussed. In practice, it’s also the least important.
Layer 2, maintenance cost (what shows up over time). Version conflicts, breaking changes, deprecated API migrations, security patches. These costs accumulate as a project runs for one, two, five years. The maintenance burden of a 10-dependency project versus a 100-dependency one isn’t linear. It’s exponential.
Layer 3, trust cost (invisible until something blows up). Supply-chain attack exposure, maintainer departures, license changes, malicious-code injection. This is the layer litellm and axios revealed. The probability is low, but when it triggers, the damage outweighs layers 1 and 2 combined. And the more dependencies you have, the higher the probability climbs. Each dependency widens the attack surface.
When AI recommends dependencies
There’s an interesting, slightly unsettling phenomenon. Ask an AI coding assistant to write code that makes an HTTP request, and a large fraction of the time you get code that imports axios. Even though Node.js 18 and later ship with built-in fetch(). The AI’s training data is overwhelmingly axios, so the most “probabilistically plausible” suggestion is axios.
Compared side by side:
// Before: axios dependency
const { data } = await axios.get("https://api.example.com/data");
// After: built-in fetch (Node.js 18+)
const data = await fetch("https://api.example.com/data").then(r => r.json());
Is there a reason to install an external package for those two lines? If your project genuinely needs axios’s interceptors, request cancellation, or automatic JSON conversion, fine. But for the majority case of a simple GET or POST, built-in fetch() is plenty. The AI doesn’t make that contextual judgment. It just emits the most frequent pattern from its training data.
This creates a feedback loop where “popular package equals good package” gets stronger in the AI era. Heavy use leads to AI recommendations, AI recommendations lead to even heavier use, and heavier use raises the attack value. axios’s 100M weekly downloads is also a signal to attackers: “Hit here and you reach 100 million projects.”
That’s why uncritically accepting AI-generated code is dangerous. AI doesn’t check whether the package’s maintainer has 2FA enabled. AI doesn’t inspect what the package’s postinstall hook actually does. AI just pattern-matches and emits the most common code, and the most common code is not guaranteed to be the safest code.
Checklist: six things to verify before adding a dependency
Here’s a practical checklist. Before you add a new package to your project, run through at least these six items.
1) Is there a built-in alternative?
Check whether you’re using an external package for something Node.js’s fetch(), crypto, test runner, path, or url could already handle.
# List Node.js built-in modules
node -e "console.log(require('module').builtinModules.join('\n'))"
2) How deep is the dependency tree? A single direct dependency can drag in 100 transitive ones. Inspect the tree before installing.
# Inspect an npm package's dependency tree
npm view <package> dependencies
# Visualize the full tree
npm ls --all
# For Python
pip show <package> | grep Requires
3) Maintainer info and activity history? OpenSSF Scorecard automatically grades the security practices of an open-source project. Maintainer 2FA usage, code-review practices, CI/CD security, branch protection rules, all scored.
# Check security score with OpenSSF Scorecard
# https://scorecard.dev/ — paste your GitHub repo URL
# Or via CLI:
scorecard --repo=github.com/<owner>/<repo>
4) Are there postinstall hooks or auto-run code?
The core of the axios incident was the postinstall hook. Inspect the package’s scripts field before installing.
# Check a package's install scripts
npm view <package> scripts
# Find postinstall hooks among already-installed packages
find node_modules -name "package.json" -exec grep -l "postinstall" {} \;
5) What’s the SLSA level? Supply-chain Levels for Software Artifacts. A framework that indicates how verifiable a package’s build process is. SLSA Level 3 or higher means the build process is recorded in a tamper-evident way, so even if an attacker compromises the build pipeline, you can trace it.
6) How many lines if you implemented it yourself? left-pad was 11 lines. The Superpowers WebSocket handshake was 5 lines. If the cost of writing it yourself is low, it can be a better choice than adding a dependency. Remember Rob Pike’s “a little copying is better than a little dependency.”
This checklist isn’t saying “never use any dependency.” Implementing your own crypto library is a fast path to a security disaster. Writing your own database driver is unrealistic. Asking “do I really need this package, or am I adding it out of habit?” alone reduces your attack surface.
Closing
npm install was never a convenience command. It was a trust command.
The moment I type npm install axios, I’m declaring that I trust axios’s maintainer, the maintainers of every package axios depends on, the security posture of all those maintainers’ accounts, the integrity of the npm registry, and every single line of code the postinstall hook will run on my system. pip install litellm is the same. Most of us have been running this command dozens of times a day without registering the weight of what we’re trusting.
354 lines is something you can read. 1,200 lines is something you can only trust.
What Superpowers’ server.cjs shows isn’t minimalism or showing off. It’s a decision: “I’ll only put code I can understand into my project.” Of course not every project should write its own 354-line implementation. That’s unrealistic. What is realistic is asking, every single time, “do I really need this dependency, would it be hard to write myself, do I have grounds to trust this package?”
Few dependencies isn’t minimalism. It’s reducing what I have to trust.
In the end, “don’t reinvent the wheel” is still good advice. Building the wheel yourself is a waste of time most of the time. But the adage probably needs a line added to it.
Don’t reinvent the wheel. But know what the wheel is doing in your car.
References
- litellm, Security Update — March 2026
- Sonatype, Compromised litellm PyPI Package
- Kaspersky, Critical Supply Chain Attack
- HeroDevs, The LiteLLM Supply Chain Attack
- Elastic Security Labs, Axios: One RAT to Rule Them All
- Huntress, Supply Chain Compromise: Axios
- Sophos, Axios npm Package Compromised
- Russ Cox, Our Software Dependency Problem (2019)
- Rob Pike, Go Proverbs (2015)
- David Haney, Have We Forgotten How to Program? (2016)
- Suckless, Philosophy
- Wikipedia, npm left-pad incident
- SLSA, Supply-chain Levels for Software Artifacts
- OpenSSF, Scorecard
- npm, Scripts Best Practices
- GitHub, obra/superpowers
- Flowkater.io, Introducing Superpowers
댓글
댓글을 불러오는 중...
댓글 남기기
Legacy comments (Giscus)