セキュリティ 2026年7月15日

npm audit の high 9件に毎回身構えるのをやめて、静的サイトの依存脆弱性にトリアージ基準を作った話

XECIN npmサプライチェーン依存関係技術検証

先週、コーポレートサイトの記事を追加してリリースしようとしたときのことです。習慣で npm audit を叩いたら、high が9件、moderate が12件、合計23件。

正直なところ、毎回ここで手が止まっていました。

記事を1本足しただけのリリースです。コードは1行も触っていない。それでも「high が9件出ているものをこのまま本番に出していいのか」と言われると、胸を張って「いい」とは言えない。かといって全部追いかけると、記事1本の公開に半日かかってしまう。

この「毎回身構えるけど、結局何もしないで出す」という状態が一番よくないと考えていました。運用を考えると、判断を毎回その場の気分でやっている限り、いつか本当に危ないものを同じ気分で見送ることになります。

なので、基準を作ることにしました。


まず、何件あるのかを正確に見た

最初にやったのは、感覚で「多い」と言うのをやめて、npm audit --json | jq '.metadata' で数字を出すことでした。結果はこうです。

  • 脆弱性: 合計23件(critical 0 / high 9 / moderate 12 / low 2)
  • 依存パッケージ: 合計841(うち直接依存は26)

ここで一つ目の気づきがありました。私たちが package.json に自分で書いたのは26個です。それが841個に膨らんでいる。23件の指摘のうち、直接依存に紐づくのは astro / @astrojs/rss / js-yaml の3つだけで、残り20件は「私たちが選んだ覚えのないパッケージ」の話でした。

追いかける気力が湧かないのは、当然といえば当然だったんですよね。


--omit=dev で減ると思ったら、1件も減らなかった

次にやったのが、「本番に関係あるものだけ見ればいいはずだ」という発想でした。npm には開発用の依存を除外するオプションがあります。

# 本番向けの依存だけを対象にしたつもりだった
npm audit --omit=dev --json | jq '.metadata.vulnerabilities'

期待としては、ここでゴソッと減るはずでした。Playwright やら型定義やらは開発時にしか使わないので、本番向けに絞れば残るのは数件だろう、と。

結果は 23件のまま。1件も減りませんでした。

今思えば当たり前なのですが、これは私が npm の区分を誤解していたからです。dependenciesdevDependencies の境目は「本番で動くかどうか」ではなく「そのパッケージをインストールする必要があるか」でしかありません。

そして Astro のような静的サイトジェネレータは、dependencies 側に入ります。astro 本体も、その下にいる viteesbuild も、全部 dependencies の配下です。実際にはこれらはビルドが終わった瞬間に役目を終えるのに、npm から見れば立派な本番依存なんですよね。

つまり --omit=dev は、私が知りたかった「本番で悪用されうるか」という問いには、そもそも答えてくれない。ここが分かって、ようやく話が進みました。


自分たちの配信形態から「到達性」を定義し直した

判断基準がないなら、自分たちの構成から作るしかありません。

このサイトの構成は単純です。GitHub Actions 上で npm ci してビルドし、出来上がった dist/(HTML・CSS・JS・画像)を S3 に同期して、CloudFront から配信する。それだけです。

ここで重要なのは、本番環境に Node.js のランタイムが存在しない ことです。S3 にあるのは静的ファイルだけで、node_modules は1バイトも置いていません。だから「本番サーバー上で脆弱な npm パッケージが実行される」という事故は、構造的に起こりません。

ただし、これを「じゃあ全部無視していい」に短絡させると間違えます。本番に届く経路は、実は3つに分かれていました。

分類どこで動くか本番への到達経路対応
A: 出力に載る/出力を汚しうるビルド時。ただし成果物が配信される生成されたHTML/JSが訪問者のブラウザで動く即対応
B: CI上で実行されるGitHub Actions のジョブ内出力には載らないが、認証情報と同じプロセスにいる供給網リスクとして別枠で管理
C: 手元でしか動かない開発サーバー・エディタ支援なし定期更新でまとめて

この表を作った時点で、頭の中がだいぶ整理されました。severity は「その脆弱性が悪用されたときどれくらい酷いか」しか語っていなくて、「うちの構成でそこに手が届くのか」は何も語っていない。両方を掛け合わせないと優先度にならないんですよね。


23件を実際に振り分けてみた

基準ができたので、23件を機械的に流し込みました。

まず A に入るものを探します。生成物に影響しうるのは、Astro 本体に出ていた「define:vars の不完全なサニタイズによる XSS」の1件でした。ここで「うちは define:vars なんて使ってないだろう」と思いながら念のため確認したところ、これが外れました。

grep -rn "define:vars" src/
# → 7箇所ヒット。うち2箇所は <script is:inline define:vars={{ contactEndpoint, contactApiKey }}>

問い合わせフォームと LP で、エンドポイントと API キーをインラインスクリプトに渡すのに使っていました。到達性は「ある」です。

もっとも、ここに流し込んでいる値は環境変数と自分たちのリポジトリ由来で、訪問者が入力した文字列ではありません。なので実際に踏む確率は低い。ただ「低い」と「経路がない」は別なので、これは A に置いて Astro を上げる、で決着させました。

