公開したあとの話 — 脆弱性検知と監視の仕組みを作る

脆弱性検知と監視の仕組み 記事のサムネイル

執筆者:

カテゴリ:

ポートフォリオサイトを本番公開したあと、運用のための仕組みを整えました。脆弱性の検知、稼働の監視、依存パッケージの更新です。

公開までの記事は「動かすまで」の話でしたが、今回は「動き続けさせる」ための話になります。地味な作業ですが、初回の実行でいきなり実害のある問題が見つかったので、やっておいてよかったと思っています。

公開したら終わり、ではなかった

デプロイが終わった時点で、次のような状態でした。

・使っているnpmパッケージ 679個 の脆弱性 → 誰も見ていない
・WordPress本体・プラグインの更新       → 気付いたときに手で確認
・公開してはいけないファイルの置き忘れ   → 気付く手段がない
・サイトが止まったとき                  → 自分が見に行くまで分からない

個人サイトなので、止まっても誰も困りません。それでも仕組みを入れたのは、実際に「置き忘れ」をやらかしていたからです。

移行作業のときに、データベース全体が入った約62MBのバックアップファイルをサーバーに置きました。作業には必要でしたが、URLさえ分かれば誰でもダウンロードできる状態でした。中にはユーザー情報もパスワードのハッシュも入っています。

これは移行の途中で自分で気付いて削除しましたが、気付かなければ置きっぱなしでした。同じことがまた起きないようにしたい、というのが動機です。

やったこと1: 脆弱性の自動検知を入れる

GitHubには Dependabot という機能が最初から用意されています。使っているパッケージの一覧を読み取り、既知の脆弱性データベースと突き合わせて教えてくれるものです。

設定は3段階に分かれていて、順に有効化する必要があります。

機能 役割
Dependency graph 依存関係を解析する土台。これが無いと以下が動かない
Dependabot alerts 脆弱性を検知して通知する
Dependabot security updates 修正版へ上げるPRを自動で作る
ポイント

alerts だけだと「アラートは来るが誰も直さない」状態になりやすいです。security updates まで有効にして、修正案がPRの形で届くようにするのが実用的でした。人間がやるのは中身を見てマージするだけになります。

あわせて .github/dependabot.yml という設定ファイルも置きました。これがあると、脆弱性の有無に関係なく定期的な更新PRも作られます。

.github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    # Next.jsアプリはリポジトリ直下ではなく frontend/ にある
    directory: /frontend
    schedule:
      interval: weekly
    open-pull-requests-limit: 5
    groups:
      minor-and-patch:
        update-types: [minor, patch]
つまずきやすい点

directory: /frontend の指定が重要です。今回のリポジトリはルート直下にWordPressのファイルもあり、Next.jsは frontend/ の中にあります。ここを間違えると package.json が見つからず、Dependabotはエラーも出さずに何もしません。「設定したのに動かない」で悩む典型的なパターンです。

やったこと2: 検知できない領域を自分で作る

Dependabotは強力ですが、npmパッケージしか見ません。今回のサイトで実際に起きた問題を並べると、拾えるのは1つだけでした。

実際に起きたこと Dependabotで拾えるか
next に脆弱性 拾える
バックアップファイルが誰でもダウンロード可能 拾えない
SSL証明書がホスト名と不一致 拾えない
CMSが落ちてサイトのデータが全部空 拾えない

パッケージの脆弱性より、設定ミスや置き忘れのほうが実際には多かったわけです。そこで、残りをカバーする監視をGitHub Actionsで作りました。

GitHub Actionsは、GitHubのサーバー上でコマンドを定期実行できる仕組みです。毎朝9時に動かし、問題があればSlackへ通知します。

.github/workflows/security-audit.yml(抜粋)
on:
  schedule:
    # 毎日 09:00 JST(cron は UTC なので 00:00 を指定)
    - cron: "0 0 * * *"
  workflow_dispatch:   # 手動実行もできるようにする

チェックしているのは4領域です。

1. npm依存パッケージ … npm audit の結果から high 以上を報告
2. WordPress本体     … 導入バージョンと最新版を比較
3. 公開ファイル       … 見えてはいけないURLを実際に叩いて確認
4. 稼働とSSL証明書    … 表示・API応答・証明書の残り日数

実際の失敗から作った監視項目

この中に、教科書には出てこない項目をひとつ入れました。

scripts/security-audit.mjs(抜粋)
if (/まだ実績が登録されていません|まだ記事がありません/.test(html)) {
  report("high", "health", "サイトは表示されるがCMSのデータが取得できていない");
}

デプロイのときに、ビルドは成功しページも表示されるのに、データが1件も出ないという状態に数時間はまりました。HTTPステータスは200なので、普通の死活監視では検知できません。

