DEV Community

Cover image for npm install && pray
Jo Franchetti
Jo Franchetti

Posted on

npm install && pray

There’s a ritual every JavaScript developer knows.

You’re building something. You need to parse a date, validate an email, or format a number. So you do what any reasonable person does, you open a terminal, type npm install, pick a package with four million weekly downloads and a friendly README, and move on.

For a long time, that felt safe. The openness of npm and the kindess of opensource developers helped turn the JS ecosystem into something genuinely remarkable. We had a global library of shared effort, where someone could publish a utility at 2am and developers on the other side of the world could be using it in production by morning.

But the openness and expansiveness of the JavaScript ecosystem was built on trust. And, sadly, that trust is constantly being eroded.

The worm that changed the mood

The Sandworm from Dune

In September 2025, ReversingLabs documented the first known self-replicating worm in the npm ecosystem. They traced the initial compromise to rxnt-authentication@0.0.3, then watched the malware spread through compromised maintainer accounts into hundreds of packages.

A couple of months later, Datadog described a second wave: Shai-Hulud 2.0. That campaign backdoored 796 npm packages totaling more than 20 million weekly downloads.

The mechanics were elegant in the worst possible way. Infect one developer, steal their npm credentials, then publish tainted versions of other packages they maintain, and let every new victim become the next distribution channel. The malware hunted for environment variables, cloud tokens, GitHub credentials, and npm auth tokens, then exfiltrated what it found while the application appeared to run normally. The infected developers none the wiser.

Then came the phishing attack against maintainer Josh Junon (qix). Attackers used a fake npmjs.help domain and a convincing support email to steal publishing credentials. From there, they pushed malicious versions of widely used packages including chalk, debug, ansi-styles, strip-ansi, and other foundational libraries. At the time of disclosure, the affected packages accounted for roughly 2.6 billion weekly downloads.

Not a particularly sophisticated exploit. Just a phishing email and the right publishing permissions.

Why this keeps working

Supply-chain attacks succeed because the trust model still assumes the ecosystem is friendlier and safer than it is.

Version ranges put trust on autopilot

When your package.json says ^2.1.0, your CI can quietly pull in 2.1.1 the moment it ships. That’s convenient! It means a maintainer’s latest publish can slide straight into your build without a human ever reviewing it, but you are implicitly trusting that every update is safe.

The soft target is the maintainer, not the package

The attack on Qix didn’t require finding a flaw in chalk or debug. It required compromising the person who could publish them. Once the account falls, all downstream trust falls with it. Opensource maintainers are only human, and often tired and overworked humans. That makes them a soft target, and the attack surface is huge: npm has millions of maintainers, many of whom have access to publish widely used packages.

Install-time execution is an attacker’s dream

npm lifecycle scripts like preinstall, install, and postinstall run during installation. That means malicious code can execute before you import anything, before your tests run, and before you’ve even finished reading the package name. Shai-Hulud took advantage of exactly that kind of install-time execution.

And the malicious behavior doesn’t need to look dramatic. A lifecycle script, a conditional payload, a quietly spawned subprocess, a snippet that only activates when certain environment variables exist — all of that can slip past casual review.

AI has widened the blast radius

Depiction of an ai robot pointing at some code in an editor

AI coding assistants are genuinely useful. I use them. You probably do too. They’re great at boilerplate, they explain unfamiliar code faster than docs, and they often catch the kind of obvious mistakes that cost real time.

But they also create a new category of trust problem: code you didn’t write, don’t fully understand, and may run too quickly.

In February 2025, Truffle Security reported that a scan of the December 2024 Common Crawl archive found roughly 12,000 live API keys and passwords embedded in public web data — the same kind of data that can end up in model training pipelines.

In May 2025, KrebsOnSecurity reported that an xAI employee exposed a private API key on GitHub that could access private or fine-tuned models tied to internal data from companies including SpaceX and Tesla. In November 2025, Wiz reported that 65% of companies it analyzed from the Forbes AI 50 had verified secret leaks on GitHub.

Even the companies and systems building AI tools suffer from the same secret management failures as everyone else!

The second problem is more mundane and, in practice, more common. AI-generated code fails in ordinary ways. A cleanup script deletes the wrong directory. A migration points at the wrong database. A one-line “helper” turns into a subtle security bug. These aren’t exciting science-fiction failures. They’re the normal failures of code produced someone that has no real understanding of your environment (which is what an AI assistant is).

And once that code runs with your local permissions, a mistake isn’t just a bug. It can be data loss, credential exposure, or an accidental production incident.

We should also talk about prompt injection.

