TL;DR
- 筆者の公開先リポジトリは、フレームワークの非互換で本番が落ちたあと、patch を含む全自動更新を意図的に止めて 142 日運用しました(2026-04-17 → 2026-09-06、
社内データ)。この記事はその区間の記録です。 update-typesを付けたignoreはセキュリティ更新を止めません(GitHub 公式ドキュメントに明記)。ignore を入れた 26 日後に自動 PR が pin を上書きし、本番デプロイが再び失敗しました(社内データ)。設定側の正解はupdate-typesを外すことでしたが、筆者はそこへ行かずガードを足す方向へ進みました。- 止めた先が「もう更新されない系列」でした。 pin していた
[email protected]は16.1.xの最終リリースで、16.1.8以降は存在しません。影響する advisory はこの区間だけで 23 件(critical 0 / high 12)ありましたが、この系列に修正が来る見込みは pin した時点で無かったことになります。一方15.5.xには 25 リリースが出ており、影響する advisory 25 件のうち 24 件はそちらで修正されていました。ベンダーの backport 方針ではなく、pin 先の選び方の問題です。 - 上書きを止めるために CI ガードを入れましたが、そのガードは bot の PR を除外するジョブの中にあり、守るべき相手の PR では 113 日間一度も動きませんでした(
社内データ)。補償コントロールは「置いたこと」ではなく「発火したこと」を測らないと意味がありません。 - 第 7 節に、pin したバージョンの露出を数える
pin-exposure.mjsを置きました。依存に無い / 範囲指定 / パッケージ名 typo / 未公開バージョン / API 失敗 / 壊れたレスポンスはすべて非 0 で落ちます。使い捨てリポジトリで 22 系統を実際に走らせた結果を表にしてあります。 - 本記事が扱うのは解決済みの区間だけです。運用中の現在の pin と現在の露出は扱いません。
(2026-09-10 追記) 上の「運用中の現在の pin と現在の露出は扱いません」は第 1 節から第 8 節までの範囲についての宣言です。第 9 節はそれに加えて、2026-09-10 に観測した挙動の実測値だけを扱います(現在の pin と現在の露出はこの節でも扱いません)。要点は、pin を前進させた構成でトップページだけが 500 を返し、いったん pin を戻して復旧させた、という 1 点です。「pin を維持する側の補償」だけを論じていた本記事に、解除側のコストが加わりました。
はじめに:止める判断は、たいてい事故のあとに来る
依存の自動更新を止める話は、行儀が悪く聞こえます。実際には、止める判断はセキュリティを軽んじたからではなく、更新して本番が落ちたから発生します。そして落ちたあとに残るのは「止めた」という一行の設定ではなく、「止めているあいだ、何で穴を埋めるのか」という宿題のほうです。
この記事は一般論ではありません。2026-09-10 時点で、筆者の公開先リポジトリ(Next.js + Cloudflare Workers 構成)の履歴と設定を実際に読んで数えた記録です。基準となるコミットは公開先リポジトリの 03ccd80d(2026-09-09)で、advisory の集計は 2026-09-10 に取得した値です。攻撃手法には触れません。扱うのは防御・検知・運用設計だけです。
同型の記事として Node.jsセキュリティリリースに48時間で備える があります。あちらが「告知が来てから 48 時間で上げる」側の話であるのに対し、本記事は上げられない/上げないと決めたときの話です。ガードそのものが効いているかを疑う視点は 効いていないガードを見つける7つの型 と共通しています。

