(->> read write unlearn)

書いても書かなくても忘れる

npm の pacakge-lock.json の仕様

Shai-Hulud サプライチェーン攻撃

npm に対する大規模なサプライチェーン攻撃「Shai-Hulud」について調査するなかで pacakge-lock.json の仕様について調べました。

codebook.machinarecord.com

npmプラットフォームを標的とした新たなサプライチェーン攻撃について、複数のセキュリティ企業が報告。自己伝播を行うワーム性能を特徴とするこの攻撃ではすでに187件のパッケージが侵害されており、その数は今後も増えていく恐れがあるという。

9月15日、SocketとStep Securityが最初にこの攻撃に関するレポート記事を公開。週あたりのダウンロード数220万超のパッケージ「@ctrl/tinycolor」に悪意あるアップデートが加えられていたこと、この感染はその他40以上のパッケージにも影響を与えているサプライチェーン攻撃の中で発生していたことなどが明かされていた。しかし16日、Aikido社はブログ記事の中で、さらに147件の侵害されたパッケージを観測したと報告。またこれらの感染パッケージの中には、セキュリティ大手CrowdStrikeのものも含まれると伝えた。

Socketによれば、侵害されたバージョンには関数「NpmModule.updatePackage」が含まれており、この関数によりパッケージのタールボールのダウンロード、package.jsonの修正、ローカルスクリプト「bundle.js」の注入、アーカイブのリパック、再公開が行われることで、「下流パッケージのトロイの木馬化」を自動化できるようになっているという。

副産物として pacakge-lock.json を jq でパースして目的のパッケージとバージョンがインストールされているか調べるスクリプトを作りました。

github.com

そのときに学んだ知見を吐き出します。

pacakge-lock.json

lockfileVersion

バージョンで構造が異なる。2025年10月現在は "lockfileVersion": 3

  1. v1: npm v5およびv6で使用されている。
  2. v2: npm v7およびv8で使用されている。v1形式と後方互換性がある。
  3. v3: npm v9以降で使用されている。npm v7との下位互換性がある。

npm v9 のリリースが 2022年なのでまだ3年しか経っていないので v2 以前もまだまだ普通に使われてそうですね。

https://github.blog/changelog/2022-10-24-npm-v9-0-0-released/

lodash を見るとまだ v1 ですね。

https://github.com/lodash/lodash/blob/main/package-lock.json#L4

v2 は、 v3 で必要な .packages キーにも依存パッケージ一覧の情報を冗長に保持しているので、今回の目的としては v2 もそれほど大きな違いはないようでした。

全体構造

以降では v3 を主に扱っていきます。例として gemini-cli の package-lock.json

https://github.com/google-gemini/gemini-cli/blob/main/package-lock.json

{
  "name": "@google/gemini-cli",
  "version": "0.9.0-nightly.20251001.163dba7e",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "@google/gemini-cli",
      "version": "0.9.0-nightly.20251001.163dba7e",
      "workspaces": [
        "packages/*"
      ],
      "dependencies": {
        "@testing-library/dom": "^10.4.1",
        "simple-git": "^3.28.0"
      },
      "bin": {
        "gemini": "bundle/gemini.js"
      },
      "devDependencies": {
        "@types/marked": "^5.0.2",
        "@types/mime-types": "^3.0.1",
        // 省略
        "vitest": "^3.2.4",
        "yargs": "^17.7.2"
      },
      "engines": {
        "node": ">=20.0.0"
      },
      "optionalDependencies": {
        "@lydell/node-pty": "1.1.0",
        "@lydell/node-pty-darwin-arm64": "1.1.0",
        "@lydell/node-pty-darwin-x64": "1.1.0",
        "@lydell/node-pty-linux-x64": "1.1.0",
        "@lydell/node-pty-win32-arm64": "1.1.0",
        "@lydell/node-pty-win32-x64": "1.1.0",
        "node-pty": "^1.0.0"
      }
    },
    "node_modules/@a2a-js/sdk": {
      "version": "0.3.2",
      "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.2.tgz",
      "integrity": "sha512-maqxdZ/xeuSRywObfBTvwXbXvkDMmKVkiY8K9rCHDwm0QYUJuu512GnNrwuxkKTwXpNyByzEPg3RYfBveRl96w==",
      "dependencies": {
        "uuid": "^11.1.0"
      },
      "engines": {
        "node": ">=18"
      },
      "peerDependencies": {
        "express": "^4.21.2"
      },
      "peerDependenciesMeta": {
        "express": {
          "optional": true
        }
      }
    },
// 省略

.packages

.packages にオブジェクトとして依存の一覧が書かれています。ツリー状ではなくほぼフラットです。

.packages.""

.packages オブジェクト内の空文字のキーは特殊なキーで直接的な依存パッケージ、つまり package.json に記述されたものが一覧で書かれます。

  • .packages."".dependencies
  • .packages."".devDependencies
  • .packages."".optionalDependencies

のように package.json での記述と同様のグルーピングがされています。

さて、インストールされているパッケージ名だけ一覧するならこんな感じ。

