How TypeScript devs can avoid getting pwned by malicious packages | built by stef<br>Third-party dependencies have always been an attack vector for malicious actors.
But coding agents have made the problem much, much worse. Between the volume of code being generated and the agents’ abilities to find vulnerabilities, it has gotten much easier to put malicious code inside of npm packages.
Last November, the Shai-Hulud 2.0 attack showed just how quickly a compromised package could affect the entire npm ecosystem. The attack used a self-replicating npm worm that backdoored 796 packages totaling more than 20 million weekly downloads. It ran malicious code through package lifecycle scripts, allowing it to spread across developer machines and CI/CD pipelines as soon as someone installed an affected package.1
And 2026 hasn’t been much better. A related attack, called Mini Shai-Hulud, harvested tokens and credentials from CI/CD runners and developer machines. By abusing legitimate publishing infrastructure, attackers were able to push compromised package versions through trusted release paths.
It first impacted the SAP developer ecosystem in late April.2 Then on May 11, it compromised the TanStack release pipeline and published 84 malicious versions across 42 @tanstack/* packages.3
Three config changes that do the heavy lifting
The following three controls address most of the common npm supply-chain attack paths with relatively little setup.
Use a release-age cooldown
A package version’s release age is the amount of time that has passed since it was published. A release-age policy tells your package manager to install only versions that have been available for a minimum amount of time.
This is a very effective security measure because malicious versions are often discovered quickly. Security researchers and automated scanners identify them, registries remove them, and maintainers publish clean replacements, usually in a matter of hours or days. By avoiding brand-new releases, you give that process time to happen before a poisoned version reaches your machine or CI pipeline.
How long you should wait depends on your risk tolerance. Seven days is a reasonable baseline. For more sensitive projects, consider increasing the window to 14 days.
Here is how to configure it in each package manager:
pnpm: Set minimumReleaseAge in pnpm-workspace.yaml. The value is measured in minutes, so 10080 represents seven days. In pnpm v11, the default is 1440, or 24 hours. Use minimumReleaseAgeExclude to exempt specific packages from the requirement.
npm: Set min-release-age in .npmrc. The value is measured in days, so 7 represents one week. It is disabled by default. Use min-release-age-exclude to exempt specific packages or package-name patterns.
Bun: Set minimumReleaseAge under [install] in bunfig.toml. The value is measured in seconds, so 604800 represents seven days. The exclusion setting is the plural minimumReleaseAgeExcludes.
Block build and lifecycle scripts
npm packages can declare lifecycle scripts, such as preinstall and postinstall, that run automatically during installation.
These scripts are dangerous because developers generally don’t review them before installing a package. They can execute arbitrary code as soon as installation begins, potentially reading environment variables, SSH keys, npm tokens, cloud credentials, and other secrets before you’ve run any of your own code.
Lifecycle scripts have been one of the most common execution mechanisms in recent npm supply-chain attacks. The Shai-Hulud campaigns used installation-time execution to compromise machines, steal credentials, and spread to additional packages.
The safest approach is to deny dependency install scripts by default and explicitly permit only the packages that genuinely need them.
pnpm: Dependency build scripts are blocked unless explicitly permitted. In pnpm v11, configure approved and denied packages using the allowBuilds map in pnpm-workspace.yaml. Avoid globally enabling all build scripts unless you fully trust every dependency in the project.
npm: For a blanket block, set ignore-scripts=true in .npmrc. It defaults to false, meaning npm normally runs dependency lifecycle scripts during installation. Current npm 11 releases also support package-level decisions through the allowScripts policy and the npm approve-scripts and npm deny-scripts commands.
Bun: Bun blocks dependency lifecycle scripts by default and includes a built-in allowlist for some commonly used packages. Add additional packages through trustedDependencies in package.json. One thing to watch out for, setting trustedDependencies in your config replaces Bun’s built-in list rather than extending it, so you must re-add any packages from that list that you still want to trust.
Not every install script can be blocked, and that’s fine. Packages such as sharp, esbuild, Prisma, and Playwright may require installation-time setup.
The goal is to understand which packages...