← Blog一覧へ

脆弱性アラートが消えない原因はyarn.lockに残った古い解決だった

#tech#Yarn#Renovate

Renovate——依存パッケージの更新PRを自動で作ってくれるツール——が脆弱性のあるパッケージの更新PRを作り、マージしました。package.json は新しいバージョンになっています。それなのに、GitHubのDependabot alertsも yarn npm audit も鳴り止まない。アラートの中身を見ると、更新したはずのパッケージの古い脆弱なバージョンを指したままでした。対応したのにアラートの数が減らないの、地味につらい。

種を明かすと、セキュリティスキャナが見ているのは package.json ではなく yarn.lock(と、そこから実際にインストールされる依存ツリー)です。Dependabot alertsの元になるdependency graph(GitHubが構築するリポジトリの依存一覧)について、GitHubのドキュメントはこう書いています。

For the most reliable graph, you should use lock files (or their equivalent), because they define exactly which versions of the direct and indirect dependencies you currently use. (最も信頼できるグラフのためには、ロックファイル(または同等のもの)を使うべきです。直接・間接の依存が現在どのバージョンで使われているかを正確に定義するのはロックファイルだからです) — GitHub Docs「How the dependency graph recognizes dependencies

つまりロックファイルに古いバージョンが残っている限り、package.json を何度更新してもアラートは消えません。そしてYarn 4には、古いバージョンがロックファイルに残り続ける仕組み上の理由がありました。

前提として、パッケージ管理はYarn 4です。zero-install(依存のキャッシュごとリポジトリにコミットして yarn install を省く運用)は使っておらず、CIでは毎回installしています。依存の更新はRenovateに任せています。

yarn.lockには「要求」と「答え」が別々に書かれている

原因を追うには、まず yarn.lock に何が書かれているかを知る必要があります。開いてみると、こういうエントリが並んでいます。

"foo@npm:^2.3.0, foo@npm:^2.0.0":
  version: 2.3.4

キー側の foo@npm:^2.3.0 は「誰かが foo をこの範囲で欲しいと要求している」という要求の記録です。自分の package.json が書いた範囲だけでなく、依存パッケージが内部で要求している範囲もすべてここに載ります。そして値側の version: 2.3.4 が、その要求に対してYarnが出した答え(実際にインストールするバージョン)です。

キーがカンマ区切りで2つ並んでいるのは、複数の要求がひとつの答えを共有しているからです。^2.3.0^2.0.0 はどちらも 2.3.4 で満たせるので、1エントリに集約されている。逆に言えば、要求と答えの対応が分かれてしまえば、同じパッケージのエントリが複数並びます。この状態が重複(duplicate)です。

Yarn 4は一度出した答えを動かさない

Yarnがinstall時にやるのは、要求ひとつひとつに対して「既にロックファイルに、この要求を満たす答えがあるか」を見ることです。あればそれを使う。なければ、そのときの最新からレンジ内で新しく解決してエントリを追加する。ここで重要なのは、既にある答えを勝手に更新しないという点です。

これはバグではなく設計です。誰がいつ実行しても同じ依存ツリーが再現されること(決定性)を守るため、Yarnは一度ロックした解決に手を付けません。yarn dedupe のドキュメントが、この方針をそのまま書いています。

Yarn doesn’t deduplicate dependencies by default, otherwise installs wouldn’t be deterministic and the lockfile would be useless. What it actually does is that it tries to not duplicate dependencies in the first place. (Yarnはデフォルトでは依存の重複を解消しません。そうするとinstallが決定的でなくなり、ロックファイルが意味をなさなくなるからです。実際にやっているのは、そもそも最初から重複させないようにすることです) — Yarn CLI「yarn dedupe

「あとから重複を解消する」のではなく「そもそも最初から重複させない」。そのために、一度出した答えは動かさない。