$ cat package-lock.json | jq '.packages | keys' | head 
[
  "",
  "node_modules/@a2a-js/sdk",
  "node_modules/@a2a-js/sdk/node_modules/uuid",
  "node_modules/@alcalzone/ansi-tokenize",
  "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles",
  "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point",
  "node_modules/@ampproject/remapping",
  "node_modules/@asamuzakjp/css-color",
  "node_modules/@azu/format-text",

インストールされた理由が間接依存だったりする場合には、この記述がより長くなります。 例えば string-width パッケージはいろんなところから依存されているので次のような結果に。

$ cat package-lock.json | jq '.packages | keys' | grep 'string-width"'
  "node_modules/@textlint/linter-formatter/node_modules/string-width",
  "node_modules/ansi-align/node_modules/string-width",
  "node_modules/cli-truncate/node_modules/string-width",
  "node_modules/cliui/node_modules/string-width",
  "node_modules/ink/node_modules/string-width",
  "node_modules/listr2/node_modules/string-width",
  "node_modules/string-width",
  "node_modules/table/node_modules/string-width",
  "node_modules/update-notifier/node_modules/string-width",
  "node_modules/wrap-ansi-cjs/node_modules/string-width",
  "node_modules/wrap-ansi/node_modules/string-width",
  "node_modules/yargs/node_modules/string-width",
  "packages/cli/node_modules/string-width",

.packages."" 以外の要素

.packages."" 以外のキーは "node_modules/@a2a-js/sdk" のように、パッケージ名になっており、バリューはオブジェクトで、詳細を保持しています。

例えば .packages."node_modules/@a2a-js/sdk".version には、インストールしているパッケージのバージョン が書かれています。

$ jq '.packages."node_modules/@a2a-js/sdk".version' package-lock.json
"0.3.2"

$ cat package-lock.json | jq '.packages.[] | .version' | head
"0.9.0-nightly.20251001.163dba7e"  # これはこの gemini-cli 自体のバージョン .packages."" に入っている。
"0.3.2"
"11.1.0"
"0.2.0"
"6.2.1"
"5.0.0"
"2.3.0"
"3.2.0"
"1.0.2"
"1.0.1"

ざっくり 〜〜 パッケージはどんなバージョンがインストールされているんだろう、というのは次のようになります。

$ cat package-lock.json | jq '.packages'| grep 'string-width": {' -A1
  "node_modules/@textlint/linter-formatter/node_modules/string-width": {
    "version": "4.2.3",
--
  "node_modules/ansi-align/node_modules/string-width": {
    "version": "4.2.3",
--
  "node_modules/cli-truncate/node_modules/string-width": {
    "version": "7.2.0",
--
  "node_modules/cliui/node_modules/string-width": {
    "version": "4.2.3",
--
  "node_modules/ink/node_modules/string-width": {
    "version": "7.2.0",
--
  "node_modules/listr2/node_modules/string-width": {
    "version": "8.1.0",
--
  "node_modules/string-width": {
    "version": "5.1.2",
--
  "node_modules/table/node_modules/string-width": {
    "version": "4.2.3",
--
  "node_modules/update-notifier/node_modules/string-width": {
    "version": "7.2.0",
--
  "node_modules/wrap-ansi-cjs/node_modules/string-width": {
    "version": "4.2.3",
--
  "node_modules/wrap-ansi/node_modules/string-width": {
    "version": "7.2.0",
--
  "node_modules/yargs/node_modules/string-width": {
    "version": "4.2.3",
--
  "packages/cli/node_modules/string-width": {
    "version": "7.2.0",

さらにこのバージョンで入っているかな、まで見たかったら雑に grep すればいいでしょう。

その他の要素

その他の要素についても公式のドキュメントに網羅的に書かれています。

docs.npmjs.com

隠しロックファイル node_modules/.package-lock.json

node_modulesフォルダの処理を重複して行わないために node_modules/.package-lock.json に格納された「隠し」ロックファイルが存在します。パッケージツリーに関する情報が含まれておりルートの package-lock.json の代わりに npm から適宜使用されます。

package-lock.json との diff を取ってみると大部分は同じ内容ですが、いくつか違いがあります。その中の1つに

ideallyInert

パッケージオブジェクトに"ideallyInert" という Boolean の要素が追加されている、というのがあります。

この値は「理想的にはインストール対象外になってほしい(inert=非アクティブ扱い)パッケージ」であることを示すフラグだそうです。ググってもあまり情報がヒットしないので LLM に聞きました。

背景

npm はクロスプラットフォームなパッケージ(例: esbuild, sharp, fsevents など)を扱うとき、 CPU アーキテクチャや OS によって 使わないバイナリが node_modules に現れることがあります。

例: @esbuild/linux-arm → Linux ARM 向けのビルド macOS/x64 の環境では 必要ない が、npm レジストリには公開されているので解決ツリーに含まれる。

こうした「optional かつ環境依存で不要になる可能性が高い依存」は lockfile の packages セクションに残るけど、 実際にはインストール・利用されないことが多い。

そこで npm が ideallyInert: true を付けて、 「このパッケージは理想的には inert(非活性、インストール不要)扱いにしてほしい」という印を残す。

とのこと。

まとめ

  • package-lock.json の構造はバージョンによって異なるが、バージョン間で互換性も一部ある。最新は v3 (2022年〜)。
  • インストールされたパッケージとバージョンの一覧は、フラットに保持されているのでパースして探しやすい。
  • node_modules/.package-lock.json という隠しファイルがある。

注意点

単純にインストールされているパッケージのバージョンを調べたいとか、どういう経緯でインストールされているか調べたい場合は

などが使えます。今回は勉強を兼ねて直接パースしてみました。

また、 yarn や pnpm はロックファイルの名前もファイル形式(yaml)も構造もまったく異なるのでまた別途勉強しようと思います。