Supply Chain Security

Install Scripts Are the New Initial Access: A 500-Package npm and NuGet Wave, and the SMB Lockdown Playbook

Dark cyberpunk illustration of a night shipping port where one cargo container on the conveyor has cracked open, bleeding orange light onto cyan data cables, symbolizing a poisoned software package moving down an install pipeline.

Between May 27 and May 29, 2026, the open-source package registries took a coordinated beating. Microsoft's threat researchers documented a single actor publishing 14 malicious npm packages inside a four-hour window on May 28, a cluster that grew to 33 within a day. SafeDep tracked a separate operation that pushed 164 packages across five namespaces, later expanding to 179. Socket caught a malicious NuGet package impersonating a Brazilian bank's SDK that had been quietly stealing signing certificates since May 5. Add the dependency-confusion and adware campaigns Sonatype reported in the same window, and the last week of May produced well over 500 malicious packages across npm and NuGet. Almost all of them shared one mechanism.

That mechanism is the install script. Not a clever zero-day, not a phished maintainer password - just the preinstall and postinstall lifecycle hooks that npm runs automatically, with the developer's full shell privileges, the moment a package lands in node_modules. CISA made the trend official the same week. On May 27 it added three "embedded malicious code" entries to the Known Exploited Vulnerabilities catalog: Nx Console (CVE-2026-48027), Daemon Tools Lite (CVE-2026-8398), and TanStack (CVE-2026-45321). Poisoned packages are no longer a research curiosity. They are exploited-in-the-wild initial access, sitting on the same federal patch list as edge-VPN bugs.

If your company ships any software, runs a CI pipeline, or lets a contractor run npm install on a laptop that can reach your cloud, this is your problem this week and not a big-tech problem. The credential a poisoned package scrapes off a twelve-person dev shop unlocks the same AWS account, the same GitHub org, and the same production database it would at a Fortune 500. The blast radius does not scale down with headcount.

What landed in one week

The Microsoft cluster is the cleanest example of the modern pattern. Writing on its security blog, the team described packages that typosquatted OpenSearch, ElasticSearch, and common DevOps libraries, several of them spoofing the real OpenSearch repository URL in package.json so they read as legitimate on the registry page. Every package in the cluster shipped the same install-time stager and the same roughly 195 KB second-stage payload, compiled with Bun, purpose-built to harvest cloud and CI/CD secrets. It reads VAULT_TOKEN and VAULT_AUTH_TOKEN for HashiCorp Vault, validates any stolen npm token against the registry's /-/whoami endpoint and enumerates its publish scope through /-/npm/v1/tokens, and collects GITHUB_REPOSITORY and RUNNER_OS to fingerprint build runners. The point of stealing an npm publish token is not the token. It is the next hundred packages the attacker can poison with it. Microsoft's follow-up a day later tracked the cluster to 33 packages and confirmed the dependency-confusion angle.

SafeDep's campaign was blunter and far bigger. Every one of the 179 packages carried version 99.99.99, a dependency-confusion trick that forces any resolver configured to take "the latest version" to pull the malicious public package over a legitimate internal one of the same name. The postinstall hook downloaded a second stage, spawned it as a detached process so it outlived the install, and shipped the victim's entire process.env to oob.moika[.]tech/report. Every variant authenticated to that server with the same hardcoded X-Secret header value, which is how SafeDep tied three separate maintainer accounts to a single operator. Full environment exfiltration on install means every API key, registry token, and cloud secret loaded into that shell is gone the instant the package unpacks.

Socket's NuGet find shows the identical logic on the .NET side. A package called Sicoob.Sdk, versions 2.0.0 through 2.0.4, posed as the SDK for a large Brazilian banking cooperative. When the client was instantiated in production mode, it read the caller's PFX certificate from disk, base64-encoded it, and exfiltrated it - along with client IDs, PFX passwords, and raw payment API responses - by abusing a hardcoded Sentry error-reporting endpoint as a covert channel. Socket counted only 484 downloads across six versions, plus eleven sibling packages under the same owner. Small numbers, but each install was a payments integration handing over its signing certificate.

Why install scripts are the soft underbelly

npm, by default, runs a package's preinstall, install, and postinstall scripts during npm install. That behavior exists for a good reason - native modules need to compile, binaries need to be fetched - but it means installing a dependency is functionally the same as running a stranger's shell script as your user. There is no sandbox, no prompt, no review step. The script inherits your environment variables, your cloud metadata endpoint, your SSH agent, and your registry tokens.

That is why every campaign above converged on the same primitive. An attacker does not need a memory-corruption bug or a maintainer's 2FA code. They need you, or your CI runner, to type npm install once. The dependency-confusion and typosquatting tricks are just delivery: 99.99.99 version pins win resolution, a one-character typo or a plausible prefix wins a tired developer's trust, and the install hook does the rest. The same applies to NuGet and PyPI build steps, and increasingly to IDE extensions, which is exactly why Nx Console landed in CISA's KEV catalog as embedded malicious code rather than as a traditional CVE.

Why this is an SMB story this week

