../field-guide

field guide #supply-chain#npm#pnpm#socket#ci-cd#github-actions#ai#mcp updated Oct 20, 2026

Supply chain attacks: guard the loading dock

You didn't write most of the code you ship, and neither did your agent. How supply chain attacks work, why AI coding makes them worse, and how to layer Socket.dev and pnpm so a poisoned package never reaches your app.

Bandit the masked raccoon in a hard hat and work vest, slipping something into an open box on a warehouse conveyor belt between sealed, taped-up packages
Bandit took a warehouse job. Every box still arrives sealed. One of them just has something extra inside.

Bandit doesn’t pick your lock anymore. He gets a job at the warehouse that supplies you, slips one extra box onto the pallet, and lets you sign for it. You carry it inside yourself.

That’s a supply chain attack. Your app’s package.json lists a few dozen names, and your lockfile resolves them into hundreds or thousands of packages, each published by someone you’ve never met. Every one of them can run code on your laptop, your CI runner and your production server. This guide covers how these attacks actually land, why AI coding agents make them more likely, and how to set up Socket.dev and pnpm in layers, using the config from a production Shopify app we run.

How a supply chain attack works#

Attackers don’t need to break into your app if they can break into something your app trusts. The common routes into a dependency tree:

RouteWhat happensReal example
Maintainer account takeoverAttacker steals a maintainer’s npm login and publishes a poisoned version of a popular packageaxios, March 2026
Stolen CI publishing tokenA misconfigured workflow leaks the token that publishes the packageNx (“s1ngularity”), August 2025
WormMalware steals the victim’s npm token and republishes their packages with itself insideShai-Hulud, September 2025
TyposquatA lookalike name (plain-crypto-js for crypto-js) waits for a typo, or gets added as a dependencyaxios, again
SlopsquatAn attacker registers a package name that AI models keep hallucinatingSee below
ProtestwareA maintainer ships unwanted behavior on purposees5-ext

The payload almost always runs at install time, through a postinstall script. You don’t have to import the package, call it, or deploy it. npm install is enough.

The axios attack shows the whole pattern. On March 31, 2026, an attacker took over the lead maintainer’s npm account and published [email protected] and [email protected]. The only change was one new dependency: [email protected], a typosquat of crypto-js that axios never imports. Its postinstall script dropped a cross-platform remote access trojan, then deleted itself and rewrote its own package.json to hide. The bad versions were live for about three hours.