監視項目は、教科書からではなく自分が踏んだ失敗から作るほうが実用的になります。

通知は異常時だけにする

Slackへの通知は、問題があったときだけ送るようにしました。正常時は何も送りません。

毎日「異常なし」が届くと、そのうち誰も読まなくなります。 通知は「届いたら必ず見る」状態を保つことが重要で、そのためには送る回数を絞る必要があります。

初回実行でいきなり本物が出た

作った監視を初めて動かしたところ、25件を検知しました。そして、その中に実害のある指摘が含まれていました。

CRITICAL (exposure) /wp/readme.html が公開状態(200 / 7406バイト)

readme.html はWordPressに最初から入っているファイルで、WordPressのバージョン番号が書かれています

これがなぜ問題かというと、攻撃する側にとって攻撃対象を絞り込むための情報だからです。「このサイトはWordPress 7.0.2だから、このバージョンに効く攻撃を試そう」と判断できてしまいます。

削除すべきファイルなのですが、WordPressを更新するたびに復活します。だからこそ、一度消して終わりではなく定期チェックの対象にする価値があります。

作った意味があった

これはDependabotでは絶対に拾えません。パッケージの脆弱性ではなく、置き忘れだからです。移行のときの .wpress と同じ種類の問題が、まだ他にもあったということでした。

誤報も出た(そして直した)

同時に、誤検知も1件ありました。

CRITICAL (exposure) /wp/wp-config.php が公開状態(200 / 0バイト)

wp-config.php にはデータベースのパスワードが平文で書かれています。もし中身が読めるなら重大な問題です。

ただ、よく見ると0バイトです。中身は漏れていません。PHPファイルはサーバー側で実行されてから結果が返るので、このファイルの場合は実行結果が空になります。アクセスできること自体は正常な挙動でした。

私の判定ロジックが「ステータス200 = 公開されている」という単純な条件だったために起きた誤報です。本文まで読んでから判定するように直しました。

scripts/security-audit.mjs(修正後)
const res = await get(`${CMS}${path}`);
if (res.status !== 200) continue;

// 200が返っても中身が空なら「漏れていない」。
// 例: wp-config.php はPHPとして実行され、出力は空になる。
const body = await res.text();
const bytes = new TextEncoder().encode(body).length;

if (bytes === 0) {
  note(`${path} は200を返すが中身は空(問題なし)`);
  continue;
}

// ファイルの性質によって深刻度を変える
const severity = /wp-config\.php|\.wpress$/.test(path) ? "critical" : "high";
report(severity, "exposure", `${path} の中身が読める状態(${bytes}バイト)`);

修正後、検知件数は 25件 → 23件 になりました。誤報1件と、削除した readme.html の1件が消えた形です。

監視を作るときの原則

監視は動かしてから調整するものです。最初から完璧を狙う必要はありません。ただし誤報を放置してはいけないのは重要で、誤報が続くと通知そのものが信用されなくなり、本物の警告まで無視されるようになります。

更新PRの見分け方 ── セマンティックバージョニング

Dependabotを有効にした直後、脆弱性51件を検知し、修正PRが8件作られました。

これを全部そのままマージしてよいかというと、まったく違います。判断の基準になるのがセマンティックバージョニングという約束事です。

16 . 3 . 0
│    │   └── パッチ: バグ修正。動作は変わらない
│    └────── マイナー: 機能追加。既存の動作は壊さない
└─────────── メジャー: 破壊的変更あり。動かなくなる可能性がある

この約束に従うと、左端の数字が変わらなければ、原則として壊れません。逆に左端が変わるものは、動作確認なしでマージしてはいけません。

今回の8件を分類するとこうなりました。

# 内容 種別 判断
1 brace-expansion パッチ すぐマージ可
2 js-yaml 4.3.0 → 4.3.1 パッチ すぐマージ可
3 postcss 8.4.39 → 8.5.23 マイナー すぐマージ可
4 minor-and-patch グループ マイナー すぐマージ可
5 eslint-config-next 14 → 16 メジャー×2 #7とセット
7 next 14.2.5 → 16.3.0 メジャー×2 #5とセット
6 tailwindcss 3.4.4 → 4.3.3 メジャー 要注意
8 graphql-request 6.1.0 → 7.4.0 メジャー 単独で検証

セットで扱う必要があるもの

eslint-config-next は Next.js とバージョンを揃える前提のパッケージです。片方だけ上げると噛み合いません。 #5 と #7 は必ず一緒にマージする必要があります。

特に注意が要るもの

Tailwind CSS の v3 → v4 は、設定の書き方が根本から変わります。これまで tailwind.config.ts に書いていた内容を、CSS側へ移す作業が必要です。

