セキュリティ サーバー 公開日 2026.09.11 更新日 2026.09.11

脆弱性スキャナが緑でも本番は古いことがある|lockと実体がずれる原因と確認方法

ロックファイルが新しく脆弱性スキャナも0件なのに、実際に動いているものだけが古いことがあります。スキャナが見ているのは宣言であって実体ではないためです。ロックは0.35.4、実体は0.35.1、audit は0件という状態を手元で再現し、確認方法とその盲点まで実測でまとめます。

目次(20項目)

先に要点

  • ロックファイルが新しくても、実際に置かれているファイルが古いことがあります。筆者が再現したところ、ロックは 0.35.4、実体は 0.35.1、そして npm audit は脆弱性0件と答えました。
  • 理由は単純で、脆弱性スキャナが見ているのはロックであって、実際に置かれたファイルではないからです。ロックを迂回して入れたものは、スキャナの視界に入りません。
  • ずれを作る代表が、コンテナイメージのビルド中にロックを使わずに個別インストールする1行です。書いた理由はあっても、そこだけ更新から外れます。
  • 完了の判定は、ロックでもスキャナでもなく「今動いているものを開いて確かめること」に置きます。ただし確認コマンドにも盲点があるので、そこも測って示します。

依存の更新作業では、ロックファイルを見て、脆弱性スキャナを流して、どちらも問題なければ完了と判断するのが普通です。この判断はほとんどの場合は正しく、ある条件のときだけ静かに外れます。

外れるのは、ロックを通らない経路で何かが入っているときです。この記事では、その状態を手元で作って、各ツールが何と答えるかを測った結果をまとめます。

再現する

環境は Node.js 22.22.0 / npm 10.2.4 です。まず、ふつうに新しい版をインストールします。

npm install

この時点ではロックも実体も同じです。次に、ロックを使わずに古い版を入れる1行を足します。コンテナイメージのビルドで実際に書かれることがある形です。

npm install --no-package-lock sharp@0.35.1

この2つを実行した後の状態を、それぞれ別の方法で確認しました。

見る場所 出た答え
ロックファイル 0.35.4
実際に置かれたファイル 0.35.1
npm audit 脆弱性0件
npm ls 0.35.1

ロックとスキャナだけを見ていたら、この状態は「対応済み」に見えます。実際に読み込まれるのは 0.35.1 の方です。

実体を直接確認するには、置かれたファイルの情報を読みます。

node -e "console.log(require('./node_modules/sharp/package.json').version)"

なぜスキャナは気づかないのか

判定の材料が宣言の側だからです。npm の公式ドキュメントは npm audit を「プロジェクトに設定された依存の内容をレジストリへ送り、既知の脆弱性の報告を求める」コマンドだと説明しています。送るのは依存ツリーの名前とバージョンの一覧であって、ディスクに置かれたファイルそのものではありません。

宣言の側 実体の側 ロックファイル 0.35.4 置かれたファイル 0.35.1 npm audit 脆弱性0件 ロックを使わない 個別インストール 読むのはここ 古い版 実体はスキャナの判定に使われない

上の再現はそれと一致しました。ロックには 0.35.4 と書いてあり、置かれているのは 0.35.1 で、答えは0件。実体は判定に使われていないことが、結果からはっきり分かります。

これは不具合ではありません。宣言を基準にするのは設計として自然で、しかも速いという利点があります。問題は、宣言と実体がずれうることを忘れて運用する側にあります。

判断の言い換え

スキャナの0件が意味するのは「宣言されている組み合わせに既知の問題は無い」ということであって、「今動いているものが安全だ」ではありません。この2つは普段は一致するので、ずれたときだけ静かに食い違います。

ずれを作る3つの原因

ロックを使わない個別インストール

イメージのビルド中に、ロックを無視して特定の版を入れる1行があると、そこだけ更新の対象から外れます。ロックの外なので差分にも出ません。

依存の範囲指定の打ち消し

上流が引き上げた下限を、自分の設定が打ち消して古い版が据え置かれることがあります。こちらはロック自体が古くなるので、スキャナは検知できます。

開発用として除外していた

開発時だけの依存だと整理したものが、実際にはビルド成果物に含まれていることがあります。一覧を作る段階で対象から外れます。

2つ目についてはoverridesが上流のセキュリティ修正を打ち消す仕組みで詳しく測っています。3つ目は、除外の判断が正しいかを成果物の中身で確かめないと分かりません。

いずれも共通しているのは、紙の上の情報(ロック・依存の一覧・スキャナの出力)だけでは決着しないということです。

ロックを迂回する1行には理由がある

念のため書いておくと、この1行が書かれるのには理由があることが多いです。特定の構成では依存が正しく追跡されず、明示的に入れておかないと、エラーも出さずに機能だけが静かに落ちることがあります。そのために置かれた回避策であれば、構造としては正しく、古くなっているのはピンで留めた版だけです。