この設計の帰結として、既存の答えでは満たせない新しい要求が来たときだけ新エントリが追加され、古いエントリは他の要求が参照している限りそのまま残ります。

古いバージョンが取り残される流れ

具体的に追ってみます。前提はこうです。

更新前の yarn.lock は、2つの要求が1つの答えを共有した状態です。

"foo@npm:^2.3.0, foo@npm:^2.0.0":
  version: 2.3.4        # ← 脆弱性あり。両方の要求がここに集約されている

ここでRenovateが foo を 2.10.14 に更新します。rangeStrategypinbump(package.json に書かれたバージョン自体を書き換える戦略)の場合、package.json"foo": "2.10.14" になります。更新後のロックファイルはこうなりました。

"foo@npm:2.10.14":
  version: 2.10.14      # ← 新しく追加された(自分の直接依存ぶん)

"foo@npm:^2.0.0":
  version: 2.3.4        # ← barの要求が取り残され、脆弱性が残留する

新しい要求 foo@npm:2.10.14 は既存の答え 2.3.4 では満たせないので、新エントリが作られます。ここまでは期待どおり。問題は bar 側です。foo@npm:^2.0.0 は 2.10.14 でも満たせるのに、既存の答え 2.3.4 が有効なままなので、Yarnは前節の方針どおりそこを動かしません。

結果、package.json は更新済み、しかし yarn.lock には脆弱な 2.3.4 が居座り続ける。これがアラートが消えない直接の原因です。

ちなみにこの 2.3.4 と 2.10.14 という数字は、yarn dedupeのドキュメントが同じ取り残しの説明に使っている例をそのまま借りました。ドキュメントの例は yarn add foo@2.10.14 ですが、Renovateの更新でも起きることは同じです。Yarn自身が公式に説明している挙動、ということです。

Renovateの更新はロックファイルを最小限しか書き換えない

「Renovateが更新するときに一緒に直してくれないのか」と思いますが、直してくれません。Renovateのソースを読むと、Yarn 3以降のロックファイル更新では yarn install--mode=update-lockfile を付けて実行しています(yarn.ts)。このモードをYarnのドキュメントはこう説明しています。

update-lockfile will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost. (update-lockfileはリンクステップを丸ごとスキップし、ロックファイルに存在しないパッケージ(またはチェックサムが紐づいていないパッケージ)だけを取得します。このモードは、RenovateやDependabotのようなツールが、フルインストールのコストをかけずにロックファイルを最新に保つために使うのが典型です) — Yarn CLI「yarn install

リンク処理を省いてロックファイルの更新だけを速く済ませるモードですが、解決のルール自体は通常のinstallと同じです。つまり「一度出した答えは動かさない」挙動もそのままで、取り残されたエントリは残り続けます。重複の解消は yarn dedupe という別コマンドの仕事で、installの延長では実行されません。

しかも rangeStrategypinbump だと、更新のたびに「新しい固定バージョンの要求が追加され、レンジ側の要求が取り残される」という同じ分岐が発生します。つまり更新を重ねるほど重複が蓄積していきます。

対応

1. Renovateにdedupeさせる

dedupe(デデュープ)は、重複した解決をまとめ直す操作です。yarn dedupe コマンドは、レンジが重なっている要求をより新しいバージョンに寄せ直してくれます。これをRenovateのPR作成フローに組み込みます。renovate.json に1行足すだけです。

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "postUpdateOptions": ["yarnDedupeHighest"]
}

これでRenovateはロックファイル更新後に yarn dedupe --strategy highest を実行します(Renovate Docs「postUpdateOptions」)。先ほどの例なら foo@npm:^2.0.0 の答えも 2.10.14 に寄せられ、脆弱な 2.3.4 のエントリがロックファイルから消えます。

