blog / · 7 min read

Supply Chain Security: The Forgotten Front

We patch servers and ignore dependencies. Three incidents from 2026, including a backdoored Claude Code extension that was live for 11 hours, show why that no longer works, and how to hunt for victims with KQL.

supply-chainnpmdependenciesvscode-extensionssentinelkqlthreat-huntingdevsecops

Most teams have a patching process for servers and laptops. Far fewer have one for the code they did not write: npm packages, Python libraries, container base images, IDE extensions. We add a dependency once, it works, and nobody looks at it again.

That is the gap attackers are using. You do not need to breach a company if you can get your code into something the company already trusts and installs automatically.

“Safe today” is not “safe forever”

A dependency is not a fixed thing. It has maintainers whose accounts can be stolen, publishing pipelines that can be tampered with, and code that will be reviewed by researchers long after you added it. Log4j was considered boring, stable infrastructure for years until Log4Shell turned it into a CVSS 10.

So the question is not “is this library safe?” but “will I find out quickly when it stops being safe?”

Three examples from 2026

node-ipc (May 2026). Three versions of node-ipc, a package with over 10 million weekly downloads, shipped an obfuscated credential stealer inside the package bundle. No install hook was needed: it ran as soon as the package was loaded. Because the latest tag pointed to a malicious version, every unpinned install pulled it automatically. (StepSecurity)

ChainDrop (August 2026). A self-propagating worm, described by Microsoft as a Mini Shai-Hulud variant, compromised more than 400 npm packages, including widely used ones such as keyv and cache-manager. A preinstall hook stole npm, GitHub, cloud, Kubernetes and Vault credentials. With a stolen npm token, it republished every package that identity could publish, with a patch bump and the same hook. It also abused GitHub Actions trusted publishing, so some malicious releases carried valid provenance. (Microsoft Security Blog)

The Claude Code extension in Cursor’s marketplace (October 2026). On 29 October, tampered builds of the Claude Code extension (versions 2.1.312 and 2.1.313) were available in Cursor’s own extension marketplace for about 11 hours. Cursor says up to 5,000 users received them and that the build was not developed by Anthropic. (Help Net Security, BankInfoSecurity)

The payload ran as soon as the editor loaded the extension, with no user interaction. According to the published analysis, it:

  • added an attacker SSH key labelled svc-telemetry to authorized_keys;
  • opened a reverse SSH tunnel to the attacker’s server, retrying every minute and surviving editor restarts;
  • sent host reconnaissance (hostname, user, OS, Git email, public IP) and the names of up to 40 credential-like environment variables;
  • persisted through a LaunchAgent on macOS and a SysMon scheduled task on Windows.

Uninstalling the extension does not remove it. (ThreatDefence advisory)

The lesson from this one is uncomfortable: the extension had the right name, the right publisher and came from the marketplace built into the tool. Developers did exactly what they were supposed to do.

Why it keeps working

  • Unpinned versions. ^1.2.3 means “give me whatever is published next”, including the malicious release.
  • Install-time code execution. npm lifecycle scripts and IDE extensions run with the developer’s full permissions.
  • Developer machines are rich targets. SSH keys, cloud CLIs, Git tokens, .env files, CI secrets.
  • Nobody owns the inventory. Without an SBOM you cannot answer “are we affected?” in minutes.
  • Scanning once is not monitoring. A clean scan at merge time says nothing about the CVE published six months later.

What to do about it

  1. Know what you run. Generate SBOMs for every build and keep them somewhere you can query (Dependency-Track is a good free option).
  2. Pin and lock. Commit lockfiles, use npm ci, and avoid floating latest in production.
  3. Update on purpose, not never. Tools like Renovate or Dependabot turn updates into small, reviewable pull requests. Adding a short cooldown before adopting brand-new releases gives the community time to spot malicious versions.
  4. Scan continuously. Scan in the pipeline and keep re-scanning what is already deployed (Trivy, Grype, Dependency-Track).
  5. Restrict install scripts. Consider --ignore-scripts in CI where possible, and review packages that need them.
  6. Treat IDE extensions as software. Maintain an allowlist, manage it centrally, and include extensions in your inventory.
  7. Keep secrets off developer machines. Short-lived credentials, OIDC in pipelines and a vault reduce what a stealer can take.

Hunting for victims with Microsoft Sentinel / Defender

When the next incident lands, the first question from management will be “are we affected?”. If your endpoints feed Defender for Endpoint into Sentinel, you can answer it with advanced hunting.

A good starting point is Kevin Beaumont’s AnthropicSupplyChainAttack.kql, which sweeps for the attacker IP, version-specific artifacts, editor-spawned SSH and reconnaissance commands. Two caveats if you run it as-is: it only looks for version 2.1.313 (2.1.312 was also tampered), and some of its low-fidelity checks, such as any Claude Code extension activity or lookups to ifconfig.me, will be noisy in an environment where developers use these tools legitimately.

