Last updated on

Implementing Aikido: malware detection & Safe Chain install-time blocking


Part 3 of 4 in the series Defending Against npm Supply-Chain Attacks. Full series ↓

In Part 2 I audited this site for the Shai-Hulud worm, came up clean, and wrote down a defense-in-depth plan. One row of that plan was "detect known-malware and compromised versions," and I handed it to a tool called Aikido. This is the part where I stop planning and install it.

Two things share the Aikido name, and it's worth separating them up front:

  • Aikido (the platform) is a hosted scanner you connect to your repo. It does software-composition analysis, malware detection, secrets, and SAST, and it can gate pull requests. This is the part with an account, a dashboard, and, per Aikido's own writeup of this campaign, a 100/100 critical malware finding on the compromised packages.
  • Aikido Safe Chain is a free, open-source CLI that needs no token. It wraps your package manager and blocks malicious packages at install time, before a preinstall script can run. No account required.

The platform tells you what's already in your tree. Safe Chain stops the bad thing from getting into it. I want both, so this post sets up both.

Why Aikido owns the malware layer

Part 1 walked through how the worm worked: a malicious preinstall on a poisoned version downloads Bun and runs a credential stealer, all during a routine pnpm install. The defense that matters most for that specific attack looks at packages (is this exact name and version known to be bad?) and acts before the install finishes.

That's dependency and malware scanning, and it's Aikido's strongest lane here. When the campaign broke, Aikido flagged it as a 100/100 critical malware finding on the affected packages. So for the "detect known-malware and compromised versions" row of the Part 2 table, Aikido is the owning tool. Semgrep, in Part 4, will own the code-and-config lane: reachability, secrets, and the worm's persistence edits. Different jobs.

Connecting the platform and running the first scan

This step needs an account and an OAuth grant to GitHub, so it's the one part of this post you do through a browser rather than a terminal.

  1. Sign up at aikido.dev and choose "Sign in with GitHub."
  2. Authorize the Aikido GitHub App and scope it to a single repository, dfadler/dfadler.com, rather than the whole account. Least privilege applies to scanners too.
  3. Aikido starts a first scan on its own once the repo is connected. For a repo this size it finishes in a couple of minutes.

Here's the first scan of this repo:

Aikido Dashboard

And a real finding, opened up:

Dashboard Detail


A note on what "connect the repo" actually grants: the GitHub App gets read access to code and metadata so it can scan your lockfile and source. That's a real trust decision. You're handing a third party a read view of a private repo. I scoped it to one repository, and I'd revoke the connection if I ever stopped using it. It's the same calculus as any CI integration.

Aikido Safe Chain: blocking the install before it runs

Now the part I like, because it's the direct counter to a malicious preinstall. Safe Chain runs a small local proxy. Every package your package manager tries to download is checked against Aikido Intel's malware feed first, and a match is refused before the tarball lands. Detect, don't execute.

Install is a versioned URL with a pinned checksum, the same shape I use for every other tool in this repo's CI:

curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/1.5.15/install-safe-chain.sh -o /tmp/install-safe-chain.sh \
  && echo "de0565e3d6346407a604e84e639e95fea8758748063da2216bbfdca5feda5dd2  /tmp/install-safe-chain.sh" | sha256sum -c - \
  && sh /tmp/install-safe-chain.sh \
  && rm /tmp/install-safe-chain.sh

The installer verifies the download, drops a binary in ~/.safe-chain/bin, and sets up shell aliases that wrap npm, npx, pnpm, yarn, bun, and the Python managers too:

[INFO] Installing safe-chain 1.5.15
[INFO] Detected platform: macos-arm64
[INFO] Checksum verified.
[INFO] Binary installed to: /Users/dfadler/.safe-chain/bin/safe-chain
Setting up shell aliases. This will wrap safe-chain around npm, npx, yarn, pnpm, ...
Detected 2 supported shell(s): Zsh, Bash.
- Zsh: Setup successful
- Bash: Setup successful
Please restart your terminal to apply the changes.

Restart the terminal, then confirm the wrapper is live:

pnpm safe-chain-verify
# OK: Safe-chain works!

Proving it on the real thing

Aikido ships a harmless test package, safe-chain-test, that its feed always flags. Installing it is the smoke test:

$ pnpm add safe-chain-test
✖ Safe-chain: Malicious changes detected:
 - safe-chain-test@0.0.1-security

Safe-chain: Exiting without installing malicious packages.

Exit code 1, nothing written to node_modules. Good, but a synthetic test package is a soft target. I wanted to watch it stop the actual thing from Part 1, so I asked it to install keyv@6.0.0, one of the exact poisoned versions:

$ pnpm add keyv@6.0.0 --safe-chain-logging=verbose
Safe-chain: Package keyv@6.0.0 is marked as malware: MALWARE
✖ Safe-chain: Malicious changes detected:
 - keyv@6.0.0

Safe-chain: Exiting without installing malicious packages.

This one is worth sitting with. npm has since unpublished keyv@6.0.0, and its latest keyv is back to 5.6.0, so if you try that install without Safe Chain, npm just shrugs:

$ pnpm add keyv@6.0.0        # no safe-chain
npm error code ETARGET
npm error notarget No matching version found for keyv@6.0.0.

Those are two very different failures. Plain npm fails because the version happens not to exist anymore, an accident of cleanup, not a security decision. If the attacker had picked a version npm hadn't gotten around to pulling, that install would have gone through. Safe Chain fails with a verdict: this name and version is malware, per the intel feed, whether or not the registry still serves it. That's the difference between lucky and defended.

