package-lock.json に名前がある、だけでは対応の優先度は決められない。誰がそのパッケージを引っ張っているかまで出せないと、更新候補を絞れないからだ。
8月4日、Keyv と周辺パッケージを含む npm サプライチェーン侵害の報告が出た。こういう時に advisory のパッケージ名を grep して終わると、間接依存なのか、実行環境に載るのかが分からない。自分はまずロックファイルから逆向きの依存経路を出すようにしている。地味だけど、調査の最初の10分がかなり変わる。
ここでは npm の lockfile v2/v3 を対象に、指定したパッケージのバージョンと「なぜ入っているか」を標準ライブラリだけで表示する。
先にやること
侵害報告を見たら、脆弱性情報の一次ソースから対象のパッケージ名とバージョン範囲を控える。このスクリプトは CVE 判定器ではない。手元の package-lock.json に、その名前がどの経路で入っているかを確認するための小さい道具だ。
ロックファイルを更新する前に実行するのがコツ。先に npm install すると、調査したかった状態そのものが消えることがある。
依存経路を出すPython
lock_trace.py として保存する。
import argparse
import json
from collections import defaultdict, deque
from pathlib import Path
def package_name(path: str) -> str:
return path.rsplit("node_modules/", 1)[-1] if "node_modules/" in path else path
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("lockfile", type=Path)
parser.add_argument("package")
args = parser.parse_args()
lock = json.loads(args.lockfile.read_text(encoding="utf-8"))
packages = lock.get("packages")
if not isinstance(packages, dict):
raise SystemExit("package-lock.json v2/v3 の packages が必要です")
records = {}
parents = defaultdict(list)
for path, meta in packages.items():
if not path:
continue
name = package_name(path)
records[path] = {
"name": name,
"version": meta.get("version", "?"),
"dev": meta.get("dev", False),
}
for dependency in meta.get("dependencies", {}):
parents[dependency].append(path)
root = packages.get("", {})
root_dependencies = dict(root.get("dependencies", {}))
root_dependencies.update(root.get("devDependencies", {}))
for dependency in root_dependencies:
parents[dependency].append("<root>")
hits = [path for path, meta in records.items() if meta["name"] == args.package]
if not hits:
raise SystemExit(f"{args.package}: 見つかりません")
for path in hits:
meta = records[path]
print(f"FOUND {meta['name']}@{meta['version']} path={path} dev={meta['dev']}")
print("WHY")
queue = deque([(args.package, [args.package])])
seen = {(args.package, (args.package,))}
while queue:
child, chain = queue.popleft()
for parent in parents[child]:
if parent == "<root>":
print(" " + " <- ".join(chain + ["root"]))
continue
parent_name = records[parent]["name"]
next_chain = chain + [parent_name]
marker = (parent_name, tuple(next_chain))
if marker not in seen and len(next_chain) <= 8:
seen.add(marker)
queue.append((parent_name, next_chain))
if __name__ == "__main__":
main()
node_modules/ の後ろをパッケージ名として扱うので、@scope/name もそのまま渡せる。packages[""] にある直下の dependencies と devDependencies を根に足し、そこから逆向きにたどっている。lockfile v3 は dependencies ツリーより packages の方が、実際に固定されたバージョンを取り出しやすい。
この逆引きの流れを図にすると、調査対象は「パッケージ名」ではなく「導入元を含む経路」になる。
手元のlockfileでの実行例
次のように実行する。
python3 lock_trace.py package-lock.json ws
今回、手元の lockfile v3 で実行するとこうなった。
FOUND ws@8.18.0 path=node_modules/ws dev=True
WHY
ws <- miniflare <- wrangler <- root
この出力なら、ws を直接使っているのではなく wrangler 経由だと分かる。アプリのランタイム依存か、CI だけの開発依存かも dev で見える。今回は dev=True だったので、まず更新可否を wrangler 側で確認する順番になる。いきなり ws を個別に上書きするより、依存元の対応版を探した方が lockfile が壊れにくい。
この出力だけで決めない
同じ名前が複数バージョン入る lockfile では、逆引きの候補が混ざることがある。ネストされた依存を厳密に追う必要がある場合は、出力を足がかりに npm ls <package> と advisory の対象範囲を突き合わせる。dev=True も安全の印ではない。ビルド時にトークンを読むパッケージなら、CI の侵害としては十分に問題になる。
あと、ロックファイルに無いから安心とも言えない。CI が npm install を使っているなら、まず npm ci に直す。ここで初めて「調査した依存」と「実際に入った依存」が同じになる。昨日この確認をしていて、普段は見ない dev=True に少し救われたけれど、CI のシークレットを読む工程だったら話は別だな、と手が止まった。
おわりに
サプライチェーン事故の初動は、対象名を見つける作業より依存の入口を特定する作業に時間を取られる。ロックファイルから経路を一度出しておけば、更新担当、実行環境、検証範囲を同じメモに載せられる。
次の侵害報告でも、advisory のバージョン範囲を確認してからこのスクリプトに名前を渡す。出力が短い案件ほど、更新と npm ci の再実行まで早く進む。これは検知を自動化する話ではなく、最初の判断を雑にしないための道具だ。
参考: Keyv and friends compromised in active Shai-Hulud supply chain attack