Skip to main content

Supply-chain attacks are a code problem

· 9 min read
Vulkro
Security research

Supply-chain security is usually sold as a list problem: keep an inventory of your packages, match it against a vulnerability feed, patch what turns up. That works for the bug a maintainer shipped by accident. It did very little for the attacks of 2025, because in almost every one the malicious version was installed and running before any feed knew it existed.

The 2025 compromises were not vulnerabilities. They were programs. Each one read a credential, called a network endpoint, ran a shell command or republished itself, and each of those is a shape you can read in code without waiting for anyone to publish an advisory.

Four kinds of supply-chain attack in one year​

A maintainer gets phished. In September 2025 the npm account behind debug was taken over after a phishing attack and malicious versions of debug, chalk and other everyday packages were published. The advisory describes a payload that tried to redirect cryptocurrency transactions in the browser. npm removed the malicious debug version on 8 September, but anything built from it in the meantime carried the payload.

A worm does it for you. Days later came Shai-Hulud, which CISA described as a self-replicating worm. GitHub describes it entering npm "via compromised maintainer accounts by injecting malicious post-install scripts into popular JavaScript packages." The script harvested GitHub tokens and cloud keys, sent them out, and used any npm token it found to publish infected versions of the victim's own packages. GitHub removed more than 500 of them.

The build pipeline leaks the token. In August 2025 the Nx advisory traced a compromise to a workflow that printed pull request titles without sanitising them, under the pull_request_target trigger, which runs with the target repository's permissions. A crafted title became a shell command, the npm token went to a webhook, and malicious versions followed. Reporting adds that the install script prompted local AI coding CLIs with "dangerous flags (--dangerously-skip-permissions, --yolo, --trust-all-tools)" to search the file system. Earlier in the year, the tj-actions/changed-files Action was compromised and exposed secrets from the workflows that ran it.

The package never existed. A USENIX Security 2025 study generated 576,000 code samples and found models recommending packages that do not exist, 5.2% of the time on average for commercial models and 21.7% for open-source ones, across 205,474 unique invented names. The authors call it "a novel form of package confusion attack": register the name a model keeps inventing, and wait. The practice now has a name, slopsquatting.

Four entry points. In every case the damage was done by ordinary code: a file read, an environment variable, an HTTP request, a npm publish, a run: line. That is the part a scanner can read.

Check a dependency before you install it​

Vulkro Core treats a dependency as code you are about to run and checks it at four points. Everything in this section is in the Free tier.

1. Before the install: is the name real?​

An assistant's dependency list should be checked before npm install, not after. vulkro slopcheck reads a manifest, or a list you paste in, and flags names on the curated hallucination list, typosquats of popular packages (edit distance one or two, or a scope confusion) and decoy suffixes such as a popular name plus -js. It runs offline. Only with --online does it ask the registry whether a name was ever published, which is itself a strong signal.

pbpaste | vulkro slopcheck --list - --ecosystem npm

Vulkro Labs carries the same idea into the agent loop: free, keyless commands such as verify, which answers whether a package is real, non-malicious and reputable before anything is installed, and foresee, which lists the names an assistant is likely to invent for your stack so they can be blocked first.

2. In the lockfile: is this version known to be bad?​

vulkro scan matches every resolved package in your lockfiles against a local copy of public advisory data, including the malicious-package advisories. On a lockfile pinned to the September versions it reports, for example, chalk 5.6.1 has 1 known vulnerability (MAL-2025-46969) [known-malicious package] at Critical, and the same for @ctrl/tinycolor 4.1.1 and the Nx versions. The data is local, so the match also runs on an air-gapped machine with an offline bundle.

This is the list half of the problem, and it has the list's limit: it can only flag a version once an advisory exists. So it is the third line of defence, not the first.

3. In the code: does it behave like a payload?​

The checks that do not wait for an advisory read what the code does. They run in vulkro inspect, which is built for the moment you have cloned something, or installed it, and not yet run it, and group what they find by capability for a human to review:

  • MAL-EXFIL-001: a credential harvest (a read of ~/.npmrc, ~/.aws/credentials, a named token or the whole environment) within a few lines of a network call. This is the payload shape of the 2025 worm wave.
  • MAL-WORM-001: a publish or CI token next to a propagation sink, such as npm publish, a gist, a git push or a write into .github/workflows/. Known release tooling is allow-listed.
  • MAL-PKG-001: reads what an installed package's preinstall or postinstall script actually does, and flags the ones that read credentials and call out, or pipe a download into a shell.
  • MAL-WALLET-003: code that overrides a network primitive such as fetch to rewrite crypto addresses, which is what the browser payload in September did.
  • MAL-CRED-001, MAL-LOADER-001 and MAL-OBFUS-*: credential-store reads, fetch-to-eval loaders and decode-to-execute chains.

These are proximity rules, not proofs, and the docs say so: a hit is a reason to read two lines of code, never a verdict. inspect also never prints the words clean or safe. An empty result means no known shape was found, not that the code is harmless.

4. In the pipeline: can a stranger reach your tokens?​

The Nx compromise started in a workflow file, so Vulkro reads those too:

  • CICD-001 flags ${{ github.event.* }} text, such as a pull request title, expanded into a run: shell.
  • SUPPLY-CI-001 and npm-install-lifecycle-script flag install scripts in your own package.json that fetch, eval or shell out.
  • SUPPLY-CI-003 flags a third-party action pinned to a tag or branch instead of a commit SHA, and SUPPLY-CI-006 flags a repository secret passed to one. Both describe the tj-actions situation exactly: whoever controls the tag controls the code that receives your secret.
  • SUPPLY-CI-002 flags over-broad workflow permissions.
  • AGENT-AUTONOMY-001 flags an AI agent CLI launched with a permission-bypass flag in a script, a workflow or a package.json.

What no scanner fixes​

Two things stay outside any static check, and it is better to say so.

The maintainer's phishing email is the first. Nothing in your repository can stop someone else's account being taken over. What you control is how far a hijacked version gets: pin exact versions in lockfiles, review install scripts before allowing them, and keep publish tokens short-lived and scoped. GitHub's own plan after Shai-Hulud moves npm toward exactly that: two-factor publishing, seven-day granular tokens and trusted publishing.

The second is a payload written to dodge a known shape. A determined author can split a harvest and an egress across files. That is why the behaviour checks sit alongside lockfile matching and pipeline hardening rather than replacing them, and why inspect reports sites to read rather than a pass.

Start with the lockfile you have​

The quickest useful run is the one against the project you are working on today: vulkro scan for the lockfiles, the code and the workflows, vulkro inspect for anything you have just cloned, and vulkro slopcheck for the next list an assistant hands you. All of it runs locally and sends no code anywhere.

Vulkro Core has the details. To run it, start here.

Sources​