There's a second layer here that's easy to miss. Safe Chain also enforces a minimum package age: by default it suppresses versions younger than 48 hours. Shai-Hulud spread through freshly published poisoned versions, and the 48-hour hold is aimed straight at that window, when a malicious release is live but not yet caught. It's a blunt instrument, and I'll come back to its cost below, but it's the right blunt instrument for this threat.

Gating CI on it

A laptop is one install surface. CI is the other, and arguably the scarier one, because a runner holds tokens and works unattended. Part 2's success criteria included "CI fails when a known-malicious dependency is present," so the same Safe Chain goes into GitHub Actions.

The --ci flag installs shims onto PATH (via $GITHUB_PATH) instead of shell aliases, so the wrapped pnpm is the one every later step sees. Here's the workflow I added, .github/workflows/supply-chain.yml:

name: Supply chain

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

env:
  # Pinned release + checksum, an immutable GitHub asset. Bump both to upgrade.
  SAFE_CHAIN_VERSION: 1.5.15
  SAFE_CHAIN_SHA256: de0565e3d6346407a604e84e639e95fea8758748063da2216bbfdca5feda5dd2

jobs:
  malware-scan:
    name: Safe Chain (pnpm install)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - name: Install pnpm
        uses: pnpm/action-setup@v6

      - name: Install Node.js
        uses: actions/setup-node@v7
        with:
          node-version-file: package.json
          # No pnpm cache on purpose: this job exists to route every download
          # through Safe Chain's proxy, and a warm store would let cached
          # tarballs skip the scan.

      - name: Install Aikido Safe Chain
        run: |
          curl -fsSL "https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh" -o /tmp/install-safe-chain.sh
          echo "${SAFE_CHAIN_SHA256}  /tmp/install-safe-chain.sh" | sha256sum -c -
          sh /tmp/install-safe-chain.sh --ci
          rm /tmp/install-safe-chain.sh

      - name: Install dependencies (malware-scanned)
        run: pnpm install --frozen-lockfile

To prove the check does something, I planted safe-chain-test in a branch's package.json and lockfile, a stand-in for a PR that adds a bad dependency, and let it run. On a clean tree the install is boring and green:

✓ Lockfile passes supply-chain policies
+ is-odd 3.0.1
Done
>>> exit code: 0

With the planted package, the frozen-lockfile install hits Safe Chain's proxy on the malicious tarball and the job goes red:

[ERR_PNPM_FETCH_403] GET https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz: Forbidden - blocked by safe-chain - 403

Safe-chain: blocked 1 malicious package downloads:
 - safe-chain-test@0.0.1-security

Safe-chain: Exiting without installing malicious packages.
>>> exit code: 1

A 403 on the download, a non-zero exit, a failing check. All of it before the package is unpacked, and long before any lifecycle script runs. Pull the planted dependency and the check goes green again. That's the failing-then-passing gate Part 2 asked for.

[SCREENSHOT (optional): the GitHub Actions "Supply chain" check red on the planted branch, then green after removing the dependency.]

The platform can gate PRs too, through its own GitHub check, and if you're already on the platform you may prefer that as the single gate. I like Safe Chain in CI because it's the same tool I run locally, it needs no token, and it fails at the download, which is the exact moment I want the pipeline to stop.

Device Protection: worth it for a solo maintainer?

Aikido sells a Device Protection tier that builds on Safe Chain. It extends the same malware feed to IDE extensions (VS Code, Cursor, the Open VSX crowd), Chrome extensions, and more ecosystems, plus centralized policy and approval workflows across a fleet of machines.

For an org with many developer laptops, that centralization is the pitch. For a one-person site it mostly isn't: I am the fleet, the free Safe Chain already covers the npm and pnpm surface this attack used, and I don't need a workflow to approve things for myself. So I'm noting it exists and leaving it out of scope. If this were a team repo I'd look harder at the extension-scanning piece specifically, since the worm's cousins target editor extensions too.

Honest notes: cost, noise, false positives

  • Cost. Safe Chain is free and open source, no token, and no build data leaves the machine. The platform has a free tier that's fine for a single small repo. The paid tiers and Device Protection are where money enters, and I don't need them here.
  • The 48-hour age gate is real friction. Holding back packages younger than two days is great against fresh malware and annoying the day you want a just-published fix. In CI it can bite right after a dependency bump: if the lockfile now pins a version less than two days old, the scan can block it until the window passes. You can lower the threshold, exclude specific scopes, or bypass it for one install, but every loosening is a small hole, so I'd rather feel the friction and open holes deliberately.
  • False positives happen. Any malware feed will occasionally flag something you believe is fine, and a blocked install is a hard stop, not a warning. The escape hatches exist, but the honest tradeoff is that a tool which fails closed will sometimes fail closed on you. For a supply-chain gate I'll take that over failing open.
  • Trust surface. The platform reads your repo. Safe Chain routes your installs through a local proxy with its own CA certificate. Both are reasonable, and both are worth understanding rather than clicking past.

Where this leaves the plan

Two rows of the Part 2 table are now real: known-malware detection through the Aikido platform, and install-time blocking through Safe Chain, on my laptop and in CI. A routine pnpm install on this project now has to clear a malware check before it can execute anything, which is the sentence Part 1 ended on, turned around.

Part 4 takes on the code-and-config lane with Semgrep: reachability-aware dependency rules, secret detection, and custom rules for the worm's persistence trick (the edits to .claude/settings.json and .vscode/tasks.json that let it spread). Malware scanning catches the package. Semgrep catches the pattern.

Comments

No comments yet. Be the first to comment.

Leave a comment