Know the attack, stop the attack

Supply-chain attacks

Find install-time code hidden in dependencies, and look-alike fake packages.

  • Level: Basics
  • About 40 min
  • 2026-11-03

#Dependencies as an entry point

A modern app has more code written by other people than code you wrote yourself. When you run npm install, you get not only the packages you asked for, but also the packages they depend on, all the way down. An attacker who gets into any one link of that chain reaches everyone who uses it. There are three main ways in.

MethodWhat happens
HijackTake over the publishing rights of a legitimate package, and publish a malicious version
FakePublish a fake package under a name that imitates a famous one (such as expresss)
PlantPut code that runs at install time into a package that looks legitimate

There is a real example. In October 2021, malicious code was published in three versions (0.7.29, 0.8.0, 1.0.0) of ua-parser-js, a popular npm package. The fixed versions are 0.7.30, 0.8.1, and 1.0.1 (GitHub Advisory). The people using it had not made a mistake. It could get in through an ordinary update.

Why install time is targeted

An npm package can have "lifecycle scripts" such as preinstall, install, postinstall, and prepare. They run automatically in the middle of the install, with the rights of the person who ran npm install. They finish before you start the app, and before you read a single line of the code.

#Lab: list the code that runs at install time

RoamSwitch's Check of code that runs at install time lists the lifecycle scripts that packages in node_modules have. It runs nothing.

Find packages that have scripts

Build a harmless dummy. demo-helper has a script that runs at install time, and plain-lib does not. The setup.js it names does not exist, and nothing is executed.

mkdir -p app/node_modules/demo-helper app/node_modules/plain-lib
echo '{ "name": "demo-helper", "version": "1.0.0", "scripts": { "postinstall": "node setup.js" } }' > app/node_modules/demo-helper/package.json
echo '{ "name": "plain-lib", "version": "2.0.0" }' > app/node_modules/plain-lib/package.json

# The "reviewed" record is saved under ~/.config/roamswitch/. For this lab, use a throwaway home
LAB_HOME=$(mktemp -d)
HOME=$LAB_HOME roamswitch scan-scripts ./app
Output excerpt
これは脅威判定ではなく一覧表示です。何も実行しません。⚠️は誤検知を含む軽量ヒューリスティックです。

    demo-helper 1.0.0 [postinstall]
    node setup.js

確認済みの状態がまだありません。今のインストールスクリプトを確認したら、roamswitch scan-scripts --accept を実行してください。次回からは、増えた分だけを知らせます。

The output follows the language of your RoamSwitch settings (this example is in Japanese). Only demo-helper, which has a script, appears; plain-lib does not. The first line says "this is a list, not a threat verdict," so a person decides whether anything is malicious. The last line says that once you have reviewed the list and run --accept, it will tell you only what was added.

Learn only what was added

Once you have reviewed the list, record that state as reviewed. After the next update, you are told only when packages that have scripts have increased.

HOME=$LAB_HOME roamswitch scan-scripts --accept ./app

# Suppose an update brought in a new package
mkdir -p app/node_modules/new-helper
echo '{ "name": "new-helper", "version": "1.0.0", "scripts": { "install": "node build.js" } }' > app/node_modules/new-helper/package.json
HOME=$LAB_HOME roamswitch scan-scripts ./app
Output excerpt
確認済みの状態を記録しました。次回からは、増えた分だけを知らせます。

(…一覧…)

確認済みにした日(2026-11-03)の後で、インストール時に動くコードが増えたパッケージが 1 件あります。

● new-helper  - → 1.0.0  新しいパッケージがインストール時のコードを持っています (install)

In short: it recorded the state as reviewed, and after the new package was added, it reported "1 package gained install-time code since the day you reviewed, new-helper." Reading the whole list again every time, among hundreds of packages, is not realistic. Looking only at "did it increase since last time" makes it easier to notice a change hidden in an ordinary update like the ua-parser-js one.

#Lab: find look-alike package names

A fake is made to resemble the name of a famous package. It bets on typing mistakes, so it is called typosquatting. Look-alike package detection compares the names in your package.json dependencies with famous names.

Check spelling differences
cat > app/package.json <<'EOF'
{ "name": "demo-app", "version": "1.0.0",
  "dependencies": { "express": "^4.0.0", "expresss": "^4.0.0", "reakt": "^18.0.0", "lodahs": "^4.17.0" } }
EOF
roamswitch scan-typosquat ./app
Output excerpt
これは静的な参考情報であり断定ではありません。ネットワーク接続は一切行いません。正規の類似パッケージが誤検知されることもあります。

⚠️  expresss は次に似ています: "express" (distance 1)  [./app]
⚠️  reakt は次に似ています: "react" (distance 1)  [./app]

expresss and reakt were found. express is the legitimate name, so it does not appear. But lodahs (letters swapped in lodash) was not reported.

Here is what happened when I tried other misspellings on a real machine.

NameDifference from the real oneFound?
expresssOne letter added (express)Found
reaktOne letter replaced (react)Found
recatTwo adjacent letters swapped (react)Not found
axoisTwo adjacent letters swapped (axios)Not found
loadshTwo adjacent letters swapped (lodash)Not found
lodahsTwo adjacent letters swapped (lodash)Not found

In what I tried, adding or replacing one letter was found, and swapping adjacent letters was not. The result may change with the product version and the list of names it received. "Not found" is not proof of safety.

#Building your defense

RoamSwitch's list and detection are part of the front door. Combine them with measures like these.

  • Control the install: use npm ci to install exactly what the lockfile says, and add --ignore-scripts when you do not want scripts to run. Allow scripts only for the packages that need them
  • Do not use a new version right away: avoid a version just after publication, and wait a few days. A hijacked version is most dangerous in the time before it is noticed and withdrawn
  • Confine the scripts: according to its command description, RoamSwitch's roamswitch install wraps install of npm or pnpm, and confines script execution inside an isolated environment (this lesson did not run it to check)
  • Watch what increased: at every update, check whether packages with scripts have increased
What not to do
  • Decide that everything on the list is malicious just because it appears. Many legitimate scripts are needed for builds
  • Decide that you are safe because nothing appears (read "what it cannot see" below)

#What it cannot see

What the inspection cannot find
  • It does not read what the scripts do. The list shows only the fact that a script exists, and its command line. It does not judge whether it is malicious
  • An attack without a script does not appear. Code that runs when a package is loaded (require) is not a lifecycle script, so it is out of scope
  • The name comparison finds adding or replacing one letter (in the range I verified on a real machine). Swapping adjacent letters was not found
  • What it compares against is a list of famous package names. A fake imitating a name that is not on the list cannot be compared
Check yourself

You ran scan-scripts after an update and it reported nothing added. Which can you be sure of?

Show the explanation

All it looks at is "did the packages with install-time code increase." Code that runs at load time, or a change inside a package that already had a script, cannot be seen by this check.

#What to learn next

Features covered

Related techniques (ATT&CK): T1195.001

What was verified

Verified on 2026-11-03. Target: Linux 1.17.30. Ran on Ubuntu 26.04, Server Edition, against a harmless dummy node_modules

Command output varies with the product version and the environment. If yours differs, check the version first.