In August 2025, researcher Johann Rehberger showed that Claude Code could be tricked by an indirect prompt injection into reading sensitive local files and leaking their contents through DNS requests. Around the same time, GitHub detailed prompt-injection risks in VS Code and Copilot agent mode, including the possibility of leaking tokens, exposing confidential files, or even executing code without the user’s explicit approval.

The attack surface isn’t just the packages you install anymore. It’s also the code, issues, pull requests, docs, and comments your assistant is asked to look at.

Model poisoning is also a real, though still somewhat experimental, threat. Researchers have shown that injecting as few as 250 malicious documents into pre-training data can successfully backdoor an LLM with 90% attack success rates. You wouldn't know the model was compromised. It would behave normally except when specific conditions were met.


Defence-in-depth

This is the part where it’s worth being concrete, because there are real defences. They just require a different default mindset.

Least-privilege execution matters.

Deno’s permission model is a meaningful improvement here. By default, a Deno program cannot read environment variables, touch the filesystem, or make outbound network requests unless you allow it.

So when a malicious payload tries to read HOME or scrape your environment, the runtime can stop it cold:

┏ ⚠️  Deno requests env access.
┠─ To see a stack trace for this prompt, set the DENO_TRACE_PERMISSIONS environmental variable.
┠─ Learn more at: https://docs.deno.com/go/--allow-env
┠─ Run again with --allow-env to bypass this prompt.
┗ Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all env permissions) >
Enter fullscreen mode Exit fullscreen mode

screenshot of the deno security protocols warning about sensitive API use

But this is old news, we all already know about Deno's permissions model, and it isn’t a complete solution. If code legitimately needs network or environment access, you still have to grant it. But it does shrink the default blast radius in a way Node.js traditionally has not.

Treat AI-generated code as untrusted code.

That’s the mindset shift that matters most. Running model-generated code with the same implicit trust you give your own code feels normal, but it isn’t. You’re executing output from a system that may have learned from insecure examples, can misunderstand your environment, and can be steered by untrusted content it was asked to analyse. It doesn't have your best interests at heart, not because it is malicious by nature, but because it just doesn't know.

For execution you don’t control, use real isolation.

The gold standard is running untrusted code somewhere with no ambient access to your host filesystem, host secrets, or unrestricted network.

Docker can do this, but there’s real workflow friction there: building images, managing lifecycle, mounting the right files, and keeping the whole setup ergonomic enough that you’ll actually use it.


A solution - isolated dev environments

A Deno dinosaur playing in a sandpit

One emerging option is Deno Deploy Sandbox, which provides programmatic access to Firecracker-based Linux microVMs. These aren’t just containers with nicer branding. Each sandbox gets its own filesystem, network stack, and process tree, and the platform advertises startup times under 200 milliseconds — fast enough to use inside an agent loop.

Here’s what that looks like in practice: ask a model to write some code, run it in a sandbox, and make sure your secrets can be used only where you intend.

Below is an example script that asks Claude to write a Deno script that fetches the current Bitcoin price from the CoinGecko API, then runs it in a sandbox with no permissions except outbound network access to api.coingecko.com. Even if the generated code is malicious, it can only talk to that one host, and it can’t access any secrets or your local filesystem:

// Step 1: Import the Anthropic SDK and the Deno Sandbox SDK.
// The Anthropic SDK lets us talk to Claude, and @deno/sandbox gives us
// a secure microVM to run untrusted code in.
import Anthropic from "npm:@anthropic-ai/sdk";
import { Sandbox } from "jsr:@deno/sandbox";

// Step 2: Create an Anthropic client.
// It automatically picks up ANTHROPIC_API_KEY from the environment.
const client = new Anthropic();

// Step 3: Ask Claude to write some code for us.
// We're asking for a complete, runnable Deno script — Claude will return
// it wrapped in a markdown code block.
const response = await client.messages.create({
  model: "claude-opus-4-6",
  max_tokens: 1024,
  messages: [
    {
      role: "user",
      content: "Write a Deno script that fetches the current Bitcoin price from the CoinGecko API and prints it."
    }
  ]
});

// Step 4: Pull the generated code out of Claude's response.
// The response is an array of content blocks — we check it's a text block,
// then strip the markdown fences to get the raw source.
const firstBlock = response.content[0];
if (firstBlock.type !== "text") {
  throw new Error(`Unexpected content type: ${firstBlock.type}`);
}
const generatedCode = extractCode(firstBlock.text);
//          console.log("Generated code:\n", generatedCode);

// Step 5: Create a sandbox — a fully isolated Linux microVM.
// "await using" means it's automatically destroyed when this scope ends,
// so we never leak resources even if something throws.
await using sandbox = await Sandbox.create();