flowchart LR
    A[Attacker takes over<br/>maintainer's npm account] --> B[Publishes [email protected]<br/>typosquat with a postinstall script]
    B --> C[Publishes [email protected]<br/>adds plain-crypto-js as a dependency]
    C --> D[You run npm install<br/>caret range resolves to 1.14.1]
    D --> E[postinstall runs:<br/>RAT installed, evidence deleted]:::bad
    E --> F[Your tokens, keys and .env<br/>belong to someone else]:::bad
    classDef bad fill:#1a0f0f,stroke:#FF5F56,color:#FF5F56;

Notice what a vulnerability scanner sees here: nothing. There’s no CVE for a brand-new malicious release. npm audit and Dependabot alerts are built for known vulnerabilities in honest code. A supply chain attack is dishonest code with no known vulnerabilities. You need a tool that looks at what the package actually does.

Why AI makes it worse#

AI coding agents didn’t invent supply chain attacks, but they help them along in four ways.

Agents install things. Ask an agent to “add phone number parsing” and it’ll run pnpm add for you, often without stopping to ask. Nobody sees a name and thinks “huh, that’s not the package I meant.”

Agents hallucinate package names, and attackers register them. A USENIX Security 2025 study by Spracklen et al. generated 576,000 code samples across 16 models. About 20% of the packages they recommended didn’t exist, and 43% of those fake names came back on every repeated run of the same prompt. A name that a model hallucinates every time is a name an attacker can register and wait on. That’s slopsquatting. When a researcher registered one commonly hallucinated name, huggingface-cli, as an empty package on PyPI, it got over 30,000 downloads in three months.

Agents are now the payload’s tool. The Nx “s1ngularity” malware looked for AI CLIs on the infected machine, including Claude Code and Gemini CLI, and ran them with flags like --dangerously-skip-permissions and --yolo to search the filesystem for secrets. Wiz saw it work in hundreds of cases. Your agent has broad file access and a shell, so malware doesn’t have to bring its own.

Everything moves faster. More code, more dependencies, more PRs, less time per review. A new transitive dependency in a 2,000-line lockfile diff is easy to miss for a human, and easier still for a human skimming an agent’s PR.

WARN Your agent is a developer with no instincts

A senior engineer pauses at a package with 40 weekly downloads, published yesterday, by an account created last week. An agent doesn’t, unless you give it a tool that surfaces those facts and a rule that says to check. That’s most of this guide.

What Socket does differently#

Socket analyzes the published package itself: the code inside the tarball, its install scripts, network and filesystem access, obfuscation, maintainer changes and new dependencies. It flags behavior, so it can catch a malicious release before anyone has filed a CVE. For axios, Socket’s research team flagged plain-crypto-js the same day.

You use it at four points, and each answers a different question:

WhenToolQuestion it answersCost
Before you add a packageSocket MCP (depscore)Is this package a good idea?Free, OAuth login
At install timeSocket Firewall (sfw)Is anything I’m about to download known to be malicious?Free, no account
On every PRSocket GitHub AppWhat did this change add to the tree, and is any of it risky?Free tier available
Alwayspnpm settings (not Socket)Should this install even be allowed to happen?Free

None of these replaces the others. The firewall only blocks packages already confirmed malicious. The MCP and GitHub App surface risk, but they don’t stop an install. The pnpm settings work everywhere pnpm runs, but they don’t know anything about malware. Together, they cover each other’s gaps.

flowchart TD
    IDEA([Agent or dev wants a package]) --> NEED{Can the platform,<br/>or a few lines of code, do it?}
    NEED -->|yes| NONE[No new dependency]:::good
    NEED -->|no| WHY{pnpm why pkg -r<br/>already in the tree?}
    WHY -->|yes| SMALL[Smaller decision:<br/>promote to direct dep]
    WHY -->|no| DS[Socket MCP depscore<br/>+ package_file_grep]
    DS -->|low scores or red flags| STOP[Pick something else]:::bad
    DS -->|scores OK| ADD[sfw pnpm add pkg]
    SMALL --> ADD
    ADD --> PNPM{pnpm checks:<br/>release age, trust level,<br/>build scripts, exotic sources}
    PNPM -->|fails| STOP
    PNPM --> FW{Socket Firewall:<br/>known malware?}
    FW -->|blocked| STOP
    FW --> PR[Open PR] --> APP{Socket GitHub App<br/>diff scan}
    APP -->|warn| TRIAGE[Human reviews the alert]
    APP -->|clean| MERGE[Merge]:::good
    TRIAGE --> MERGE
    classDef bad fill:#1a0f0f,stroke:#FF5F56,color:#FF5F56;
    classDef good fill:#0d1a12,stroke:#7CF9A0,color:#7CF9A0;

Before any tool: depend on less, and on people you trust#

Every tool in this guide filters packages that are already on their way in. The cheaper move is to let fewer of them in at all. As Shopify’s own guidance puts it: “Depend on fewer things. Every external dependency is a party you’ve given write access to your code.”

Use the platform first#

A lot of dependencies date from before the platform caught up. Node 18 and later have most of the classics built in:

Instead ofUseBuilt in since
axios, node-fetch, gotfetch()Node 18
uuidcrypto.randomUUID()Node 19 as a global (Node 14.17 via node:crypto)
lodash.clonedeepstructuredClone()Node 17
yargs, minimist (simple CLIs)util.parseArgs() from node:utilNode 18.3
dotenv (for scripts)node --env-file=.envNode 20.6
jest, mocha (small packages)node --testNode 18

The axios row is worth a second look. Plenty of the apps that compromise reached had installed an HTTP client to do something Node can do on its own. fetch isn’t a drop-in replacement for every axios feature, but for most apps’ “call an API and parse the JSON,” it’s enough.

Removing code you already have counts too. In our WooCommerce plugin, a site-wide crash traced back to a bundled PHP dependency injection library that clashed with another plugin’s copy of the same interface. We didn’t upgrade it. We replaced every bundled runtime library with native PHP, because each one was only being used for a sliver of itself:

  • A dependency injection container became a 20-line class
  • A templating engine became printf, since it was rendering one six-line template
  • A state machine library became an array and a can() function, with 119 tests checking that the behavior matched

The plugin’s runtime vendor/ folder went from 6.7 MB to 80 KB, and that kind of conflict can’t happen again. Coding agents make this trade much cheaper than it used to be: “write the 20 lines we actually use” is a quick task now, not a sprint.

WARN Don't hand-roll the hard parts

Swap out conveniences, not expertise. Use well-maintained libraries for cryptography, HMAC and JWT verification, HTML sanitizing, and parsing anything an attacker controls. A homemade sanitizer is a vulnerability with extra steps. Reach for the platform (node:crypto’s timingSafeEqual, for example) or a vetted package, never something you or your agent invented this afternoon.

When you do add one, prefer the vendor’s own package#

For a third-party service, use the SDK that the vendor publishes and maintains: @shopify/shopify-api, stripe, @aws-sdk/*, @temporalio/*. You’re already trusting the vendor with your data, so their SDK adds less new trust than a community wrapper from a stranger. A large vendor’s packages are also more likely to have a security team, a security policy and locked-down publishing behind them.

Check that it’s really theirs before you install it:

  • The scope or name matches the vendor’s docs. Copy the install command from the vendor’s own documentation, not from a blog post, a search result or your agent’s memory. @shopify/ is owned by Shopify, but @shopify-dev/ or shopify-api-node might not be.
  • The package links back to the vendor’s GitHub org, and that repo links back to the package.
  • It’s published with provenance. npm shows a provenance badge on packages built and published from CI with trusted publishing. Run npm audit signatures to verify the registry signatures and provenance attestations for everything in your tree.

Official doesn’t mean immune, though. Nx and axios were both the real, official packages when they were compromised. A trusted publisher makes an attack less likely. It doesn’t make the rest of this guide optional, and the cooldown and trustPolicy settings below are what catch it when a trusted publisher gets hijacked.

Give your agent the same rule. Add it to CLAUDE.md next to the package rules in Layer 1:

- Before adding a dependency, check whether Node's standard library, the platform,
  or a package already in the tree covers it. Prefer a few lines of our own code
  over a new package for small utilities, but never for crypto, auth or sanitizing.
- For third-party services, use the vendor's official SDK. Take the package name
  from the vendor's docs, not from memory.

Layer 0: make pnpm refuse the obvious#

Start with config that ships in the repo and applies to every pnpm install, whether it’s run by you, CI, your agent or your hosting provider’s build step. This is our pnpm-workspace.yaml, on pnpm 11:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "extensions/*"

# Fail the install if any dependency tries to run a build script
# that hasn't been explicitly allowed below
strictDepBuilds: true

# Fail if a package's trust level drops compared with earlier releases
trustPolicy: no-downgrade

# Only direct dependencies may come from git repos or tarball URLs
blockExoticSubdeps: true

# Don't install any version published less than 2 weeks ago
minimumReleaseAge: 20160 # minutes

# The short list of packages allowed to run install scripts
allowBuilds:
  '@sentry/cli': false
  '@swc/core': true
  esbuild: true
  core-js: false
  protobufjs: false

What each one buys you:

  • minimumReleaseAge is a cooldown: pnpm won’t install any version younger than 14 days. It gets its own section below, because it’s the single most effective line in this file.
  • strictDepBuilds and allowBuilds stop install scripts, the usual way a payload runs. pnpm 10 and later won’t run dependency build scripts by default. strictDepBuilds goes further and fails the install when an unreviewed package wants to run one, so a new postinstall script can’t sneak in quietly. allowBuilds is the reviewed list, and false means “we know, don’t run it.”
  • trustPolicy: no-downgrade fails the install if a package had stronger trust evidence in an earlier release than in the one you’re installing. For example, earlier versions were published from CI with provenance, and this one was published from someone’s laptop.
  • blockExoticSubdeps stops a transitive dependency from pulling code from a git repo or a random tarball URL, where no registry scanning applies.
INFO Replay: axios against Layer 0

[email protected] was live for about three hours. A 14-day minimumReleaseAge would never have resolved it. Reports say 1.14.0 was published from GitHub Actions with trusted publishing, while 1.14.1 was published directly from the hijacked account. That’s exactly the drop trustPolicy: no-downgrade fails on. And plain-crypto-js’s postinstall script isn’t in allowBuilds, so strictDepBuilds would have failed the install instead of running it. That’s three independent stops, before Socket even comes into it.

Freeze updates: a 14-day cooldown on every installer#

Malicious releases have a short shelf life. The axios versions lasted about three hours, and most poisoned packages are found and pulled within days. A cooldown makes your installer ignore any version younger than a set age, so by the time a release is old enough for you, someone else has already found what’s wrong with it. You give up two weeks of freshness, and attackers lose the window they depend on.

Every major JavaScript package manager supports this now. They all use different units, so copy carefully:

ToolFileSetting14 days isMinimum version
pnpmpnpm-workspace.yamlminimumReleaseAge20160 (minutes)10.16 (on by default at 1 day in 11)
npm.npmrcmin-release-age14 (days)11.10.0
Yarn.yarnrc.ymlnpmMinimalAgeGate20160 (minutes)4.10 (defaults to one week in current releases)
Bunbunfig.tomlinstall.minimumReleaseAge1209600 (seconds)1.3

pnpm:

# pnpm-workspace.yaml
minimumReleaseAge: 20160 # 14 days, in minutes

npm:

# .npmrc (commit it at the repo root, and add it to ~/.npmrc for your own machine)
min-release-age=14

Yarn:

# .yarnrc.yml
# Newer Yarn also accepts "14d", but a plain number of minutes works on every version
npmMinimalAgeGate: 20160

Bun:

# bunfig.toml
[install]
minimumReleaseAge = 1209600 # 14 days, in seconds

The cooldown applies to transitive dependencies too, not only the ones in your package.json. If no version of a dependency is old enough to satisfy your ranges, the install fails instead of quietly picking a new one. That’s what you want.

CRITICAL An old npm ignores the setting and keeps going

npm versions before 11.10.0 don’t know min-release-age. They print npm warn Unknown user config "min-release-age" and then install with no cooldown at all. The machine we wrote this guide on had min-release-age=7 in ~/.npmrc and npm 11.8.0, so the cooldown had never been on. Run npm -v everywhere you install, and add a CI step that fails on an old npm instead of trusting it to complain:

- name: Require npm 11.10+ (min-release-age)
  run: |
    node -e 'const [a,b]=process.argv[1].split(".").map(Number); process.exit(a>11||(a===11&&b>=10)?0:1)' "$(npm -v)" \
      || { echo "::error::npm $(npm -v) ignores min-release-age. Upgrade to 11.10.0 or later."; exit 1; }

Match the cooldown in your update bots#

Dependabot and Renovate don’t read your package manager’s config. Without their own cooldown, they open a PR for a version the day it’s published, and whoever approves that PR has just skipped your waiting period by hand. Set them to the same 14 days.

Dependabot has a cooldown option for version updates. It defaults to 3 days. Security updates skip it, which is the behavior you want:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 14

Renovate uses minimumReleaseAge:

{
  "extends": ["config:recommended"],
  "minimumReleaseAge": "14 days"
}

Exempt one version, not a whole package#

Sometimes you need a release that’s still inside the cooldown, like a fix for a CVE you’re exposed to. Exempt that exact version and leave a note saying when to remove it:

# pnpm-workspace.yaml
minimumReleaseAgeExclude:
  # CVE fix, still inside the cooldown. Remove after 2026-06-01.
  - '@babel/[email protected]'

Pin the version (supported since pnpm 10.19). A bare package name exempts every future version of that package from the cooldown, permanently. Bun’s minimumReleaseAgeExcludes works the same way: a name@version entry exempts one version, and a bare name exempts all of them. In Yarn, the list is npmPreapprovedPackages. For a one-off npm install, npm install <pkg>@<version> --min-release-age=0 skips the cooldown for that command only.

Layer 1: evaluate a package before it exists on disk#

The cheapest malicious package is the one you never install. Give your agent a way to research packages, and a rule that makes it do so.

Add the Socket MCP server#

Socket hosts an MCP server at https://mcp.socket.dev/. It authenticates with OAuth, so there’s no API key to provision, store or rotate. Add it to your project’s .mcp.json so everyone on the team gets it:

{
  "mcpServers": {
    "socket": {
      "type": "http",
      "url": "https://mcp.socket.dev/"
    }
  }
}

Each person runs /mcp in Claude Code, selects socket, and signs in once. The tools that matter:

ToolWhat it’s for
depscoreSupply chain, quality, maintenance, vulnerability and license scores, 0 to 100
threat_feedKnown malware, typosquats and obfuscated code
package_files / package_file_contentsList and read the files in a published tarball
package_file_grepSearch a published tarball with a regex, without installing it

package_file_grep is the one that changes how review works. Instead of guessing from a package’s README, you can ask “does any file in this tarball call child_process, eval, or reach the network in an install script?” and get an answer before the code ever touches your machine.

Write the rule where the agent will read it#

A tool your agent doesn’t know to use is decoration. Put the rule in CLAUDE.md (or AGENTS.md), which loads every session. A skill only loads when it’s invoked, and nobody invokes “remember to be careful.” This is ours:

## Package management

- **Evaluate a package BEFORE adding it.** Run the `socket` MCP server's `depscore`
  on any new dependency and report the scores in the PR that introduces it.
  Use `threat_feed` for malware and typosquats, and `package_files` /
  `package_file_grep` to read the published tarball without installing it.
- Socket Firewall (`sfw`) and `depscore` answer different questions: `sfw` blocks
  *known-malicious* packages at install time, `depscore` tells you whether a package
  is unmaintained, low quality, or carrying risky transitive deps. Use both.
- Check whether a dependency is **already in the tree** before treating it as new:
  `pnpm why <pkg> -r`. A package we already ship transitively is a much smaller
  decision than a genuinely new one.

That last rule came from a near miss. An agent proposed libphonenumber-js as a “new” dependency, and we vetted it by hand against registry metadata. Only later did someone notice the app had already been shipping it for months, pulled in transitively by react-phone-number-input. The risk picture changed completely, and it changed by accident. pnpm why takes two seconds.

WARN Scores are evidence, not a verdict

A 100/100 depscore means Socket found nothing wrong today. It doesn’t mean the next release is safe, which is what the cooldown and the firewall are for. And a low maintenance score on a small, finished library isn’t always a problem. Read the alerts behind a score, not just the number.

Layer 2: firewall every install#

Socket Firewall (sfw) is a short-lived HTTP proxy that sits between your package manager and the registry. Put sfw in front of an install command, and every package request, including transitive ones, gets checked against Socket before it’s downloaded. If a package is confirmed malicious, it’s blocked. The free version needs no account or API key, and it works with npm, yarn, pnpm, pip, uv and cargo.

npm i -g sfw
sfw pnpm install
sfw pnpm add libphonenumber-js

A firewall you have to remember to type is a firewall you’ll forget. Wire it into the three places installs actually happen: your agent, your CI, and your container builds.

For your agent: a hook that adds sfw for it#

Claude Code hooks can rewrite a command before it runs. This PreToolUse hook adds sfw to the front of any pnpm command that installs something, and passes read-only commands like pnpm test or pnpm why through untouched:

#!/usr/bin/env bash
# .claude/hooks/wrap-pnpm-with-sfw.sh
# Prefixes pnpm install/add/update/dlx/import with `sfw` so they go through
# Socket Firewall. Does NOT catch pnpm inside compound commands
# (e.g. `cd foo && pnpm install`).
set -euo pipefail

input="$(cat)"
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')"

# Already wrapped, or not pnpm at all: allow as-is
[[ "$cmd" =~ ^[[:space:]]*sfw[[:space:]] ]] && exit 0
[[ "$cmd" =~ ^[[:space:]]*pnpm([[:space:]]|$) ]] || exit 0

# Find the subcommand: the first token that isn't a flag or a --filter value
read -ra tokens <<< "$cmd"
subcommand=""
for ((i=1; i<${#tokens[@]}; i++)); do
  t="${tokens[$i]}"
  [[ "$t" == -* ]] && continue
  prev="${tokens[$((i-1))]}"
  [[ "$prev" == "-F" || "$prev" == "--filter" ]] && continue
  subcommand="$t"; break
done

case "$subcommand" in
  install|i|add|update|up|upgrade|dlx|import)
    jq -nc --arg c "sfw $cmd" '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        updatedInput: { command: $c }
      },
      systemMessage: "Wrapped pnpm install with sfw (Socket Firewall)"
    }'
    ;;
esac

Register it in the project’s .claude/settings.json, so it applies to everyone who works in the repo:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/wrap-pnpm-with-sfw.sh", "timeout": 5 }
        ]
      }
    ]
  }
}

The agent doesn’t have to remember the rule, because the rule runs whether it remembers or not. Test it by piping fake tool input through the script. {"tool_input":{"command":"pnpm add left-pad"}} should come back rewritten, and pnpm test should come back empty.

For CI: one composite action, no fallback#

sfw downloads its firewall binary the first time it runs, so a GitHub or registry hiccup can fail a job before a single test runs. Put the retry logic in one composite action instead of copying it into every workflow:

# .github/actions/firewalled-install/action.yml
name: Firewalled pnpm install
description: Install dependencies through Socket Firewall, with retries

inputs:
  args:
    description: Everything after `sfw pnpm`, like "install --frozen-lockfile"
    required: true
  working-directory:
    required: false
    default: "."
  attempts:
    required: false
    default: "3"

runs:
  using: composite
  steps:
    - name: Install Socket Firewall
      shell: bash
      run: |
        for i in 1 2; do
          npm i -g sfw && exit 0
          echo "::warning::npm i -g sfw failed (attempt ${i}/2)"
          sleep 10
        done
        echo "::error::Could not install Socket Firewall. Dependencies were NOT installed. This is an infrastructure failure, not a test failure."
        exit 1

    - name: Install dependencies through Socket Firewall
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      env:
        SFW_ARGS: ${{ inputs.args }}       # via env, not interpolated into the script
        SFW_ATTEMPTS: ${{ inputs.attempts }}
      run: |
        for attempt in $(seq 1 "$SFW_ATTEMPTS"); do
          sfw pnpm $SFW_ARGS && exit 0     # unquoted on purpose: several arguments
          if [ "$attempt" -lt "$SFW_ATTEMPTS" ]; then
            echo "::warning::Install attempt ${attempt}/${SFW_ATTEMPTS} failed, retrying"
            sleep $((attempt * 15))
          fi
        done
        echo "::error::Socket Firewall could not complete 'pnpm ${SFW_ARGS}'. Re-run the job."
        exit 1

Then every workflow installs the same way:

- name: Install dependencies (through Socket Firewall)
  uses: ./.github/actions/firewalled-install
  with:
    args: "install --frozen-lockfile"
CRITICAL Never fall back to a plain install

It’s tempting to add || pnpm install so a flaky firewall doesn’t block your deploy. Don’t. That fallback turns the firewall off at random, and the outage you don’t notice is the one an attacker gets. Fail loudly with a message that says “infrastructure,” then re-run. We run this action at nine install steps across seven workflows.

For containers: warm the binary once#

Container builds without a layer cache download the firewall binary on every build. Fetch it once, with retries, before you copy your source in, then skip the update check for later installs:

RUN npm install -g [email protected] sfw

# Warm sfw's binary cache once, so a transient GitHub blip doesn't fail the build
ENV SFW_SKIP_UPDATE_CHECK=1
RUN for i in 1 2 3 4 5; do \
      sfw --version && break; \
      sleep $((i * 3)); \
    done; \
    sfw --version

COPY . .
RUN sfw pnpm install --frozen-lockfile

Layer 3: the Socket GitHub App on every PR#

The firewall answers “is this known malware?” The Socket GitHub App answers a broader question: “what did this PR add to the tree, and is any of it risky?” Install it on the repo and it comments on every PR that changes dependencies, with scores for each added package and alerts for risky behavior anywhere in the new tree.

A clean result looks like this, from the PR that added libphonenumber-js:

PackageSupply chainVulnerabilityQualityMaintenanceLicense
npm/[email protected]10010010096100

When something’s off, the comment shows the alert and the path that pulled it in. These are real ones from our repo:

AlertPackagePulled in byWhat it turned out to be
Protestware or unwanted behavior[email protected]serverlessReal, well-documented protestware. serverless was unused, so we removed it a week later.
Obfuscated code (90%)[email protected]@temporalio/worker → @sentry/nextjs → webpackbarAlmost certainly minified build output in a very widely used package
Obfuscated code (91%)[email protected]The lockfile, several pathsSame story: a widely used package whose code looks dense to a scanner

That table is the honest picture. Most alerts are judgment calls, not emergencies. Minified code looks a lot like obfuscated code. Read the dependency path first, because “a transitive dependency of a tool we stopped using” is a five-minute fix: remove the tool.

INFO Warn by default, block on purpose

Start with Socket’s warn-level policy so alerts show up as PR comments without failing builds. Once you’ve triaged the backlog, switch the alert types you never want to see, like known malware and install scripts in new packages, to block, and make the Socket check required in your branch ruleset.

Replaying axios against all four layers#

What would each layer have done with [email protected] on the day it was live?

LayerWhat happens
minimumReleaseAge: 20160pnpm won’t resolve a version published three hours ago. You stay on 1.14.0.
trustPolicy: no-downgradePer the published reports, a trusted-publishing release followed by a direct publish is a downgrade, so the install fails.
strictDepBuildsplain-crypto-js wants to run postinstall and isn’t in allowBuilds, so the install fails.
Socket FirewallOnce Socket confirms plain-crypto-js as malware, it’s blocked at the proxy, even when it’s transitive.
Socket GitHub AppA lockfile bump that adds plain-crypto-js gets an alert on the PR.
Socket MCP + CLAUDE.mdAn agent told to “upgrade axios” checks the score first, and the alerts are right there.

You don’t need every row to hold. You need one.

Gaps we found in our own setup#

Writing this guide meant auditing our own config, and it isn’t airtight. Check yours for the same holes:

  • Your host’s build step probably skips the firewall. Vercel, Netlify and friends run their own pnpm install, so the firewall only runs if you change the install command. The pnpm-workspace.yaml settings still apply there, because they live in the repo. That’s the best argument for Layer 0.
  • npx and MCP servers go around all of it. An .mcp.json entry like "args": ["@playwright/mcp@latest"] downloads whatever was published a minute ago, every time the agent starts. It uses npx, not pnpm, so the cooldown and build rules don’t apply, and nothing wraps it in sfw. Pin MCP servers to exact versions like any other dependency.
  • The hook misses compound commands. cd apps/core && pnpm install doesn’t start with pnpm, so it isn’t wrapped. The script says so in its header. Add a CLAUDE.md line telling the agent to run installs as standalone commands, or tighten the matcher.
  • npm i -g sfw installs the latest wrapper, and the wrapper fetches the latest firewall binary. It checks the download against the checksum GitHub reports, which protects the transfer, but you’re still trusting whatever Socket released last. Pin the wrapper (npm i -g [email protected]) so at least that part only changes on purpose.
  • Cooldown exemptions outlive their reason. An exemption by bare package name lasts forever. Pin the version and write a removal date next to it.
  • Actions pinned to tags. A tag like actions/checkout@v5 can be moved to point at different code. Shopify recommends pinning actions to a full commit SHA. We’ve done it for some actions, but not all of them yet.

The checklist#

  • Before each new dependency, ask whether the platform or a few lines of code can do it instead
  • For third-party services, use the vendor’s official SDK, and run npm audit signatures
  • Commit your lockfile, and install with --frozen-lockfile in CI
  • Set a 14-day cooldown in your package manager, check npm -v is 11.10.0 or later if you use npm, and match it in Dependabot or Renovate
  • Set strictDepBuilds, allowBuilds, trustPolicy: no-downgrade and blockExoticSubdeps in pnpm-workspace.yaml
  • Add the Socket MCP server to .mcp.json, and have everyone authenticate once
  • Add the “evaluate before adding, check pnpm why first” rule to CLAUDE.md / AGENTS.md
  • Add the PreToolUse hook that wraps pnpm installs in sfw
  • Route every CI install through one firewalled composite action, with no fallback
  • Wrap installs in your Dockerfiles and your host’s install command
  • Install the Socket GitHub App, triage the backlog, then block the alert types you never accept
  • Pin MCP servers, npx tools and GitHub Actions to exact versions or SHAs

References#