npm の pacakge-lock.json の仕様
Shai-Hulud サプライチェーン攻撃
npm に対する大規模なサプライチェーン攻撃「Shai-Hulud」について調査するなかで pacakge-lock.json の仕様について調べました。
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 でパースして目的のパッケージとバージョンがインストールされているか調べるスクリプトを作りました。
そのときに学んだ知見を吐き出します。
pacakge-lock.json
lockfileVersion
バージョンで構造が異なる。2025年10月現在は "lockfileVersion": 3 。
v1: npm v5およびv6で使用されている。v2: npm v7およびv8で使用されている。v1形式と後方互換性がある。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 すればいいでしょう。
その他の要素
その他の要素についても公式のドキュメントに網羅的に書かれています。
隠しロックファイル 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 という隠しファイルがある。
注意点
単純にインストールされているパッケージのバージョンを調べたいとか、どういう経緯でインストールされているか調べたい場合は
npm lshttps://docs.npmjs.com/cli/v6/commands/npm-ls サブコマンドnpm-whyhttps://github.com/amio/npm-why パッケージ
などが使えます。今回は勉強を兼ねて直接パースしてみました。
また、 yarn や pnpm はロックファイルの名前もファイル形式(yaml)も構造もまったく異なるのでまた別途勉強しようと思います。
Javascript の Symbol
symbol はユニークであることが保証された値を動的に生成できる。
JavaScript の環境では Symbol 関数を使って生成される。
Symbol("ほげ") === Symbol("ほげ") //=> false "ほげ" === "ほげ" //=> true
symbol は ECMAScript 2015 (ES6) で導入された。そのため ECMAScript 5 には存在しない。
symbol は Primitive (プリミティブ、Primitive Value, Primitive Data Type とも)である。
- Primitive であるということは、オブジェクトではなくメソッドを持たず、そして不変(immutable)であるということ。
- Javascript では Primitive は symbol の他に string, number, bigint, boolean, undefined, null があり あわせて7つのみが存在する。
symbol は文字列に自動変換されない
const Sym = Symbol("ほげ"); console.log(Sym); //=> Symbol(ほげ) alert(Sym); //=> Uncaught TypeError: Cannot convert a Symbol value to a string
これは混合しないための「言語ガード」で、これは文字列とシンボルが根本的に異なるため、そして他の型に変換するべきものではないためです。
しかし toString メソッド自体は普通に使える。また、 description プロパティから作成時の説明文を取り出せる。
const Sym = Symbol("ほげ"); console.log(Sym.toString()); //=> Symbol(ほげ) console.log(Sym.description); //=> ほげ
well-known symbol と呼ばれる定数が存在する。これらは、言語機能が個々のオブジェクトをどのように扱えるかを示すフラグのような役目を果たすようで、プロパティキーとして使用されている。
例えば @@iterator という名前(この名前は ECMAScript 上の名前) well-known symbol は Symbol.iterator というかたちで定数として定義されており、次のような意味を持っている。
A method that returns the default Iterator for an object. Called by the semantics of the for-of statement.
オブジェクトのデフォルトの Iterator を返すメソッド。 for-of ステートメントのセマンティクスによって呼び出されます。
console.log(Symbol.iterator.description);
//=> Symbol.iterator
例えば、イテラブルな代表的なオブジェクトである配列が実際にこのプロパティ値を持つことは次のように確認できる。
[1,2,3][Symbol.iterator] //=> ƒ values() { [native code] } // Symbol.iterator はシンボル値なので、当然そのその文字列値でそのプロパティを参照することはできない。 console.log(Symbol.iterator.description); //=> Symbol.iterator [1,2,3]['Symbol.iterator'] //=> undefined
for-of などでイテレーション可能なオブジェクトを定義しようと思ったら、そのオブジェクトに対してこのプロパティの関数を適宜実装すればいい。これは面白そうなので今度やってみたい。
well-known symbol の一覧は仕様書に記載されている。searchable であることを示す Symbol.search など Javascript の基本的な言語機能や API に対応するシンボルがたくさん見れる。
ECMAScript® 2025 Language Specification
もともと古いこの動画を見ていて React コンポーネント(初期のクラスコンポーネントのみなのかも)が $$typeof というプロパティキーにシンボル値をもたせることでそれが React Element なのかを判断する、という機構を持っていた話が解説されていたことから調べたが面白かった。ちなみにこの動画記事では ClojureScript 側から Javascript の Symbol を extends-type することで Printable にしている。もともとの思想と衝突している気もするが、これができてしまうこと自体と利用用途は面白い。
参考