Two reasons, and both cut against the smaller organization. First, SMBs and the MSPs that serve them run the leanest pipelines. The default GitHub Actions token in most small repos still has read-write on every scope, secrets are injected as plain environment variables, and nobody has turned install scripts off because nobody knew they were on. The controls that would contain a poisoned package are precisely the ones a ten-person shop has not gotten to yet.

Second, the credentials are interchangeable. A stolen AWS access key, GitHub Actions OIDC token, or Vault token grants whatever that identity was scoped to, and small-company identities are routinely over-scoped because tightening them was never urgent. When a single postinstall can read the whole environment, the gap between "we got popped on a dev laptop" and "the attacker is in production" is one over-privileged token. For a government contractor under CMMC, that same event is also a reportable incident.

The SMB lockdown playbook

You cannot audit every transitive dependency by hand, and you do not need to. The goal is to break the install-script primitive these campaigns depend on, shrink what a leaked token is worth, and put one detection in place for the install-time callout. Four steps, in priority order.

1. Turn install scripts off by default

This is the single highest-leverage control. Disable lifecycle scripts globally and re-enable them only for the handful of packages that genuinely need to compile. In CI, never let an install run arbitrary scripts.

# Project root .npmrc - commit this so every dev and the CI runner inherits it
ignore-scripts=true

# In the pipeline, install without ever executing lifecycle hooks:
npm ci --ignore-scripts

# Find which of your current dependencies actually declare install scripts,
# so you can allowlist only those (most legitimate projects need zero):
npm query ":attr(scripts, [preinstall]), :attr(scripts, [install]), :attr(scripts, [postinstall])" \
  | jq -r '.[].name' | sort -u

If a build genuinely needs a native module compiled, run that one package's install in an isolated, network-restricted step rather than flipping scripts back on for the whole tree.

2. Pin versions and shut the dependency-confusion door

The 99.99.99 trick only works when your resolver is allowed to reach the public registry for a name you also use internally. Bind every internal scope to your private registry and refuse to fall back to npmjs.org for it.

# .npmrc - force your org scope to resolve only from your private registry
@your-org:registry=https://registry.your-org.internal/
//registry.your-org.internal/:_authToken=${NPM_INTERNAL_TOKEN}

# Commit lockfiles, and in CI fail the build if the lockfile would change.
# npm ci already refuses to run if package-lock.json is out of sync.
npm ci --ignore-scripts

# Reject implausible version jumps in review - 99.99.99 is the tell:
grep -rE '"(version|[^"]+)":\s*"99\.[0-9]+\.[0-9]+"' package-lock.json pnpm-lock.yaml 2>/dev/null

3. Scope and short-circuit your CI secrets

Assume an install hook will eventually run in your pipeline. The mitigation is that there is nothing valuable in the environment when it does. Set the workflow's default token to read-only, grant write only on the job that needs it, and prefer short-lived OIDC federation over long-lived cloud keys stored as secrets.

# .github/workflows/ci.yml - least privilege by default
permissions:
  contents: read          # the whole workflow is read-only unless a job opts up

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts   # no lifecycle scripts in build

  publish:
    needs: build
    permissions:
      contents: read
      id-token: write       # OIDC only, scoped to the publish job
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Federate to a short-lived cloud role here instead of a static AWS key.

Pin third-party actions to a full commit SHA rather than a moving tag, and rotate any npm publish token that has ever sat in a developer's shell environment. A token that cannot be enumerated through /-/npm/v1/tokens because it is short-lived is a token the Bun harvester cannot monetize.

4. Hunt the install-time callout

Every campaign in this wave made an outbound connection at install. That is your detection. Watch for network egress originating from npm, node, or a build step to anything that is not your registry, and sweep your lockfiles and hosts for this wave's indicators.

# Lockfile and host sweep for the late-May 2026 wave
grep -rE 'oob\.moika\.tech|99\.99\.99' . \
  --include=package-lock.json --include=pnpm-lock.yaml --include=yarn.lock

# SafeDep dropper artifact left in the OS temp dir:
find "${TMPDIR:-/tmp}" -name '*cloudplatform-single-spa*init.js' 2>/dev/null

# On a CI runner, alert on any process spawned by npm/node that opens a
# socket to a non-registry host during the install phase. With auditd:
auditctl -a always,exit -F arch=b64 -S connect -F comm=node -k npm_install_egress

# Then triage the key in your SIEM:
#   index=linux key=npm_install_egress NOT dest_host IN (registry.npmjs.org, your-registry)

The terminology debate in the reporting - whether to call this typosquatting, brandjacking, or "manufactured legitimacy" - does not change the response. The named packages will be gone from the registry by the time you read their writeups. What persists is the primitive: an install command that runs a stranger's code with your credentials in scope. The week's reporting reads like five different attacks, but it is one technique with five paint jobs.

Do the first step today. Set ignore-scripts=true in a root .npmrc, switch CI to npm ci --ignore-scripts, and rotate any publish token that has lived in a shell. That single change neutralizes the mechanism behind every campaign above, and it costs you one commit. The next 500 packages are already being uploaded.

Is your build pipeline one npm install away from a breach?

We assess software supply chain and CI/CD security for SMBs and government contractors: install-script controls, dependency-confusion exposure, CI token scoping, and the detection to catch the next poisoned package before it reaches production. Book a session and we will walk your pipeline with you.