// Step 6: Write the AI-generated code into the sandbox filesystem.
// The sandbox has its own isolated filesystem — nothing here touches the host.
await sandbox.fs.writeTextFile("/tmp/generated.ts", generatedCode);

// Step 7: Run the code inside the sandbox with Deno.
// We only grant network access to the one host the code actually needs.
// stdout and stderr are piped so we can capture and display them.
const child = await sandbox.spawn("deno", {
  args: [
    "run",
    "--allow-net=api.coingecko.com", // Only the specific host we expect
    "/tmp/generated.ts"
  ],
  stdout: "piped",
  stderr: "piped"
});

// Step 8: Set a hard timeout.
// AI-generated code could accidentally (or maliciously) loop forever —
// this ensures we kill the process after 10 seconds no matter what.
const timeout = setTimeout(() => child.kill(), 10_000);

// Step 9: Wait for the process to finish and print the results.
// output.stdoutText / stderrText are pre-decoded UTF-8 strings.
// output.status.success is true only if the exit code was 0.
try {
  const output = await child.output();
  console.log("Output:\n", output.stdoutText ?? "No output");
  if (!output.status.success) {
    console.error("Error:\n", output.stderrText ?? "No error output");
  }
} finally {
  clearTimeout(timeout);
}

// Helper: extract the first code block from a markdown string.
// Falls back to returning the raw text if no fences are found.
function extractCode(text: string): string {
  const match = text.match(/```
{% endraw %}
(?:typescript|ts|javascript|js)?\n([\s\S]*?)
{% raw %}
```/);
  return match ? match[1] : text;
}
Enter fullscreen mode Exit fullscreen mode

Notice what's happening here: if you run this script, you will get some AI gen code, which you have not read and not checked, but it can only reach api.coingecko.com. It is running on an entirely separate machine from your own. The generated code can't read your .env file. It can't write to your own filesystem. It can't call home to anywhere unexpected. And if it runs longer than 10 seconds, it gets killed.

The point is the layering.

The microVM keeps generated code away from your host OS. The --allow-net flag limits outbound traffic to exactly the host the script needs. You're giving yourself layers of safety.

And Deno sandboxes offer one more layer that’s worth mentioning, that we don't use in this example: they have a built-in secret management system. You can create secrets in the sandbox configuration with a secrets field. The value of the secret is replaced by a placeholder string inside the sandbox environment. The real values are only injected at the network layer when the code tries to send them to the specific hosts you allow. So even if the generated code tries to exfiltrate secrets, it can only send the placeholder values, which are useless to an attacker!

Imagine the following:

await using sandbox = await Sandbox.create({
  secrets: {
    ANTHROPIC_API_KEY: {
      value: Deno.env.get("ANTHROPIC_API_KEY") ?? "",
      hosts: ["api.anthropic.com"] // Only allow this secret to be sent to the Anthropic API
    }
  }
});

await sandbox.sh`echo $ANTHROPIC_API_KEY`;
Enter fullscreen mode Exit fullscreen mode

The output key will look like: DENO_SECRET_PLACEHOLDER_55eb59627bc5700ae19f371791e0f54ad3204b745afcdc60acd66c23. Your key is nice and safe.

And yes, these are somewhat contrived examples, but I wanted something to show you quickly how easy it is to spin up and run code in a Deno Sandbox.

Since these sandboxes are full linux vms, you could of course install Claude directly on the sandbox and use that to generate code, rather than generating it on your local machine. Sandboxes allow for snapshotting of a vm image, so you could, absolutely, install Claude and any other tools or software you might want, snapshot the VM and then spin multiple versions of that up in 200ms allowing really fast execution times for your Claude prompts and code.


What actually needs to change

The tools are getting better. Our habits need to catch up.

We built a lot of modern JavaScript on the assumption that convenience deserved the benefit of the doubt. In the early days of npm, that mostly worked. The ecosystem was smaller, the incentives were different, and the threat model was simpler.

That isn’t the world we operate in now.

Supply-chain attacks are organized, repeatable, and economically attractive. AI assistants are generating code that runs with your permissions but without your oversight. Prompt injection has moved from theory to working demonstrations. And when something malicious does land, it usually goes after the credentials that unlock everything else.

The answer is not to stop using npm or to stop using AI assistants. Both are too useful, and neither is going away.

The answer is to stop giving code implicit trust just because it arrived through a convenient path.

Run code with the minimum permissions it needs. Treat generated code as untrusted until it earns more trust. For anything you don’t fully control, always execute it in isolation.

npm install && pray used to be a joke about dependency sprawl. It now reads more like a description of the default security model.

That model needs to change. Stay safe out there!

Top comments (0)