なので対応は「その1行を消す」ではありません。

理由

コメントで残す

その1行がなぜ必要なのかをコメントで残す。

固定した版

一覧を作る

版を固定しているなら、更新のときに見落とさない場所に一覧を作る。

更新のたびに

今も必要か確かめる

そもそも回避策が今も必要かを、更新のたびに確かめる。

回避策を責めない

回避策を責めるのではなく、回避策が更新の網から外れることを前提に置くのが実務的です。

確認コマンド自体にも盲点がある

実体を見ればよい、と書きました。ただしその確認方法にも同じ種類の落とし穴があります。

パッケージマネージャーによって、ファイルが置かれる場所の形が違います。実際に測ると次のようになりました。

構成 node_modules の直下にあるか 実体の場所
npm・直接の依存 ある node_modules の直下
npm・間接の依存 ある(巻き上げられる) node_modules の直下
pnpm・直接の依存 ある(リンク) 仮想ストアの中
pnpm・間接の依存 ない 仮想ストアの中だけ

つまり node_modules の直下を決め打ちで見る確認コマンドは、pnpm で間接依存のとき「入っていない」と答えます。実際には入っているのにです。

# 決め打ちしない。置かれている場所を探してから読む
find . -path "*node_modules/*/package.json" -path "*sharp*"

「スキャナが実体を見ていない」と指摘しながら、自分の確認コマンドが同じ形で実体を見落とす。これは実際に起きやすい失敗です。確認する側のコマンドも、パスを固定しない形にしておきます。

完了判定の置き場所を変える

ここまでをまとめると、依存の更新で「終わった」と言えるのは次のときです。

依存の更新で「終わった」と言える条件

  1. 1
    ロックが新しい版になっている

    必要だが十分ではない。

  2. 2
    スキャナが0件を返す

    必要だが十分ではない。

  3. 3
    実際に動いているものを開いて確かめた

    期待した版であることを確かめる。ロックを迂回した経路も拾えるのはここだけ。

3番目だけが、ロックを迂回した経路も拾えます。1と2は必要ですが十分ではありません。

コンテナで動かしているなら、動いているコンテナの中で版を読みます。

# 動いているコンテナの中で、実際に置かれている版を読む
docker exec コンテナ名 node -e "console.log(require('sharp/package.json').version)"
ループでまとめて確認するときの注意

対象が複数あるからといって、シェルのループの中で標準入力を読むコマンドを素で呼ぶと、1件目だけ処理して静かに終わることがあります。件数が合っているかを必ず数えてください。仕組みはループが1回で終わる原因にまとめています。

確認した結果はどこかに残します。「確認した」と「確認しろと指示した」は別物なので、同じ場所に書くと区別がつかなくなります。

よくある質問

スキャナを使う意味がないということですか

逆です。ロックを基準にした検査は速くて確実なので、常時回す価値があります。言いたいのは役割の範囲です。スキャナは宣言の健全性を保証し、実体の一致までは保証しません。両方を別々に確認すれば、それぞれが得意な範囲をきちんと埋められます。

ロックが正しければ実体も正しくなるのでは

再インストールすれば一致します。問題は再インストールされない場所です。作り終えたコンテナイメージの中身は、次にビルドし直すまで固定されます。ロックを直しても、動いているものは古いままです。だから「直した」ではなく「入れ替わった」を確認の対象にします。

ロックファイルを消して作り直せば解決しますか

ずれの原因がロック側にあるなら有効ですが、今回の形では解決しません。迂回している1行はロックを参照していないので、ロックを作り直しても同じ結果になります。原因がロックの中にあるのか外にあるのかを先に切り分けてください。切り分けの方法は、実体とロックを両方読んで食い違うかを見るだけです。

確認はどのくらいの頻度でやるべきですか

更新作業のたびに、その対象についてだけ実施するのが現実的です。全部を毎回確認するのは続きません。ただし深刻度の高い勧告に対応したときだけは、対象を広げて全数で確認する価値があります。1件でも取りこぼすと対応そのものの意味が薄れるためです。

まとめ

ロックが新しく、スキャナが0件でも、実際に動いているものが古いことがあります。

  • 脆弱性スキャナはロックを基準にしており、実体は見ていない(実測で確認)
  • ロックを迂回して入れたものは、スキャナの視界に入らない
  • 迂回する1行には理由があることが多い。消すのではなく、更新の網から外れる前提で扱う
  • 実体を見る確認コマンドにも盲点がある。パスを決め打ちしない
  • 完了の判定は「今動いているものを開いたか」に置く

依存の更新は、宣言を直す作業だと思われがちです。実際には宣言を直したうえで、実体が入れ替わったことまで見届ける作業です。この2段目を手順に入れておくと、静かにすり抜ける種類の見落としがなくなります。

参考リンク

あとで見返すならここで保存

読み終わったあとに残しておきたい記事は、お気に入りからまとめて辿れます。