The queries below are my own adaptation, written from the IOCs in the ThreatDefence advisory and Cursor’s incident notice. They cover both versions and keep only high- and medium-fidelity signals. Adapt table names and time ranges to your environment and validate before turning any of them into an analytics rule.

1. Connections to the attacker server

DeviceNetworkEvents
| where Timestamp > datetime(2026-10-29)
| where RemoteIP == "134.209.82.225"
| summarize FirstSeen = min(Timestamp), LastSeen = max(Timestamp), Ports = make_set(RemotePort)
    by DeviceName, InitiatingProcessFileName, InitiatingProcessCommandLine

2. Planted SSH key or persistence files

DeviceFileEvents
| where Timestamp > datetime(2026-10-29)
| where FileName in~ ("authorized_keys", "administrators_authorized_keys", "smt.cmd", "sysmon.cmd", "com.apple.sysmond.plist", ".smt", ".smtk")
    or FileName startswith ".stk_"
| project Timestamp, DeviceName, ActionType, FolderPath, FileName, InitiatingProcessFileName, InitiatingProcessCommandLine

3. Reverse SSH tunnels and the SysMon task

DeviceProcessEvents
| where Timestamp > datetime(2026-10-29)
| where (FileName in~ ("ssh", "ssh.exe", "autossh") and ProcessCommandLine has_all ("-N", "-R"))
    or (FileName =~ "schtasks.exe" and ProcessCommandLine has "/tn SysMon")
    or ProcessCommandLine has "setremotelogin on"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName

4. Tampered extension builds by hash

let iocs = dynamic([
  "fcbb327e38e61c4fec795e0d0591f52cd1014b813d20a5912104953c3a17c364",
  "3de1722901165769893bef57c72594e9a54459faa23f3091b136b79966edd081",
  "80402fdcad8799285fd93044976973ca000da7995fb93363f819bb8e4f84ee6c",
  "6786ec90cbd45949695bd7805c9fbb26cfc91bc5fc897de0183d9bcb9cb21731",
  "ecb58cb29078d9646ad1dcdbe51526f2d9cfea06be5fb2ab183e8ecc3ab6bf15"]);
DeviceFileEvents
| where Timestamp > datetime(2026-10-29)
| where SHA256 in (iocs) or FolderPath has_any ("anthropic.claude-code-2.1.312", "anthropic.claude-code-2.1.313")
| project Timestamp, DeviceName, FolderPath, FileName, SHA256

5. Low-noise reconnaissance: only on devices that installed a tampered build

Reconnaissance commands like git config --global user.email or a public IP lookup are normal on a developer laptop. On their own they are noise. Correlated with the presence of a tampered build, they become a signal.

let tampered = DeviceFileEvents
| where Timestamp > datetime(2026-10-29)
| where FolderPath has_any ("anthropic.claude-code-2.1.312", "anthropic.claude-code-2.1.313")
| distinct DeviceId;
DeviceProcessEvents
| where Timestamp > datetime(2026-10-29)
| where DeviceId in (tampered)
| where InitiatingProcessFileName in~ ("cursor", "cursor.exe", "node", "node.exe", "Cursor Helper (Plugin)")
| where ProcessCommandLine has_any ("git config --global user.email", "ifconfig.me", "api.ipify.org")
    or (FileName in~ ("ssh", "ssh.exe") and ProcessCommandLine has "-R")
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName

6. Generic: package managers spawning shells or network tools

This one is not tied to a single incident. It is a useful baseline for spotting install scripts doing things they should not.

DeviceProcessEvents
| where Timestamp > ago(7d)
| where InitiatingProcessFileName in~ ("node", "node.exe", "npm", "npm.cmd", "bun", "bun.exe")
| where FileName in~ ("curl", "wget", "bash", "sh", "powershell.exe", "pwsh", "cmd.exe")
| where InitiatingProcessCommandLine has_any ("preinstall", "postinstall", "install")
| summarize Count = count(), Commands = make_set(ProcessCommandLine, 10) by DeviceName, InitiatingProcessCommandLine
| order by Count desc

For worm-style npm campaigns, Microsoft published ready-made advanced hunting queries in its ChainDrop write-up (preinstall setup.mjs execution, Bun child processes, credential access). They are worth keeping in your hunting library. (Microsoft Security Blog)

If something matches: isolate the machine, preserve evidence, run the vendor’s remediation, rotate every credential the machine could reach from a clean device, and rebuild if the tunnel ever connected.

Closing

Supply chain security is not a product you buy. It is an inventory you keep, updates you actually apply, scans that keep running after the merge, and the ability to answer “are we affected?” before the incident is on the news.

The code you did not write still runs with your permissions.


Sources

← All posts