怖いのは、ビルドが通っても見た目が崩れる可能性がある点です。「マージしたらエラーになる」なら気付けますが、「マージしたら静かにデザインが変わる」は気付きにくい。これは更新ではなく移行作業として、時間を取って扱うべきものです。

なぜ一度に全部マージしないのか

8件をまとめてマージすれば早く終わります。それをしなかった理由が2つあります。

理由1: 問題が起きたとき原因が分からなくなる

8件を同時に入れて表示が崩れたら、原因はどれでしょうか。切り分けるには結局1つずつ戻すことになり、余計に時間がかかります。

変更は1つずつ、確認できる状態で入れる。 公開作業でSSL証明書の問題に数時間かけたのは、まさに「何が原因か切り分けられない状態」を作ってしまったからでした。

理由2: マージするたびに他のPRが古くなる

PRをマージすると package-lock.json というファイルが更新されます。すると残りのPRは古い状態のままになり、そのままではマージできません。

Dependabotが自動で作り直してくれますが、少し時間がかかります。まとめられるものはまとめて処理するのが効率的です。

今回の進め方

安全な #1〜#4 を先に処理し、Dependabotが残りを作り直すのを待ってから、メジャーアップデートを1グループずつ扱う——という順序にしました。急がないものを急がないのも判断のうちです。

確認項目チェックリスト

今回の作業で使った確認項目をまとめておきます。次に同じことをするとき、これをなぞれば済むようにするためのものです。

A. 依存パッケージを更新する前

□ バージョン番号の左端が変わっていないか(メジャーか否か)
□ セットで上げる必要のあるパッケージはないか
   (next と eslint-config-next のような組み合わせ)
□ CI・プレビューのチェックが通っているか
□ コンフリクトが出ていないか
□ メジャーの場合、公式の移行ガイドを読んだか
便利だった点

VercelはPRごとにプレビュー環境を自動で作ります。本番と同じ環境でビルドが通るかを、マージ前に確認できました。「ビルドが通ることは確認済み」という状態でマージできるのは大きいです。

B. マージした直後

□ Vercelのデプロイが成功しているか
□ トップページが表示されるか
□ CMSのデータが実際に出ているか(空表示になっていないか)
□ 主要ページが全部200を返すか
□ 画像が表示されているか

3つ目が特に重要です。ステータス200でも中身が空、という状態がありうることを、公開作業で学びました。ページが開けることと、中身が正しいことは別です。

C. 定期的に確認すること(自動化済み)

□ npm依存に high 以上の脆弱性がないか
□ WordPress本体が最新か
□ 公開してはいけないファイルが読めていないか
   ・移行用のバックアップ(.wpress)
   ・php.ini
   ・wp-config.php(中身が読める場合のみ)
   ・debug.log
   ・readme.html   ← WordPress更新のたび復活する
□ ディレクトリ一覧が表示されていないか
□ サイトが200を返すか
□ ルートドメインが正しく転送されるか
□ GraphQLがJSONを返すか
□ SSL証明書の残り日数が14日を切っていないか

証明書の閾値を14日にしたのには理由があります。Let’s Encryptは90日有効で、30日前から自動更新されます。つまり14日を切っている=自動更新が動いていないと判断できるわけです。

D. 手作業でやること(自動化していない)

□ WordPressプラグインの更新確認
□ 管理者パスワードが十分に強いか
□ 不要なプラグイン・テーマを削除したか
□ ドメインの有効期限(切れるとサイト全体が止まる)
□ 2要素認証の設定(GitHub / Vercel / サーバー)

ドメインの有効期限は見落としやすい割に影響が大きい項目です。今回、作業中にたまたま管理画面で気付きました。

今回の学び

監視は既製品と自作を組み合わせる。 Dependabotだけでは今回の問題の4分の1しかカバーできませんでした。かといって全部自作するのも無駄です。既製品が得意な領域は任せ、残りを自分で埋めるのが現実的でした。

監視項目は自分の失敗から作る。 「データが空なのに200を返す」という項目は、実際にはまったからこそ思いつきました。一般的な死活監視の教科書には出てきません。踏んだ失敗を監視項目に変換していくと、そのサイト固有の実用的な監視になっていきます。

誤報は放置しない。 初回で誤報が1件出ました。誤報が続くと通知が信用されなくなり、本物の警告まで無視されるようになります。「オオカミ少年にしない」ことが、監視を維持する上でいちばん大事だと思っています。

そして、急がないものを急がない。 8件のPRを前にすると全部片付けたくなりますが、メジャーアップデートは動作確認とセットです。急いで入れて壊すより、安全なものから順に入れて、残りは時間を取って扱う。作業を止める判断も作業のうちだと感じました。

次回は、残っているメジャーアップデート(Next.js 16 と Tailwind CSS v4)に取り組む予定です。特にTailwindのv4は設定の書き方が変わるので、それなりの記事になりそうです。

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です