次に見つかったのが、想定していなかった方向の指摘です。high 2件(fast-xml-parserfast-xml-builder)が @astrojs/rss からぶら下がっていました。それで RSS の実装を見に行ったのですが——

grep -rn "astrojs/rss" src/
# → ヒット0件。src/pages/ に rss.xml も無い。

使っていませんでした。 package.json に宣言だけ残っていて、どこからも import されていない。RSS を配信していないのだから、当然この2件は生成物のどこにも載りません。

到達性で並べ替えると、こういうものが炙り出されるのは発見でした。脆弱性を「直す」のではなく「そもそも要らない依存を消す」で消える指摘が、high の中に2件あったわけです(ただし package.json の変更は今回のリリース範囲外なので、別チケットに切りました。記事1本のPRで依存を触るのは、それはそれで筋が悪いので)。

残りは B と C です。esbuild の「開発サーバー起動時に任意ファイルを読まれる(Windows)」、vite の dev 時のパストラバーサル、@astrojs/language-serveryaml-language-server あたりは、全部 C。本番にもCIにも出てきません。

振り分けた結果がこうなりました。

分類件数中身
A(即対応)1件astro の define:vars XSS
要らない依存の撤去で消える3件@astrojs/rss とその配下2件
B / C(定期更新)19件ビルド時・開発サーバー・エディタ支援

「high が9件」で毎回止まっていたものが、「今すぐ判断が要るのは1件、掃除で3件」に変わりました。作業時間でいうと、リリースのたびに15分ためらっていたのが、月1回30分のトリアージに寄った形です。


ここが一番大事なところ:B を「安全」と読み替えない

ただ、この整理には落とし穴があります。私が最初に危うく書きかけた結論は「本番にランタイムが無いから、ビルド時依存は放っておいてよい」でした。

これは間違いです。

うちのデプロイはこうなっています。

# .github/workflows/deploy.yml(抜粋)
- name: Install & Build
  run: |
    npm ci
    npm run build
  env:
    PUBLIC_CONTACT_API_KEY: ${{ secrets.PUBLIC_CONTACT_API_KEY }}

- name: Deploy HTML(キャッシュなし)
  run: aws s3 sync ./dist s3://${{ secrets.PROD_BUCKET }}/ --delete ...
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

841個のパッケージは、この同じワークフローの中でコードを実行します。そして同じワークフローには、本番バケットに書き込める AWS の認証情報がいる。

つまりビルド時依存は「訪問者から到達できない」だけであって、「攻撃価値が低い」わけではまったくない。むしろ CI は、認証情報とビルド成果物の両方に触れる、かなり美味しい場所です。仮にどれか1つのパッケージが乗っ取られて postinstall で悪意あるコードを走らせたら、dist/ に何を混ぜることもできるし、鍵を持ち出すこともできます。

しかも厄介なことに、それは npm audit では検出できません。audit は既知の CVE とバージョンを照合しているだけで、「このパッケージは今日から悪いことをするようになりました」は分からない。参照した記事でも共通して指摘されていた点ですが、実際に自分の構成に当てはめると重みが違いました。

なので結論は「B は無視してよい」ではなく、「B は audit の件数で管理するものではない」です。効くのは別の層でした。

  • npm ci でロックファイルどおりに固定する(npm install をCIで使わない)
  • 依存の追加を安易にやらない。使っていない @astrojs/rss が2件連れてきていたのが良い例です
  • 更新はリリースのたびではなく、月1回まとめて意識的にやる
# .npmrc — 検討中の設定
# インストール時の任意スクリプト実行を止める(供給網攻撃の主要な入口を塞ぐ)
ignore-scripts=true
audit-level=high

ただしこれはまだ入れられていませんignore-scripts=true にすると sharp のようにインストール時のビルドが必要なパッケージが動かなくなるので、npm rebuild sharp のようなホワイトリストをCIに足す必要があります。そこまでやり切れていないので、現状は「読んだ、効くのは分かった、まだ入れていない」という状態です。ここは正直に書いておきます。改善の余地しかありません。

コストの話をすると、商用のSCAツール(Snyk や Socket.dev など)を入れれば、到達性の判定や不審な挙動の検知はもっと精度が上がります。ただ開発者単位の課金なので、うちの規模だと「月1回30分のトリアージ」と天秤にかけて、今はまだ手動側が勝っています。依存の数か、触る人数が増えたら、そこが逆転するタイミングだと考えています。


振り返って

やったことは、実のところ大したことではありません。「npm audit の件数」を見るのをやめて、「うちの配信形態でどこまで届くか」を見るようにしただけです。

でも、これで毎回の逡巡が消えました。基準がある状態でスキップするのと、なんとなくスキップするのは、同じ行動でも意味がまったく違うんですよね。

--omit=dev が1件も減らさなかったのは、今回一番の収穫でした。ツールが用意している区分が、自分の知りたい問いと一致しているとは限らない。ドキュメントにはこう書いてあるけど、うちの構成ではこう、という翻訳を挟まないと、ツールの出力はただのノイズになります。

今後は、A分類の判定——つまり「生成物に載る依存かどうか」——をCIで機械的に出せるようにしたいと考えています。今は私が grep で確かめていますが、これは人が変わったら再現しません。あとは @astrojs/rss のような使っていない依存の棚卸しを、定期更新のタイミングに組み込むところまでは、近いうちにやるつもりです。