本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

このブログ (Next.js + Cloudflare Workers) を運用しているリポジトリに Dependabot のアラートが 2 件届いていました。postcssesbuild の moderate 脆弱性で、どちらも transitive (孫依存) として残っていたものです。

postcss < 8.5.10  — XSS via Unescaped </style> (GHSA-qx2v-qp2m-jg93 / CVE-2026-41305)
esbuild <= 0.24.2 — dev server CORS leak (GHSA-67mh-4wv8-2f99)

「自動で塞いでほしい」のがそもそもの動機でしたが、調べていくうちに、Dependabot のような既知 CVE ベースの仕組みだけではサプライチェーン攻撃には弱いことが見えてきました。今回は「既知 CVE の自動対応」と「サプライチェーン攻撃への部分的な備え」を 1 セットでまとめます。

何を「自動対応」したいのかを切り分ける

仕組みを足す前に、想定する脅威を分けて考えたほうがよさそうでした。

種類検出のしくみ
既知の脆弱性postcss の XSS、Next.js の SSRFNVD / GHSA に登録済み。npm audit / Dependabot で見える
メンテナ乗っ取り → 悪意ある新版公開chalk/debug の npm アカウント侵害 (2025-09、qix アカウントのフィッシング侵害)、eslint-config-prettier (2025-07)、ua-parser-js (2021)公開後に検出され yank されるまでの数時間〜数日が攻撃の窓
Actions の改竄tj-actions/changed-files (2025-03) で世界中のリポジトリから secret 流出複数の mutable タグを単一の悪意 commit に force-push し直す形
長期潜伏型 backdoorxz-utils (2024-03 発覚、信頼構築に 2 年以上)通常の audit では検出できない
postinstall フックでの RCEnpm install の瞬間にコード実行スキャン困難。実行を止めるしかない

npm audit や Dependabot security update は 1 行目 に対しては強いものの、2〜5 行目には別レイヤーが必要です。今回入れた設定は、このうち 2 と 3 と 5 をある程度カバーする構成です。4 (xz-utils 型) には太刀打ちできません。

入れた設定

公式機能の組み合わせで、特殊なことはしていません。

1. transitive な脆弱性は npm overrides で pin

postcssesbuild も直接依存ではなく、next などが内部で引き連れていた孫依存でした。Dependabot は直接依存しかバージョン提案できないので、package.jsonoverrides で固定します。

{
  "overrides": {
    "postcss": ">=8.5.10",
    "esbuild": ">=0.25.0"
  }
}
npm install --package-lock-only --ignore-scripts
npm audit  # found 0 vulnerabilities

実際の node_modules/postcss は 8.5.14 に置き換わり、アラートは解消されました。

2. Dependabot に cooldown を入れる

最近のメンテナ乗っ取り型サプライチェーン攻撃は、公開後 数時間〜数日で検出され yank されるケースが多いように見えます。なので「公開直後の版は取り込まない」を入れます。

この目的では Renovate の minimumReleaseAge 設定が先行して提供されており、「サプライチェーン対策を入れるなら Renovate」と言われることが多かった印象です。Dependabot 側にも 2025-07 に cooldownGA として追加された ので、いまは Dependabot 1 本でもこの層の防御を組めます。

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/web"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    groups:
      minor-and-patch:
        update-types: ["minor", "patch"]
    cooldown:
      default-days: 5
      semver-major-days: 30
      semver-minor-days: 7
      semver-patch-days: 3

  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 5
      semver-major-days: 30
      semver-minor-days: 7
      semver-patch-days: 3

公式 docs によると cooldown は security update には適用されません。つまり「CVE が出たら即時、それ以外は数日待つ」の両立になります。

3. GitHub Actions は SHA で pin する

actions/checkout@v4 のようなタグは可変で、メンテナがいつでも別の commit に貼り直せます。tj-actions/changed-files の事件 (2025-03、CVE-2025-30066) は、攻撃者が v1 から v45 付近までの複数の既存タグを 単一の悪意ある commit に force-push し直し、その commit が CI runner のメモリから secret を抽出してワークフローログに base64 で書き出すようにした実例として知られています。流出経路は外部サーバではなく公開ワークフローログそのものでした。

防御は GitHub 公式の Security hardening for GitHub Actions でも推奨されている通り「不変な commit SHA で固定する」だけです。

# NG: 可変タグ
- uses: actions/checkout@v4

# OK: 不変な SHA + 人間用コメント
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

タグから SHA を引くには git ls-remote が手堅いです。

git ls-remote --tags https://github.com/actions/checkout.git refs/tags/v4
# 34e114876b0b11c390a56381ad16ebd13914f8d5	refs/tags/v4

実リポジトリでは sed で一括置換しました。

cd .github/workflows && for f in *.yml; do
  sed -i.bak \
    -e 's|uses: actions/checkout@v4|uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4|g' \
    -e 's|uses: actions/setup-node@v4|uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4|g' \
    -e 's|uses: actions/setup-python@v5|uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5|g' \
    "$f"
  rm "$f.bak"
done

コメントの # v4 は人間が読むためのもので、Dependabot もここを読んで「v4 の最新 SHA」へと SHA とコメント両方を書き換えてくれます。なので SHA pin にすると更新追従ができなくなるわけではありません。GitHub 公式の actions/* org も対象にしました (公式 org でも乗っ取りリスクはゼロではないため)。

4. security update だけ auto-merge する

cooldown を入れた上で、security advisory が紐づいた PR だけ自動マージします。minor/patch の通常更新はそのまま手動レビューに残します。

