- はじめに — 自分で入れたパッケージは 14 個なのにインストールされたのは 1,100 個です
- 推移的依存 — リスクの実際の形
- ロックファイルが保証するものと保証しないもの
- タイポスクワッティングと dependency confusion — 名前が攻撃表面である
- postinstall — インストールはすなわち任意コード実行である
- SBOM はインシデント当日の 5 分のためにある
- アラートのシグナル対ノイズ — 到達可能性、そして署名
- おわりに — 統制三つでほとんどが片付きます
はじめに — 自分で入れたパッケージは 14 個なのにインストールされたのは 1,100 個です
プロジェクトを一つ開いて実際の数字を数えてみます。
jq '.dependencies | length' package.json
jq '.devDependencies | length' package.json
npm ls --all --parseable 2>/dev/null | wc -l
npm ls --omit=dev --all --parseable 2>/dev/null | wc -l
14
23
1123
486
自分で書いたのは 37 個なのに、インストールされたパッケージは 1,123 個です。そのうち 486 個は本番ランタイムに一緒にデプロイされます。残りの 637 個はビルドとテストの時点でしか使われませんが、その時点のプロセスはたいていリポジトリ全体と CI のシークレットにアクセスできます。
この数字がサプライチェーンセキュリティの出発点です。私たちが検討して選んだのは 37 個で、信頼することにしたのは 1,123 個です。残りの 1,086 個は名前すら聞いたことがない場合がほとんどです。
ここによくある誤解が一つ付いてきます。ロックファイルがあるから大丈夫、というものです。ロックファイルは重要な問題を一つ解きますが、その問題はセキュリティではありません。この記事はロックファイルが何を保証して何を保証しないのかから始め、実際に効果のある統制をいくつか整理します。
推移的依存 — リスクの実際の形
推移的依存が直接依存より危険な理由は三つあります。
第一に、権限の境界がありません。JavaScript でも Python でも、デフォルトの実行モデルではどのパッケージもファイルシステム、ネットワーク、環境変数にアクセスできます。文字列パディングのユーティリティでもプロセス環境全体を読んで外部に送れます。依存ツリーのどの葉の一つも、アプリケーションとまったく同じ権限を持ちます。
第二に、メンテナンスの主体が見えません。あるパッケージがどの経路で入ってきたのかを確認してみると、たいてい驚きます。
npm ls chalk --all
app@1.4.2
└─┬ jest@29.7.0
└─┬ @jest/core@29.7.0
└─┬ jest-config@29.7.0
└─┬ jest-validate@29.7.0
└── chalk@4.1.2
四段階も下りてやっと出てきます。このパッケージのメンテナが誰なのか、アカウントに二要素認証がかかっているのか、私たちは知りません。実際のインシデントの多くはメンテナのアカウント乗っ取りや引き継ぎの過程で発生しました。
第三に、更新の決定権がありません。脆弱性が見つかっても、中間のパッケージが範囲を上げてくれるまで上位からは手を出せません。強制的に上書きする方法はありますが、副作用を覚悟する必要があります。
{
"overrides": {
"semver": "7.6.3"
}
}
ツリーの規模を定期的に見るだけでも判断が変わります。
# 本番ツリーで最も多くのパッケージを引き込む直接依存を探す
for dep in $(jq -r '.dependencies | keys[]' package.json); do
count=$(npm ls "$dep" --omit=dev --all --parseable 2>/dev/null | wc -l)
printf '%6d %s\n' "$count" "$dep"
done | sort -rn | head -5
214 @aws-sdk/client-s3
131 express
88 sharp
41 pg
12 zod
機能一つのために 200 個を超えるパッケージを入れる選択が合理的かどうかは状況次第です。ただ、その数字を知らずに決めるのと、知って決めるのとは違います。
ロックファイルが保証するものと保証しないもの
ロックファイルの項目を実際に読んでみます。
"node_modules/left-pad": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
"integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="
}
integrity は tarball の SHA-512 ハッシュです。インストール時にダウンロードしたバイトがこのハッシュと違えばインストールは失敗します。これが保証するのは次の三つです。
ロックファイルを作った時点と同じバイトを受け取ること、レジストリや途中の経路で内容が変わればただちに失敗すること、そしてすべての開発者と CI が同じツリーを得ることです。つまり、ロックファイルが解く問題は再現性です。
保証しないのは、そのバイトが安全かどうかです。メンテナが悪意あるコードを含む新バージョンを正常に公開すれば、その tarball のハッシュも正常に計算されます。ロックファイルは「私が先週見たものと同じ」を保証するだけで、「これは無害だ」とは言っていません。
さらに重要なのはロックファイルがいつ更新されるかです。ここで実務の事故がよく起きます。
# ロックファイルを信頼してそのままインストールする。package.json と食い違えば失敗する。
npm ci
# package.json の範囲を満たせばロックファイルを更新する。新しいバージョンが入りうる。
npm install
CI で npm install を使っているなら、ロックファイルは事実上意味がありません。キャレット範囲がかかった依存はビルドのたびに新しいパッチバージョンを取ってくる可能性があり、その時点の最新版が侵害されたバージョンかもしれません。他のエコシステムにも同じ区別があります。
pnpm install --frozen-lockfile
yarn install --immutable
uv sync --locked
poetry install --sync
cargo build --locked
go build -mod=readonly ./...
pip install --require-hashes -r requirements.txt
Python の --require-hashes は特によく抜けます。このオプションがないと、requirements.txt にバージョンが固定されていてもハッシュ検証は行われません。
# requirements.txt
requests==2.32.3 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760
まとめると、ロックファイルは必要条件であって十分条件ではありません。ロックファイルがするのは「誰かがこっそり書き換えたか」を捕まえることであり、「公開されたもの自体が悪意あるものか」は別の統制が答えなければなりません。
タイポスクワッティングと dependency confusion — 名前が攻撃表面である
パッケージ名は文字列です。そしてインストールコマンドは人が手で打ちます。
タイポスクワッティングは、よく使われる名前と一、二文字だけ違うパッケージをあらかじめ上げておく手口です。requests と request、python-dateutil と python-dateutils、crossenv と cross-env のような組み合わせが繰り返し見つかります。ドキュメントをコピーする際に打ち間違えたり、名前を記憶に頼って打ったりするときに引っかかります。
より構造的なのが dependency confusion です。成立条件は明確です。
社内専用パッケージの名前が公開レジストリで空いていて、ビルドツールが複数のインデックスを同時に照会してバージョンの高いほうを選び、内部インデックスにその名前がスコープなしで登録されている場合です。攻撃者は同じ名前を公開レジストリにバージョン 99.0.0 で上げます。次のビルドで社内 CI が公開版をダウンロードして実行します。
Python で最もよくある失敗はこれです。
# pip.conf — 危険な設定
[global]
index-url = https://pypi.org/simple
extra-index-url = https://pypi.internal.acme.com/simple
extra-index-url は両方のインデックスを照会して、より高いバージョンを選びます。優先順位という概念がありません。内部インデックスを単一の入口に据え、そのインデックスが公開パッケージをプロキシするように構成しなければなりません。
# pip.conf — 安全な設定
[global]
index-url = https://pypi.internal.acme.com/simple
npm ではスコープをレジストリに固定するのが標準的な防御です。
# .npmrc
@acme:registry=https://npm.internal.acme.com/
//npm.internal.acme.com/:_authToken=${NPM_TOKEN}
registry=https://registry.npmjs.org/
こうすると @acme で始まる名前は絶対に公開レジストリから解決されません。ここに一つ付け加える必要があります。スコープ名そのものを公開レジストリで先に押さえておくほうが安全です。設定ファイルが抜けた環境でビルドが走っても、攻撃者がそのスコープにパッケージを上げられないからです。
内部プロキシを使うなら、名前のシャドーイングを遮断するポリシーを有効にしておくべきです。内部に存在する名前と同じ公開パッケージは、プロキシがそもそも配信しないという設定です。
インストール前にパッケージを確認する習慣も役に立ちます。
npm view left-pad time.created time.modified maintainers dist.tarball
time.created = '2014-03-20T21:08:33.000Z'
time.modified = '2018-03-05T21:16:27.081Z'
maintainers = [ 'azer <azer@roadbeats.com>' ]
dist.tarball = 'https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'
作成日が数日前でダウンロードが急増しているパッケージ、メンテナが最近変わったパッケージはもう一度見ます。
postinstall — インストールはすなわち任意コード実行である
最も過小評価されている箇所です。npm install はファイルをダウンロードして展開するコマンドではありません。各パッケージのインストールスクリプトを実行するコマンドです。
{
"name": "acme-logger",
"version": "99.0.0",
"scripts": {
"postinstall": "node ./scripts/setup.js"
}
}
setup.js が何をするのかは誰もレビューしません。このスクリプトは開発者のノート PC では開発者の権限で、CI ではデプロイ用の資格情報がある環境で実行されます。実際のインシデントで繰り返されるパターンが、環境変数の収集とクラウドメタデータの照会です。
まず現在のツリーにインストールスクリプトを持つパッケージがいくつあるかを数えます。
find node_modules -maxdepth 4 -name package.json -not -path '*/node_modules/*/node_modules/*' \
-exec jq -r 'select(.scripts.preinstall or .scripts.install or .scripts.postinstall) | .name' {} + \
2>/dev/null | sort -u
@parcel/watcher
bcrypt
esbuild
lefthook
sharp
数百個ではなく、たいてい五個から二十個ほどです。この程度なら管理できます。ですから実務上の推奨は明確です。スクリプト実行をデフォルトで遮断し、本当に必要なパッケージだけを許可します。
# .npmrc
ignore-scripts=true
npm ci --ignore-scripts
ネイティブバインディングが必要なパッケージはビルドが必要なので、個別の許可が要ります。pnpm はこの概念を設定として提供しています。
# pnpm-workspace.yaml
onlyBuiltDependencies:
- bcrypt
- esbuild
- sharp
Python にも同じ問題があります。ソース配布物をインストールすると setup.py が実行されます。ホイールだけを許可すればこの経路は消えます。
pip install --only-binary=:all: -r requirements.txt
Rust の build.rs も任意コードを実行します。この観点では Go モジュールが相対的に安全です。インストール時点で実行されるフックがないからです。
エコシステム別に整理するとこうなります。
| エコシステム | 再現インストールコマンド | インストール時のコード実行 | ハッシュ固定の方法 | 内部インデックスの優先順位設定 |
|---|---|---|---|---|
| npm | npm ci | preinstall、postinstall | ロックファイルの integrity | .npmrc のスコープ固定 |
| pnpm | pnpm install --frozen-lockfile | 許可リストからのみ | ロックファイルの integrity | .npmrc のスコープ固定 |
| pip | pip install --require-hashes -r | setup.py、ソース配布物のみ | requirements のハッシュ注記 | index-url を単一化、extra 禁止 |
| uv, poetry | uv sync --locked | ビルドバックエンドの実行 | uv.lock、poetry.lock | インデックス優先順位を明示 |
| cargo | cargo build --locked | build.rs | Cargo.lock のチェックサム | source replacement の設定 |
| go modules | go build -mod=readonly | なし | go.sum、チェックサム DB | GOPRIVATE、GOPROXY の指定 |
SBOM はインシデント当日の 5 分のためにある
SBOM は規制対応の文書として紹介されることが多く、そのせいで作っておいて誰も開かないファイルになります。実際の価値ははるかに具体的です。広く使われるパッケージで侵害が公表された日に、「うちはこれを使っていますか」という問いへ数分で答えられるかどうかです。
この答えをチャットで尋ねて人がリポジトリを漁り始めると半日が飛びます。サービスが 40 個なら一日を超えます。
ビルドパイプラインで SBOM を作り、一か所に集めておきます。
syft packages dir:. -o cyclonedx-json > "sboms/${SERVICE_NAME}.cdx.json"
syft packages registry.acme.com/payments:1.4.2 -o cyclonedx-json > sboms/payments-image.cdx.json
そしてインシデント当日はこの一つで足ります。
PKG=event-stream
for f in sboms/*.cdx.json; do
jq -r --arg p "$PKG" \
'.components[]? | select(.name==$p) | "\(input_filename) \(.name)@\(.version)"' "$f"
done
sboms/checkout-api.cdx.json event-stream@3.3.6
sboms/notification-worker.cdx.json event-stream@4.0.1
40 個のサービスのうち二つが引っかかり、バージョンまで出ました。この時点からはセキュリティの問題ではなくデプロイの問題に変わります。
SBOM に保存された脆弱性判定をやり直すのも速いです。
grype sbom:sboms/checkout-api.cdx.json --fail-on high
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
event-stream 3.3.6 4.0.1 npm GHSA-mh6f-8j2x Critical
semver 5.7.1 7.5.2 npm GHSA-c2qf-rxjj High
一つ注意すべきなのは、SBOM の正確さが生成時点に左右されるということです。ソースディレクトリで作った SBOM と最終イメージで作った SBOM は違います。イメージにはベースイメージの OS パッケージが入っていて、ソースには入っていません。インシデント対応に使うつもりなら、実際のデプロイ成果物から生成しなければなりません。
アラートのシグナル対ノイズ — 到達可能性、そして署名
依存関係スキャナーを初めて有効にすると、たいていこういう数字を見ることになります。
npm audit --json | jq '.metadata.vulnerabilities'
{
"info": 0,
"low": 41,
"moderate": 88,
"high": 23,
"critical": 4,
"total": 156
}
156 件です。この状態でチームに投げても何も起きません。数字を減らすことではなく、意味のあるものだけを残す作業が先です。
一つ目のフィルタはデプロイ対象かどうかです。
npm audit --omit=dev --json | jq '.metadata.vulnerabilities'
{
"info": 0,
"low": 3,
"moderate": 7,
"high": 2,
"critical": 0,
"total": 12
}
156 件のうち 144 件が開発依存でした。開発依存の脆弱性が無意味だという意味ではありません。ビルド環境はシークレットを握っているので、むしろ危険になりえます。ただし対応の仕方と緊急度が違います。本番ランタイムのリモートコード実行と、テストランナーの正規表現サービス拒否を同じ待ち行列に入れると、どちらも処理されません。
二つ目のフィルタが到達可能性です。インストールされていることと、脆弱なコードが実行経路にあることは違います。Go のツールがこの概念を最も明確に見せてくれます。
govulncheck ./...
Vulnerability #1: GO-2024-2687
HTTP/2 CONTINUATION フレーム処理におけるリソース枯渇
Found in: golang.org/x/net/http2@v0.21.0
Fixed in: golang.org/x/net/http2@v0.23.0
Example traces found:
#1: internal/gateway/server.go:88:21: gateway.Serve calls http2.Server.ServeConn
=== Informational ===
Vulnerability #2: GO-2024-2611
Found in: golang.org/x/net/html@v0.21.0
Fixed in: golang.org/x/net/html@v0.23.0
No traces found.
Your code is affected by 1 vulnerability from 1 module.
二件がインストールされていて、実際に呼ばれるのは一件だけです。対応の順序が自動的に決まります。他のエコシステムでも似た分析が増えていて、判定結果を文書として共有する形式が VEX です。
ここで逆方向のよくある失敗を押さえておく必要があります。アラートをなくそうとしてすべて最新に上げることです。大規模な一括アップグレードは回帰リスクを一度に持ち込み、失敗したときに原因の特定が難しくなります。しかも侵害されたバージョンが最新である場合、アップグレードはすなわち感染です。実際にいくつもの事故で、被害者は自動アップデートを真面目に適用していたチームでした。
現実的なポリシーはこうです。本番ランタイムから到達可能な項目は即時に、残りは定期バッチで処理します。新しいバージョンは公開から一定期間が過ぎたものだけを受け取るように猶予の窓を置きます。この一つの設定が、公開直後に見つかる侵害事故のほとんどを回避させてくれます。
{
"packageRules": [
{
"matchDepTypes": ["dependencies"],
"minimumReleaseAge": "5 days"
}
]
}
最後の層が署名と出所証明です。パッケージが主張するソースから実際に作られたのかを確認する段階です。
npm audit signatures
audited 1123 packages in 3s
1119 packages have verified registry signatures
4 packages have missing registry signatures
公開する側であれば出所証明を付けられます。
npm publish --provenance --access public
コンテナイメージとリリースアーティファクトは署名と検証を併せて使います。
cosign sign --yes registry.acme.com/payments:1.4.2
cosign verify registry.acme.com/payments:1.4.2 \
--certificate-identity-regexp 'https://github.com/acme/payments/.github/workflows/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
署名は「このバイトが安全だ」ではなく「このバイトがこのパイプラインから出てきた」を証明します。それ自体で悪意あるコードを防ぐわけではありませんが、インシデント時にどこから何が入ってきたのかを追跡可能にし、レジストリ侵害によって成果物がすり替えられる経路を閉じます。
おわりに — 統制三つでほとんどが片付きます
依存関係のサプライチェーンは完全には塞げない領域です。信頼しなければならないコードが千個単位で、そのほとんどを読めないからです。ですから目標は遮断ではなく、露出の縮小と対応速度です。
効果対費用が最も良い統制は三つです。
CI で再現インストールコマンドだけを使うことです。npm ci、--frozen-lockfile、--locked、--require-hashes。これが抜けていると残りの統制がすべて無意味になります。
インストールスクリプトをデフォルトで遮断し、必要なパッケージだけを許可することです。対象はたいてい十個前後で、この措置一つでインストール時点の任意コード実行経路がほぼ消えます。
デプロイ成果物から SBOM を作って一か所に集めることです。普段は使う機会がなく、インシデント当日に半日を 5 分へ縮めてくれます。
そしてロックファイルについての誤解を一つだけ正して終わりにします。ロックファイルの整合性ハッシュは「このパッケージは安全だ」ではなく「このパッケージは私が最後に見たものと同じだ」という意味です。この二つの文の差が、サプライチェーンセキュリティのほぼすべてです。
현재 단락 (1/212)
プロジェクトを一つ開いて実際の数字を数えてみます。