1. 測定条件と基準時点
pin の状態は時間で変わります。いつ時点の話かを先に固定します。
| 項目 | 値 |
|---|---|
| 対象 | 筆者の公開先リポジトリ(非公開。Next.js + @opennextjs/cloudflare + Cloudflare Pages) |
| 基準コミット | 03ccd80d(origin/develop、2026-09-09) |
| 履歴の測定方法 | git log origin/develop -G'"next": "16' -- apps/web/package.json と各コミットの git show |
| advisory の測定方法 | GitHub Advisory Database REST API(GET /advisories?ecosystem=npm&affects=next)を全ページ取得 |
| advisory の取得日 | 2026-09-10 |
| 測定対象バージョン | [email protected](2026-09-06 に pin を前進させたため、すでに稼働していない) |
以降 社内データ と付した値は、すべてこの条件で筆者が実測したものです。advisory の件数は日々増えるので、同じコマンドを後日走らせても同じ数字にはなりません。 数える手順のほうを持ち帰ってください。
2. 142 日の時系列
git log で追える範囲を並べます。日付はコミット日です。
| 日付 | 出来事 |
|---|---|
| 2026-04-13 | 別系統の不具合を受けて next を 16.1.7 に exact pin(PR #268) |
| 2026-04-15 | Dependabot のグループ PR が pin を上書きして bump(PR #270)。ignore 設定は無かった |
| 2026-04-16 | 本番トップが HTTP 500 を返し続けていることを検知。P0。ランタイムログで原因を特定し、同日中に再 pin |
| 2026-04-17 | 16.1.7 に再 pin し、dependabot.yml に next の major / minor / patch すべての ignore を追加(PR #327) |
| 2026-05-11 | フレームワーク側で 13 件の advisory が公開される |
| 2026-05-13 | ignore があるにもかかわらず Dependabot PR が 16.2.6 へ bump して merge され、本番デプロイが失敗(PR #603) |
| 2026-05-15 | 再 pin + hotfix リリース。あわせて pin を CI で強制するガード verify-next-pin を追加(PR #626) |
| 2026-07-22 | さらに 9 件の advisory が公開される |
| 2026-09-05 | Dependabot PR が 16.3.0 へ bump(PR #982)。PR 側の CI は緑のまま merge され、merge 後に develop の CI が落ちて初めて露見 |
| 2026-09-06 | pin を前進させ、ガードを bot の PR でも走る独立ジョブへ切り出した(PR #1003) |
止めていた期間は 2026-04-17 から 2026-09-06 までの 142 日、advisory が公開されてからの区間で見ると 118 日です(社内データ)。
3. update-types を付けた ignore は、セキュリティ更新を止めない
2026-04-17 の対策は、一見すると隙のない書き方でした。
# .github/dependabot.yml(抜粋)
ignore:
- dependency-name: "next"
update-types:
- version-update:semver-major
- version-update:semver-minor
- version-update:semver-patch
update-types を 3 つとも並べているので、通常のバージョン更新はこれで来なくなります。それでも 26 日後に bump が入りました。原因は、上書きした PR が通常のバージョン更新ではなくセキュリティ更新だったことです。
問題は ignore そのものではなく、update-types を書いたことでした。GitHub のドキュメントはこう明記しています(いずれも 2026-09-10 取得)。
ignoreは「version updates and security updates」の両方に対して依存を無視させられる(Controlling dependencies updated by Dependabot)- ただし
update-typesonly affects version updates, not security updates(同ページ / Dependabot options reference)
つまり update-types を付けた瞬間に、その ignore は「通常更新だけを止める」宣言に変わります。セキュリティ更新も止めたいなら、update-types を書かない形にする必要がありました。
# セキュリティ更新も含めて止めたい場合(update-types を書かない)
ignore:
- dependency-name: "next"
本件では、素通しされた更新が来たのは advisory 公開の 2 日後で、pin を最も守りたいタイミングと重なりました(一般則としてそうなると言えるだけの観測は本記事にはありません)。設計としては、こう読み替えるのが正確です。
| 置いたもの | 実際に守るもの | 守らないもの |
|---|---|---|
ignore + update-types | 通常のバージョン更新 PR の発生 | セキュリティ更新経由の bump |
ignore(update-types なし) | 通常更新とセキュリティ更新の両方 | 人手で書き換えられた pin 行 |
package.json の exact pin | 解決されるバージョン | pin 行そのものの書き換え |
| lockfile | 再現性 | 上と同じく、書き換えられたら追随する |
設定側の正解は update-types を外すことでした。 筆者はそこへ行かず、次節のように CI ガードを足す方向へ進みました。設定で塞げるものを設定で塞がないと、ガードの数だけが増えていきます。とはいえ pin 行は人手でも書き換わるので、ignore を直したうえで pin 行そのものを守る層も要る、というのが結論です。
4. 止めた先が「もう更新されない系列」だった
止めているあいだの露出を数えます。ここが本記事で一番効いた実測です。
[email protected] に影響する advisory を GitHub Advisory Database から全件取得し、影響範囲に入るものを数えました(2026-09-10 取得)。pin していた区間(2026-04-17 〜 2026-09-06)の内と外で分けます。
| 指標 | 区間内 | 区間外 | 合計 |
|---|---|---|---|
16.1.7 が影響範囲に入る advisory | 23 | 2 | 25 |
| critical | 0 | 2 | 2 |
| high | 12 | 0 | 12 |
| medium | 9 | 0 | 9 |
| low | 2 | 0 | 2 |
next の advisory は全件で 66 件、うち 16.1.7 に影響するものが 25 件です(社内データ)。公開日の内訳は 2026-04-10 が 1 件、2026-05-11 が 13 件、2026-07-22 が 9 件、2026-09-08 が 2 件。pin 開始時点ですでに 1 件あり、区間中に 22 件が積み上がって 23 件になりました。区間外の 2 件(critical)は pin を前進させたあとに公開されたもので、この記事の対象ではありません。
なぜ patch が来なかったのか
止めていた 23 件について、修正版が 16.1.x の中にあるものは 0 件でした。ただしこれを「ベンダーが backport しない」の証拠にしてはいけません。npm レジストリを引くと理由がはっきりします(2026-09-10 取得)。
| 系列 | 公開されている安定版の数 | 最終 |
|---|---|---|
16.0.x | 12 | 16.0.11 |
16.1.x | 8 | 16.1.7 |
16.2.x | 13 | 16.2.12 |
16.3.x | 5 | 16.3.4 |
15.5.x | 25 | 15.5.25 |
16.1.x は 16.1.7 で終わっています。 16.1.8 以降は 1 つも存在しません。つまり「16.1.x 内の修正版が 0 件」は、測定というよりこの系列がもう更新されていないことの言い換えです。数えるまでもなく 0 になります。
一方で、ベンダーは backport をしていないわけではありませんでした。影響する 25 件のうち 24 件は 15.5.x にも修正版があります(15.5.16 / 15.5.21 / 15.5.24 など。残る 1 件は 15 系が影響範囲に入っていない)。15.5.x は 25 リリースが出ており、いまも生きている系列です。
事実を並べ直すとこうなります。
- ベンダーは、生きている系列(当時の最新 minor と、ひとつ前の major の維持系列)には修正を配っていた
- 筆者が pin した
16.1.7は、そのどちらでもない中間の minor だった - したがって pin した瞬間から、この系列に修正が来る見込みは無かった
「patch だけは追う」という折衷案が成立しなかったのは、ベンダーの方針ではなくpin 先の選び方の問題です。pin を決める前に確認すべきはこうなります。
- pin 先の系列がまだ生きているか。 直近数か月に後続 patch が出続けているかをレジストリで見る
- 出ていないなら、その系列はもう修正が来ない。pin は「patch を待つ」ではなく「修正を受け取らない」という意味になる
- 維持されている系列(LTS 相当、あるいは最新 minor)に pin 先を寄せられないかを先に検討する
2 番目に到達したら、pin は延命ではなく期限つきの負債です。期限を決めずに置くと、この記事のように 142 日になります。
5. 補償コントロールの棚卸し(と、それぞれが埋めていない穴)
pin していた 142 日のあいだ、リポジトリに実在した補償コントロールは 4 つでした。効能を過大評価しないため、何を埋めていないかを併記します。
| # | コントロール | 実体 | 埋めるもの | 埋めないもの |
|---|---|---|---|---|
| 1 | 推移的依存のセキュリティ強制 | pnpm-workspace.yaml の overrides 20 エントリ(社内データ) | 間接依存に対する既知脆弱性の一括引き上げ | 直接依存であるフレームワーク本体 |
| 2 | 露出面の縮小 | Cloudflare Pages の _routes.json で静的アセットを Worker 経由から除外 | Worker 実行経路に載るリクエストの範囲 | Worker を通るルートの脆弱性そのもの |
| 3 | デプロイ後の外形検査 | smoke-test-production.mjs(公開記事 3 本 + 未公開記事 4 本の 404 + 固定 5 URL) | 「上げたら落ちた」の検知遅れ | 落ちる前に止めること |
| 4 | 更新のまとめ方 | Dependabot の grouped bump(minor/patch を 1 PR に束ね、major は個別) | レビュー可能な粒度と CI コスト | pin 対象そのもの |
この表を書いて初めて分かったのは、4 つとも「フレームワーク本体の既知脆弱性」を埋めていないことです。埋めているのは、その周辺(推移的依存・露出面・検知)だけです。補償コントロールという言葉は、埋めた気にさせる力が強いので、対象を明示した表の形で持つほうが安全です。
穴が埋まらないなら、残る手は 2 つしかありません。期限を切って上げるか、上げられない理由(この例では実行環境との非互換)を先に解消するかです。この構図は AIコーディングのセキュリティ設計 で扱った 4 層モデルの、いちばん外側と同じ形をしています。
6. ガード自身が発火していなかった 113 日
pin 行を守る層として 2026-05-15 に入れたのが verify-next-pin です。apps/web/package.json の next が許可 pin と一致しなければ非 0 で落ちる、43 行のスクリプトです。判定ロジックは単純で、これ自体は正しく動きます。
問題は配線でした。このスクリプトは lint-and-build ジョブの中に置かれており、そのジョブには次の条件が付いていました。
if: ${{ !endsWith(github.event.pull_request.user.login, '[bot]') }}
bot の PR ではジョブごとスキップされます。 つまり、Dependabot の自動 bump を止めるために作ったガードが、Dependabot の PR では一度も動きません。2026-09-05 の PR #982 が CI 緑のまま merge され、merge 後の develop で初めて赤くなったのはこのためです。ガード導入から露見まで 113 日(社内データ)。
修正は、pin 検証を bot 除外条件を持たない独立ジョブへ切り出すことでした(PR #1003)。ここから引ける教訓は 1 つです。
補償コントロールは「置いたか」ではなく「守る相手の入力で発火したか」で測る。
置いたことの確認は設定ファイルを読めば済みますが、発火の確認には違反する入力を実際に与える必要があります。この型の網羅的な分類は 効いていないガードを見つける7つの型 にあります。本件はそのうち「守る対象が検査対象から除外されている」型に当たります。
7. pin の露出を数えるスクリプト
第 4 節の数え方をそのまま渡せる形にしたのが pin-exposure.mjs です。Node.js 20 以降なら依存なしで動きます。「測れなかった」を合格に倒さないことが設計要件です。
#!/usr/bin/env node
// pin-exposure.mjs — pin した依存が、いま何の advisory の範囲に入っていて、
// 「pin した minor 系列の中に修正版があるか」を判定する。
//
// exit 0 = 該当 advisory なし / 1 = 該当あり / 3 = 前提条件エラー / 5 = 取得失敗
const API = "https://api.github.com";
const REGISTRY = "https://registry.npmjs.org";
function fail(code, msg) {
console.error(`FATAL: ${msg}`);
process.exit(code);
}
const args = process.argv.slice(2);
let pkgJsonPath = "package.json";
let name = null;
let explicitVersion = null;
let perPage = 100;
// 値を取り損ねたフラグを undefined のまま通すと、既定経路へ黙って落ちる。落とす。
const value = (i, flag) => {
if (i >= args.length || args[i].startsWith("--")) fail(3, `${flag} requires a value`);
return args[i];
};
for (let i = 0; i < args.length; i += 1) {
if (args[i] === "--manifest") pkgJsonPath = value(++i, "--manifest");
else if (args[i] === "--version") explicitVersion = value(++i, "--version");
else if (args[i] === "--per-page") perPage = Number(value(++i, "--per-page"));
else if (!name) name = args[i];
else fail(3, `unexpected argument: ${args[i]}`);
}
if (!name) fail(3, "usage: pin-exposure.mjs <package> [--manifest path] [--version x.y.z] [--per-page n]");
if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100) fail(3, "--per-page must be 1..100");
// --- 1. pin されている exact version を決める ---
let version = explicitVersion;
if (!version) {
const fs = await import("node:fs");
if (!fs.existsSync(pkgJsonPath)) fail(3, `manifest not found: ${pkgJsonPath}`);
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
} catch (e) {
fail(3, `manifest is not valid JSON: ${pkgJsonPath} (${e.message})`);
}
const spec =
pkg.dependencies?.[name] ??
pkg.devDependencies?.[name] ??
pkg.optionalDependencies?.[name];
// 依存に無いものを「advisory 0 件」として合格させない
if (spec === undefined) fail(3, `"${name}" is not a dependency of ${pkgJsonPath}`);
if (!/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(spec)) {
fail(3, `"${name}" is "${spec}" (a range, not an exact pin). pass --version to check a resolved version.`);
}
version = spec;
}
if (!/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(version)) fail(3, `--version must be exact semver, got "${version}"`);
// prerelease(canary / beta)は advisory 側の範囲も prerelease 単位で切られており、
// 本スクリプトの比較(prerelease を落として比較する)では正しく判定できない。合格に倒さず落とす。
if (/-/.test(version)) fail(3, `prerelease version "${version}" is out of scope for this checker`);
// --- 2. パッケージが実在するか(typo を「0 件=安全」にしない)---
const reg = await fetch(`${REGISTRY}/${encodeURIComponent(name)}`, {
headers: { accept: "application/vnd.npm.install-v1+json" },
}).catch((e) => fail(5, `registry request failed: ${e.message}`));
if (reg.status === 404) fail(3, `npm package "${name}" does not exist (typo?)`);
if (!reg.ok) fail(5, `registry returned HTTP ${reg.status} for "${name}"`);
const regJson = await reg
.json()
.catch((e) => fail(5, `registry returned unparseable JSON: ${e.message}`));
if (!regJson.versions?.[version]) {
fail(3, `"${name}@${version}" is not a published version`);
}
// --- 3. advisory を全ページ取得する(1ページ目だけを全体の結論にしない)---
const headers = { accept: "application/vnd.github+json", "user-agent": "pin-exposure" };
if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
let url = `${API}/advisories?ecosystem=npm&affects=${encodeURIComponent(name)}&per_page=${perPage}`;
const advisories = [];
let pages = 0;
while (url) {
const res = await fetch(url, { headers }).catch((e) => fail(5, `advisory request failed: ${e.message}`));
if (res.status === 403 || res.status === 429) {
fail(5, `advisory API rate limited (HTTP ${res.status}). set GITHUB_TOKEN and retry.`);
}
if (!res.ok) fail(5, `advisory API returned HTTP ${res.status}`);
const page = await res
.json()
.catch((e) => fail(5, `advisory API returned unparseable JSON: ${e.message}`));
if (!Array.isArray(page)) fail(5, "advisory API returned an unexpected shape");
advisories.push(...page);
pages += 1;
const link = res.headers.get("link") ?? "";
const next = /<([^>]+)>;\s*rel="next"/.exec(link);
url = next ? next[1] : null;
if (pages > 50) fail(5, "advisory pagination did not terminate");
}
// --- 4. 範囲判定 ---
// advisory 側は "13.0" のような 2 桁表記も返す。0 埋めして 3 桁に揃える。
const parts = (v) => {
const p = v.split("+")[0].split("-")[0].split(".").map(Number);
while (p.length < 3) p.push(0);
return p;
};
const cmp = (a, b) => {
const pa = parts(a);
const pb = parts(b);
for (let i = 0; i < 3; i += 1) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
return 0;
};
const satisfies = (v, range) =>
range
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.every((clause) => {
const m = /^(>=|<=|>|<|=)?\s*(\d+(?:\.\d+){0,2}(?:[-+][^\s]*)?)$/.exec(clause);
// 解釈できない範囲を「該当なし」に倒すと、静かに見逃す。落とす。
if (!m) fail(5, `unparseable vulnerable_version_range clause: "${clause}"`);
const c = cmp(v, m[2]);
switch (m[1] ?? "=") {
case ">=": return c >= 0;
case ">": return c > 0;
case "<=": return c <= 0;
case "<": return c < 0;
default: return c === 0;
}
});
const [major, minor] = version.split(".").map(Number);
const hits = [];
for (const a of advisories) {
// 形が想定と違う要素を「該当なし」に倒すと、全件が壊れていても exit 0 になる。落とす。
if (!a || typeof a.ghsa_id !== "string" || !Array.isArray(a.vulnerabilities)) {
fail(5, `advisory entry has an unexpected shape (ghsa_id=${a?.ghsa_id ?? "<missing>"})`);
}
for (const v of a.vulnerabilities) {
if (v?.package?.name !== name) continue;
// 範囲が欠けている=判定できない。合格側に倒さない。
if (typeof v.vulnerable_version_range !== "string" || v.vulnerable_version_range.trim() === "") {
fail(5, `${a.ghsa_id} has no vulnerable_version_range for "${name}"`);
}
if (!satisfies(version, v.vulnerable_version_range)) continue;
const fixed = v.first_patched_version ?? null;
const inLine =
fixed !== null &&
Number(fixed.split(".")[0]) === major &&
Number(fixed.split(".")[1]) === minor;
hits.push({ id: a.ghsa_id, severity: a.severity, published: (a.published_at ?? "").slice(0, 10), fixed, inLine });
}
}
const order = { critical: 0, high: 1, medium: 2, moderate: 2, low: 3 };
hits.sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9) || a.id.localeCompare(b.id));
console.log(`target : ${name}@${version}`);
console.log(`advisories: ${advisories.length} fetched (${pages} page(s))`);
console.log(`affecting : ${hits.length}`);
for (const h of hits) {
console.log(
` ${h.severity.padEnd(8)} ${h.id.padEnd(22)} published=${h.published} fixed=${h.fixed ?? "none"} same-minor-fix=${h.inLine ? "yes" : "NO"}`,
);
}
if (hits.length === 0) {
console.log("verdict : no known advisory affects this pin");
process.exit(0);
}
// 「どこにも修正版が無い」を「別 minor では直っている」と混ぜない。
const noFixAnywhere = hits.filter((h) => h.fixed === null).length;
const fixedElsewhere = hits.filter((h) => h.fixed !== null && !h.inLine).length;
const fixedInLine = hits.filter((h) => h.inLine).length;
console.log(
`verdict : EXPOSED — ${hits.length} advisory ` +
`(fix inside ${major}.${minor}.x: ${fixedInLine} / fix only in another line: ${fixedElsewhere} / no fix anywhere: ${noFixAnywhere})`,
);
process.exit(1);
実際に走らせた 22 系統
使い捨てディレクトリで全件実行しました。出力は実行時のものです。異常なレスポンスの 6 系統は、ローカルに立てた擬似 API サーバーに返させています。
| 与えた状況 | 実測 |
|---|---|
pin が影響範囲に入る([email protected]) | exit 1 EXPOSED — 25 advisory (fix inside 16.1.x: 0 / fix only in another line: 25 / no fix anywhere: 0) |
| 修正版がどこにも無い advisory を返す | exit 1 ... (fix inside 16.1.x: 0 / fix only in another line: 0 / no fix anywhere: 1) |
影響範囲外([email protected]) | exit 0 no known advisory affects this pin |
advisory が 1 件も無いパッケージ([email protected]) | exit 0 advisories: 0 fetched |
| 対象が依存に無い | exit 3 "react" is not a dependency of package.json |
| manifest のパス typo | exit 3 manifest not found: ./pakcage.json |
package.json が無いディレクトリ | exit 3 manifest not found: package.json |
依存が範囲指定(^16.3.0) | exit 3 ... is "^16.3.0" (a range, not an exact pin) |
パッケージ名 typo(nextt) | exit 3 npm package "nextt" does not exist (typo?) |
未公開バージョン(99.99.99) | exit 3 "[email protected]" is not a published version |
| manifest が壊れた JSON | exit 3 manifest is not valid JSON: ... |
prerelease の pin(16.4.0-canary.1) | exit 3 prerelease version ... is out of scope for this checker |
--version に値を渡し忘れる | exit 3 --version requires a value |
--per-page 0 | exit 3 --per-page must be 1..100 |
認証失敗(不正な GITHUB_TOKEN) | exit 5 advisory API returned HTTP 401 |
| レート制限(無認証で繰り返し実行) | exit 5 advisory API rate limited (HTTP 403). set GITHUB_TOKEN and retry. |
| ネットワーク到達不能 | exit 5 registry request failed: fetch failed |
advisory 要素に vulnerabilities が無い | exit 5 advisory entry has an unexpected shape (ghsa_id=GHSA-fake-0001) |
同フィールドが null | exit 5 advisory entry has an unexpected shape (ghsa_id=GHSA-fake-0002) |
vulnerable_version_range が空文字 | exit 5 GHSA-fake-0003 has no vulnerable_version_range for "next" |
解釈できない範囲(>= 16.0.0 || < 17) | exit 5 unparseable vulnerable_version_range clause: ... |
| API が JSON でない body を返す | exit 5 advisory API returned unparseable JSON: ... |
ページングも実測しています。既定の per_page=100 では next の advisory 66 件が 1 ページで返り、ループが一度も回りません。それでは検証にならないので --per-page を露出させてあります。--per-page 10 に変えると 7 ページで同じ 66 件・同じ 25 件が返りました。1 ページ目だけを全体の結論にしていないことをこれで確かめています。
書いている途中で見つけた「静かな見逃し」
最初の版は、解釈できない範囲文字列を false(=影響しない)として扱っていました。実データに >= 13.0, < 14.2.30 のような 2 桁表記が実在し(GHSA-3h52-269p-cp9r)、その advisory は黙って数から落ちていました。
厄介なのは、この対象では件数が変わらなかったことです。当該 advisory の上限は 14.2.30 なので 16.1.7 には元から影響せず、修正の前後どちらも 25 件でした。数字が動かない欠陥は、数字を見ている限り見つかりません。解釈できない入力を「該当なし」に倒さず落とすように直したことで初めて表面化しました。
同じ形が、独立レビューでさらに 5 つ見つかりました。前半 3 つは「API のレスポンスが想定と違うのに、該当なし=合格側へ倒れる」経路です。
| 見つかった経路 | 直す前の挙動 | 直したあと |
|---|---|---|
advisory 要素に vulnerabilities が無い / null | ?? [] で空配列に落ち、黙って 0 件扱い。全件がこの形なら exit 0 | exit 5 |
vulnerable_version_range が欠落・空文字 | continue で無視 | exit 5 |
| API が JSON でない body を返す | res.json() の例外が未捕捉で exit 1(=「該当あり」と同じ終了コード) | exit 5 |
--version にフラグだけ渡す | undefined を掴んで manifest 側へ黙ってフォールバックし、指定したつもりと違う版を exit 0 で合格させる | exit 3 |
first_patched_version が null | 「別の minor で修正済み」と同じ数に足していた。実際はどこにも修正版が無い | verdict 行で 3 分類に分けて表示 |
3 つ目は空振り合格ではなく終了コードの取り違えですが、呼び出し側から見れば「露出があった」と誤読される点で同じくらい悪い挙動です。異常系の終了コードは、正常系と区別できるだけでなく互いに区別できる必要があります。
5 つ目は、本記事の対象では 25 件すべてが null ではなかったので数字は 1 つも変わりません。それでも直したのは、読者が自分の pin に走らせたときに「別の minor には直っている」と誤読させるためです。掲載するコードの正しさは、掲載した数字が合っているかとは別に確かめる必要があります。
落ちないと確かめたもの(限界)
- prerelease(canary / beta)の pin は対象外です。advisory 側の範囲も prerelease 単位で切られており、本スクリプトの比較では正しく判定できません。判定せずに exit 3 で落とします。
- 推移的依存は見ません。 直接依存の 1 パッケージだけが対象です。推移的依存には
pnpm auditなどを併用してください。 - GitHub Advisory Database に載っていない脆弱性は数に入りません。 ベンダー独自のアナウンスだけで公開されるものは取りこぼします。
8. 止めると決めるときのチェックリスト
142 日を振り返って、着手前に確認しておけば短くできた項目です。
- pin 先の系列がまだ更新されているかをレジストリで確認する(第 4 節)。後続 patch が出ていない系列に pin するのは「修正を受け取らない」と決めることです。
update-typesを付けたignoreはセキュリティ更新を止めない。設定で塞げるところは設定で塞ぎ、そのうえで pin 行を守る層を置く(第 3 節)。- その層を、守る相手の入力で発火させて確かめる。 bot の PR を除外していないか、必須チェックに入っているかを実際の PR で見る(第 6 節)。
- 補償コントロールの表に「埋めないもの」の列を作る。 埋めた気になるのを防ぎます(第 5 節)。
- 解除条件と期限を設定ファイルに書く。 筆者の例では
dependabot.ymlと CI スクリプトの冒頭コメントに解除条件が書かれており、これは機能しました。期限のほうは書かれておらず、142 日になりました。
ここからは筆者の意見です(事実の記述ではありません)。本番が落ちる更新を自動で入れ続けるよりは、止める判断のほうが妥当だったと考えています。許容できるリスクの水準はプロダクトごとに違うので、同じ判断を薦めるつもりはありません。事実として言えるのは、止めた瞬間から時計が回り始めることを設計のどこにも書いていなかったこと、その結果 142 日になったことだけです。
9.(2026-09-10 追記)pin を外したら、トップページだけが 500 になった
ここから下は初出のあとに追記した節です。第 1 節から第 8 節までは初出時点の記録として残してあり、数値も文言も変えていません。本節の値は2026-09-10 に筆者が実測したもので、第 2 節の時系列(過去の postmortem に基づく記述)とは出典が別です。混ぜて読まないでください。
この節も、現在の pin と現在の露出は扱いません。 扱うのは「どのバージョンで / が何を返したか」という挙動の実測値だけです。
時点の確定:pin の前進は 2 回、本番に出たのは 1 回
日付が 2 つ出てくるので、先に並べておきます。
| 日付 | 起きたこと |
|---|---|
| 2026-09-06 | 開発ブランチで 16.1.7 → 16.3.0。本番はこのあいだずっと 16.1.7 で、/ は 200 を返し続けていた |
| 2026-09-10 | 開発ブランチでさらに 16.3.0 → 16.3.3 |
| 2026-09-10 | リリースで開発ブランチを本番へ。ここで初めて 16.3.x が本番に出た |
| 2026-09-10 | デプロイ直後に / の 500 を検知 |
| 2026-09-10 | 16.1.7 へ再 pin して復旧 |
本番が 500 を返していたのは同日中の約 1 時間(本番反映から復旧デプロイ完了まで)です。日単位ではありません。第 2 節の最終行が 2026-09-06 を「pin を前進させた日」として記録しているのは開発ブランチ側の話で、本番到達とは別です。本節の実測はすべて 2026-09-10 のものです。
問題はその手前にあります。開発ブランチには 4 日間 16.3.x が載っていて、preview も出ていました。それでも、本番に出すまで壊れていることが分かりませんでした。
理由は、この節でこれから書くことそのものです——preview で記事ページだけを見て、/ を見ていなかった。
後述する「デプロイ後の外形検査は対象も正しかったが、自動では走らなかった」とは別の穴です。あちらは出したあとの検知、こちらは出す前に何を見るか。同じ「一部だけを測って全体の結論にする」形が 2 段構えで並んでいて、どちらも通り抜けました。preview で / を 1 回開いていれば、本番の 500 は起きていません。
何が起きたか
第 4 節で「区間外の 2 件(critical)」として除外した advisory は、GHSA-2xp9-vwfh-vxw4 と GHSA-p293-qw3h-jr36 です。どちらも 16.x 系での first_patched_version は 16.3.3(各 advisory は 15.5.24 と 16.3.3 の 2 エントリを持ちます。第 4 節で「24 件は 15.5.x で修正済み」と書いたのと同じ形です)で、16.1.7 はこの 2 件の影響範囲に入っていました。つまり pin を上げる動機がありました。ところが上げたところ、トップページが 500 を返しました。いったん pin を戻して復旧させています(その後どう解消したかは、現在の構成の話になるので本記事では扱いません)。
同一構成の preview 環境で 3 つのバージョンを切り替え、/ の HTTP ステータスを測った結果です。筆者の preview 1 環境で 2026-09-10 に実測した値であり、バージョン一般の性質ではありません。
next のバージョン(筆者の preview 1 環境 / 2026-09-10 実測) | / の HTTP ステータス |
|---|---|
16.3.0 | 500 |
16.3.3(critical 2 件の修正版) | 500 |
16.1.7(pin していた版) | 200 |
critical を塞ぐ版でも、トップページが返りませんでした。 この時点で選べたのは、
- 影響範囲に入ったままの版に留まる
16.3.3に上げて、トップページを失う
の二択で、どちらも安全ではありません。第 8 節までの記事は「pin を維持したときに何で補償するか」を論じていましたが、この観測で分かったのは、維持側の補償を積んでも露出は消えず、解除側には即座の可用性喪失があるということです。補償コントロールは穴を埋める道具ではなく、どちらの穴を選ぶかを決めるまでの時間を買う道具でした。これは記事が持っていなかった結論です。
「アダプタを上げれば済む」は、確かめたら違いました
上げられない理由を実行環境側のアダプタ(@opennextjs/cloudflare)に求めたくなりますが、レジストリを引くとその筋書きは支持されません。リリース済みの 1.19.11 の peer dependency はこうです(2026-09-10 に公開レジストリで確認)。
{ "next": ">=15.5.18 <16 || >=16.2.6", "wrangler": "^4.86.0" }
16.3.3 はこの範囲に入っています。 むしろ範囲外なのは 16.1.7 のほうです。つまりこのデータ点は「アダプタが 16.3.x を阻んでいる」ことの証拠にはならず、示しているのは wrangler 側が peer を満たしていない(この時点の手元は 4.61.1)ことだけです。/ が 500 になる理由は特定していないので、ここで出口を 1 つに断定しません。 言えるのは、上げるのが 1 手ではなく wrangler を含む連鎖であること、その連鎖が切り替えを試みた時点で終わっていなかったことだけです。
自分の環境で同じことを疑うなら、アダプタのせいにする前に peer の範囲を実際に引いてください。筆者は引く前に一度アダプタのせいにしかけ、引いたら逆の結果が出ました。第 4 節で「ベンダーの backport 方針ではなく pin 先の選び方だった」と分かったのと同じ形の取り違えです。
故障モードのほうが重要です
このとき落ちたのは / だけでした。
| パス | ステータス |
|---|---|
/ | 500 |
/articles | 200 |
/articles/<slug>(個別記事) | 200 |
記事ページはすべて 200 のままです。 記事ページの応答だけを見て「公開できている」と判断すると、この形の障害は検知できません。筆者自身、preview でも本番でも記事ページの 200 を見て「出せている」と判断し、/ を開かないまま先へ進めていました。同じ確認手順の穴を、出す前と出したあとで 2 回踏んでいます。
この形の障害に対して、外形検査の監視対象がどう効くかを並べるとこうなります。
| 監視対象 | この障害を検知できるか |
|---|---|
| 個別記事 URL のみ | できない(200 のまま) |
| 一覧ページのみ | できない(200 のまま) |
ルート / | できる |
ところが、リストには / が入っていました
ここで筆者は一度、間違った処方を書きました。「監視対象に / を足せ」です。そのあと自分の設定を読み直したら、/ はとっくに入っていました。
第 5 節の補償コントロール 3(デプロイ後の外形検査)が見ている固定 URL には、2026-04 の時点から / が含まれています。しかもそれは、第 2 節の 2026-04-16 の事故(本番トップが 500 を返し続けた件)の是正策として足されたものでした。つまり「同じ障害をもう一度見逃さないための対策」はすでに置かれていたのに、それでも見逃しました。
理由は監視対象ではなく起動経路でした。この外形検査には起動経路が 3 つ用意されていましたが、この時点で自動的に走る経路は 1 つも残っていませんでした(以下はいずれも当時の状態です。現在どうなっているかは本記事の対象外です)。
| 起動経路 | 2026-09-10 時点 |
|---|---|
| デプロイ完了イベントで自動起動 | 発火しなかった。 ホスティング側の連携がそのイベント自体を作らない構成だった |
| 定期実行(分単位のスケジュール) | 止めてあった。 外形検査自身が bot 対策のチャレンジで 403 を踏むため |
| 手動起動 | これだけが生きていた(=誰かが手で押さないと走らない) |
置いてある。対象も正しい。ただし自動では一度も走らない。 これは第 6 節とまったく同じ型です。あちらは pin を守るガードが bot の PR を除外するジョブに入っていて 113 日発火しませんでした。こちらは / を見る検査が、デプロイのたびには起動しない配線になっていました。記事が第 6 節で立てた教訓——
補償コントロールは「置いたか」ではなく「守る相手の入力で発火したか」で測る。
——を、筆者は自分の外形検査に対して適用していませんでした。 第 6 節を書いた本人が、隣の補償コントロールで同じ穴を踏んでいます。この記事で最も再現性が高い教訓はここだと思います。
何を測るべきか
したがって処方は「URL リストに足す」ではありません。2 つを別々に確かめてください。
- 対象: 外形検査が次の 4 層を全部見ているか。1 つでも欠けると、欠けた層だけが落ちる障害を見逃します
- ルート
/(トップページ。フレームワークのルーティング変更の影響を最初に受ける) - 一覧・インデックス系のページ
- 個別の詳細ページ(動的ルート)
- 404 を返すべき URL。判定は HTTP ステータスで行うこと。 筆者の環境では存在しない記事の URL が返す HTML の
<title>はトップページと同一で、本文一致では 404 と 200 を区別できませんでした
- ルート
- 発火: その検査がデプロイのたびに、人手を介さずに走っているか。直近の実行履歴を開いて、最後に自動で走った日時を見てください。「設定はある」と「走っている」は別です
対象のほう(1)は、次のスクリプトで測れます。Node.js 20 以降なら依存なしで動きます。期待ステータスを層ごとに持たせ、1 つでも外れたら非 0 で落とします。
#!/usr/bin/env node
// smoke-routes.mjs — 層ごとに期待 HTTP ステータスを持たせ、1つでも外れたら非 0 で落とす。
// 本文一致では判定しない(404 ページと `/` の <title> が同一の実装は珍しくない)。
//
// exit 0 = 全一致 / 1 = 期待と違うステータス / 2 = 検査不能(到達不能・チャレンジ等)
const [base, ...rest] = process.argv.slice(2);
if (!base || rest.length === 0 || rest.length % 2 !== 0) {
console.error("usage: smoke-routes.mjs <base-url> <path> <expected-status> [<path> <status> ...]");
process.exit(2); // 引数不足を「合格」に倒さない
}
const checks = [];
for (let i = 0; i < rest.length; i += 2) {
const want = Number(rest[i + 1]);
if (!Number.isInteger(want)) { console.error(`FATAL: not a status: ${rest[i + 1]}`); process.exit(2); }
checks.push([rest[i], want]);
}
let bad = 0;
for (const [path, want] of checks) {
let res;
try {
res = await fetch(new URL(path, base), { redirect: "manual" });
} catch (e) {
console.error(`UNTESTABLE ${path}: ${e.message}`);
process.exit(2); // 測れなかったものを合格に倒さない
}
// WAF の bot 対策・チャレンジは「落ちている」でも「無事」でもない。区別して落とす。
if ((res.status === 403 || res.status === 503) && want !== res.status) {
console.error(`UNTESTABLE ${path}: HTTP ${res.status}(チャレンジ / bot 対策の可能性)`);
process.exit(2);
}
const ok = res.status === want;
if (!ok) bad += 1;
console.log(`${ok ? "ok " : "FAIL"} ${path} → ${res.status} (want ${want})`);
}
console.log(bad === 0 ? `verdict: all ${checks.length} routes match` : `verdict: ${bad}/${checks.length} route(s) mismatched`);
process.exit(bad === 0 ? 0 : 1);
node smoke-routes.mjs https://example.com / 200 /articles 200 /articles/<実在slug> 200 /articles/__missing__ 404
ローカルに立てた擬似サーバーで 7 系統を実行した結果です。
| 与えた状況 | 実測 |
|---|---|
| 4 層すべて期待どおり | exit 0 verdict: all 4 routes match |
/ だけが 500(今回の故障モード) | exit 1 FAIL / → 500 (want 200) |
| 404 のはずが 200(soft 404) | exit 1 FAIL /articles/nope → 200 (want 404) |
| 全経路が 403(bot 対策のチャレンジ) | exit 2 UNTESTABLE /: HTTP 403(チャレンジ / bot 対策の可能性) |
| 到達不能なホスト | exit 2 UNTESTABLE /: fetch failed |
| 引数不足 | exit 2 usage: ... |
| ステータスでない引数 | exit 2 FATAL: not a status: abc |
チャレンジの 403 を「落ちている」にも「無事」にも倒していないのが要点です。第 7 節と同じ設計要件で、「測れなかった」を合格に倒さないようにしてあります。実際、筆者の環境で定期実行が止まっていた理由がこの 403 でした。外形検査をこれから置く人は、bot 対策が自分の検査を弾く問題に最初にぶつかります。チャレンジ除外の対象にするか、別経路から測るかを先に決めてください。
そのうえで、リリース判定に使う値をどれか 1 つに代表させないこと。「記事が見えているから成功」は、記事だけを測って全体の結論にしています。第 7 節のスクリプトで避けたのと同じ形の誤りが、デプロイ後の確認の側にも出ました。
この観測の限界
- n=1 ではありません(初出時の記述を訂正します)。 本節を書いた時点では「1 環境・1 回の切り替え」と書きましたが、正しくありません。同じ節の表が示すとおり
16.3.0と16.3.3の 2 バージョンで再現しており、さらに第 2 節の 2026-04-16 の事故が同じシグネチャです(あちらは当時の postmortem の記述で、今回の実測ではありません。出典が違うので分けて読んでください)。あのときも本番トップが 500 を返し続ける一方、記事側の一部は配信され続けていました。「/だけが落ちる」は少なくとも 3 バージョン・2 回の事象で観測されています。 一方で、環境は 1 つです。他の構成でも同じになるという主張はしません。 - 今回の事象では実行時ログを捕捉していません。 ただし「原因不明」ではありません。同一シグネチャの先行事例(第 2 節の 2026-04)では、Worker ランタイム側でモジュール解決に失敗して初期化時に例外が投げられ、ハンドラが 500 を返して終わる機序が原因として特定されています。今回それを実捕捉して確認したわけではないので、先行事例の原因を再適用した推定として扱ってください。
- 測ったのは
/の HTTP ステータスだけです。レンダリング内容・パフォーマンス・他のルートの網羅的な確認は含みません。 - 「142 日」は 2026-09-06 時点の確定値です。本節のとおり開発ブランチでは 4 日後に
16.1.7へ戻しているため、pin という状態そのものはそこで終わっていません。第 1 節から第 8 節の 142 日は、区間の定義を 2026-09-06 で締めた値としてそのまま残しています。 - 現在の pin と現在の露出は本節でも扱いません。 第 1 節が「
16.1.7はすでに稼働していない」と書いているのは 2026-09-06 の前進を指した初出時点の記述で、本節のとおりその後に切り戻しがありました。初出の記録として原文のまま残していますが、現在どのバージョンで動いているかは本記事の対象外です。 - 版数(
@opennextjs/cloudflare/wrangler)はいずれも 2026-09-10 に公開レジストリで確認した値です。レジストリの最新はその後も進みます。
一般化できるとすれば、バージョン固有の挙動ではなく手順のほうです。「上げて、記事ページが 200 だから成功」という確認は、この故障モードを構造的に見逃します。そして本節で分かったとおり、その確認を自動化して置いてあっても、走っていなければ同じことです。
なお、この節の処方を「監視対象に / を足せ」と書き間違えたのは筆者だけではありませんでした。追記の独立レビューでも同じ取り違えが独立に出ています。「検知できなかった=見ていなかった」と読むのは、それだけ自然な誤りです。 見ていなかったのか、見る仕掛けが動いていなかったのかは、実行履歴を開くまで区別できません。
FAQ
ignore を書けば Dependabot は完全に止まりますか
書き方によります。update-types を付けた ignore は通常のバージョン更新にしか適用されず、セキュリティ更新には適用されません。update-types を書かない形なら両方に適用されます。筆者の環境では前者だったため、ignore 追加の 26 日後にセキュリティ更新経由の bump が入り、本番デプロイが失敗しました。設定を直したうえで、pin 行を守る層も CI に置く必要がありますが、置くだけでは足りません。bot の PR を除外していない独立したジョブにして、必須チェックに入れて、実際の bot PR で落ちることを確認するところまでが条件です。筆者はここを満たしておらず、113 日間発火しませんでした。
pin したまま patch だけ追えばよいのでは
pin 先の系列がまだ更新されている場合に限り成立します。筆者が測った例では、pin していた 16.1.7 が 16.1.x の最終リリースで、16.1.8 以降は存在しませんでした。この状態では patch を待っても永久に来ません。pin する前に、その系列に後続 patch が出続けているかをレジストリで確認してください。
補償コントロールを置いたことをどう確認しますか
設定ファイルを読むだけでは足りません。守る相手の入力(この例では bot が出す PR)で実際に落ちることを見てください。筆者の環境では、pin を守るガードが bot の PR を除外するジョブに入っており、113 日間一度も発火していませんでした。
この記事の数字は今も同じですか
同じにはなりません。advisory は増え続けるので、第 4 節の 23 件(区間内)と 25 件(合計)は 2026-09-10 取得時点の値です。第 7 節のスクリプトを自分の pin に対して走らせて、自分の数字を出してください。
攻撃手法は載っていますか
載せていません。本記事は防御・検知・運用設計だけを扱います。参照は公開済みの GHSA と GitHub 公式ドキュメントに限定しています。
pin を外せば安全になりますか(2026-09-10 追記)
筆者の環境ではなりませんでした。第 9 節のとおり、16.1.7 は critical 2 件(GHSA-2xp9-vwfh-vxw4 / GHSA-p293-qw3h-jr36、いずれも 16.x 系での修正版は 16.3.3)の影響範囲に入っていましたが、その 16.3.3 に上げるとトップページが 500 になりました。露出を減らす操作が可用性を失わせるという形です。この「/ だけが落ちる」シグネチャは 16.3.0 / 16.3.3 に加えて第 2 節の 2026-04 の事故でも観測されており、n=1 ではありません。ただし環境は 1 つなので、他の構成でも同じになるという主張はしません。上げたときに何が落ちるかは、上げる前に自分の環境で測ってください。
上げたあと、何を見れば「成功した」と言えますか(2026-09-10 追記)
2 つを別々に確かめてください。 (1) 外形検査がルート /・一覧ページ・個別の詳細ページ・404 を返すべき URL の 4 層を見ているか(404 は HTTP ステータスで判定すること)。(2) その検査がデプロイのたびに人手を介さず走っているか。筆者の環境では / は 2026-04 からリストに入っていたのに障害を見逃しました。原因は対象ではなく起動経路で、当時は自動的に走る経路が 1 つも残っていなかったためです。第 6 節(pin ガードが bot の PR で 113 日発火しなかった件)とまったく同じ型です。
References
- Dependabot options reference(
ignoreの挙動) - About Dependabot security updates
- GitHub REST API: Global security advisories
- GitHub Advisory Database
- GHSA-3h52-269p-cp9r(2 桁表記の範囲が実在する例)
- GHSA-2xp9-vwfh-vxw4(第 9 節。
16.x系のfirst_patched_versionは16.3.3) - GHSA-p293-qw3h-jr36(第 9 節。
16.x系のfirst_patched_versionは16.3.3) - npm:
@opennextjs/cloudflare(第 9 節の peer dependency の確認先) - pnpm: overrides
- Cloudflare Pages:
_routes.json