Yarn 4ではこの yarnDedupeHighest が唯一の選択肢です。Yarn Berry(Yarn 2以降の系統の呼び名)の yarn dedupe は戦略を選べる建て付けながら、ドキュメントに「only one is implemented at the moment(現時点で実装されているのは1つだけ)」とあるとおり highest しか実装されていません。もうひとつの yarnDedupeFewer はYarn 1系の yarn-deduplicate 向けオプションで、Renovateのソースを読んでもBerryでは実行されずスキップされます。

その highest 戦略の説明がこうです。

highest: Reuses (where possible) the locators with the highest versions. This means that dependencies can only be upgraded, never downgraded. (highest: 可能な場合、最も高いバージョンのlocatorを再利用します。つまり依存はアップグレードされることはあっても、ダウングレードされることはありません) — Yarn CLI「yarn dedupe

バージョンを上げる方向にしか動かないので、ダウングレードによる意図しない退行が起きない。脆弱性対応と相性の良い設定だと思います。

2. 溜まっている重複を一度掃除する

postUpdateOptions が効くのはこれから作られるPRだけで、既にロックファイルに溜まっている重複は消えません。導入時に一度手動で実行して、別PRにします。

yarn dedupe
git diff yarn.lock

差分の手動確認は儀式ではなく、ドキュメントが明示的に推奨している手順です。

Even though it never produces a wrong dependency tree, this command should be used with caution, as it modifies the dependency tree, which can sometimes cause problems when packages don’t strictly follow semver recommendations. Because of this, it is recommended to also review the changes manually. (誤った依存ツリーを生成することはないとはいえ、このコマンドは依存ツリーを変更するため、semverの推奨に厳密に従っていないパッケージがあると問題を起こすことがあります。慎重に使うべきで、そのため変更を手動でも確認することが推奨されます) — Yarn CLI「yarn dedupe

過去の蓄積がまとめて解消されるぶん影響範囲が広くなりがちなので、このPRはフルのCI(テスト・ビルド・型チェック)を通してからマージしてください。

3. CIで再発を検知する

yarn dedupe --check は、dedupeすべき重複が残っている場合に非ゼロ終了します。ドキュメントにも「making it suitable for CI purposes(CI用途に適している)」とある使い方で、入れておけば何かの拍子に重複が再び混入してもPRの段階で気付けます。

- name: Check for duplicate dependencies
  run: yarn dedupe --check

dedupeで解決できないケース

dedupeが効くのは「レンジが重なっていて、上位バージョンで両方の要求を満たせる場合」だけです。満たせないケースでは別の手が要ります。

ケース 対応
依存側が上限を切っている(foo@>=2.0.0 <2.5.0) 依存パッケージ自体の更新を待つか resolutions で強制
依存側が古いメジャーに固定(foo@^1.0.0) 同上。メジャー跨ぎはdedupeの対象外
修正版がリリースされていない 上流の対応を待つか代替パッケージを検討

強制的に寄せる場合は package.jsonresolutions を使います。指定した依存の解決を、レンジの整合性を無視して上書きする仕組みです。

{
  "resolutions": {
    "bar/foo": "^2.10.14"
  }
}

ただし resolutions は互換性の検証を飛ばして上書きするので、恒久対応ではなく暫定対応として扱い、上流が対応したら外すのがよいと思います。

本当に消えたかの確認

対応後、脆弱なバージョンがロックファイルから消えたことを確認します。

# ロックファイルに残っていないか直接確認
grep -n "foo@npm" yarn.lock

# 依存ツリー上のどこから参照されているか
yarn why foo

# 監査(依存の依存も含めて)
yarn npm audit --recursive

yarn why は「誰が古いバージョンを要求しているか」を教えてくれるので、前節のどのケースに当たるかの判断にも使えます。

運用上の注意

まとめ

Yarnが答えを動かさないのは決定性のためで、それ自体は信頼できる性質です。ただ、その性質とセキュリティアラートの間を埋めるのは自分の仕事でした。他のYarnリポジトリにも同じ設定を順次入れていきます。