判定方法は、最初は dependabot/fetch-metadata アクションの ghsa-id output を使うつもりでしたが、調べてみるとこの output は alert-lookup: true を指定したうえで PAT (Personal Access Token) を github-token に渡さないと populate されない仕様でした。デフォルトの GITHUB_TOKEN だと常に空文字になり、条件が false で全 PR が skip されてしまいます。

代わりに、Dependabot が security update PR に自動で付与する security ラベルで判定するほうがシンプルでした。ラベル判定なら追加トークンは不要です。

# .github/workflows/dependabot-auto-merge.yml
name: Dependabot auto-merge
on: pull_request
permissions:
  contents: write
  pull-requests: write
jobs:
  auto-merge:
    if: >-
      github.actor == 'dependabot[bot]' &&
      contains(github.event.pull_request.labels.*.name, 'security')
    runs-on: ubuntu-latest
    steps:
      - env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_URL: ${{ github.event.pull_request.html_url }}
        run: |
          gh pr review --approve "$PR_URL" -b "Auto-approving Dependabot security update."
          gh pr merge --auto --squash "$PR_URL"

auto-merge を有効にするには、リポジトリ側のフラグも必要です。gh から非対話で叩けました。後者は Enable Dependabot security updates のエンドポイントです。

gh api -X PATCH repos/:owner/:repo \
  -f allow_auto_merge=true \
  -f delete_branch_on_merge=true

gh api -X PUT repos/:owner/:repo/automated-security-fixes

auto-merge は CI 通過がゲートになるので、PR 用の最小 CI workflow も同時に置きました (npm ci + npm audit --audit-level=high + npm run build だけ)。なお、auto-merge の安全性は CI で何を検査しているか に依存します。今回のように静的なブログで build が通れば概ね大丈夫な構成ならこれで足りますが、複雑な機能を持つアプリで auto-merge を入れる場合は、E2E や integration test までカバーしておかないと「脆弱性は塞げたが機能はデグレ」のリスクが上がるはずです。

5. ignore-scripts=true で postinstall を止める

サプライチェーン攻撃の中で実害が大きいのが、postinstall 等のインストール時フックでの任意コード実行です。web/.npmrc に 1 行入れました。

ignore-scripts=true

ただ、これを有効にすると native binary を持つパッケージが壊れる印象があったので、念のため別ディレクトリで npm ci --ignore-scripts を実行して挙動を確認しました。

  • esbuildpostinstall hook 自体は持つものの、本体バイナリは @esbuild/darwin-arm64 等 26 プラットフォーム分の optionalDependencies 経由で配布されるため動作 OK
  • sharp@img/sharp-darwin-arm64 等 14 プラットフォーム + @img/sharp-libvips-* を optionalDependencies として取るため動作 OK
  • workerd (Cloudflare Workers ランタイム) — こちらも postinstall hook を持つが、バイナリは @cloudflare/workerd-darwin-arm64 等の optionalDependencies 経由で入るため動作 OK

postinstall 自体は残っていますが、配布の本筋が optional dep に移ったので ignore-scripts 下でも build が通る、というのが実情でした。

ただし、これは今回のスタックがたまたま optional dep への移行が進んでいる側に倒れていただけで、スタック次第ではこの設定で build が壊れます。たとえば prisma のように postinstallprisma generate を走らせて型を生成する構成や、native 拡張をローカルでビルドするライブラリを使っている場合は、ignore-scripts=true だけだと型や binary が生成されずに死にます。その場合は npm rebuild <pkg>npx prisma generate を build スクリプト側で明示的に呼び直すなど、必要なフックだけ opt-in する設計が必要になります。導入する前に対象リポジトリで一度 npm ci --ignore-scripts && npm run build を試して確かめるのが安全です。

何が守れて、何が守れないか

正直に整理しておきます。

シナリオ今回の構成で守れる?
既知 CVE (今回の postcss/esbuild など)守れる (npm audit + auto-merge)
公開後 数時間〜数日で yank されるアカウント侵害型守れることが多い (cooldown 5 日)
tj-actions/changed-files 型 (Actions の mutable tag 改竄)守れる (SHA pin)
postinstall フックでの RCE守れる (ignore-scripts)
長期潜伏型 backdoor (xz-utils 型)守れない。cooldown 30 日でも通る
ゼロデイ脆弱性守れない (登録される前は誰も知らない)
dev マシンで npm install した瞬間に侵害される経路一部のみ (ローカルにも .npmrc は効く)
typosquatting (reactdom のような偽パッケージ)守れない (人間レビュー頼り)
GitHub Actions の permissions: 最小化不足による secret 流出今回は手をつけていません

xz-utils 型 (悪意ある maintainer が 2 年以上かけて信頼を得てから差し込んだ backdoor) のような攻撃には、今回の構成では対応できません。ここに踏み込むには socket.dev のような事前スキャンや、OpenSSF Scorecard ベースの依存先評価といった別レイヤーが要りそうです。

残しているもの

次に手をつけるとしたら、このあたりかと考えています。

  • 各 workflow の permissions: を job 単位で最小化する (現状はデフォルト)
  • socket.dev の GitHub App を入れて PR 時にスコアを表示
  • gitleaks の pre-commit hook (secret コミット防御)
  • ローカル開発マシン側の .npmrc グローバル設定にも ignore-scripts=true を入れる検討

今回入れた構成は「個人運用の小さなリポジトリでも、追加コストほぼゼロで足せる範囲」に収めたつもりです。すべての攻撃を防げる構成ではないですが、現状の Dependabot だけよりは攻撃面が狭まったと考えています。

関連記事 (日本語)

本記事の個別トピックについては、より深く扱った日本語の先行記事が複数あります。交差して読むと内容の裏取りになります。

参考 (一次情報)