無料枠だけで、複数の WordPress の脆弱性と改ざんを毎日チェックする
プラグインの脆弱性に気づけない
WordPressのサイトをいくつも見ていると、プラグインの脆弱性の情報を追いきれなくなります。 管理画面の更新通知はログインしないと目に入りませんし、修正版が出ていない脆弱性はそもそも通知されません。 ファイルを書き換えられていても、見た目が変わらなければ気づけません。
有料の監視サービスもありますが、サイトの数だけ月額がかかります。 そこで、GitHub Actions と WP-CLI、Wordfence の脆弱性データを組み合わせて、無料枠だけで毎日チェックする仕組みを作りました。 数サイトから数十サイトを自分で面倒を見ている人向けです。
チェックするのは2つです。
- 本体・プラグイン・テーマのバージョンを、Wordfence Intelligence の脆弱性データと照合する
wp core verify-checksumsとwp plugin verify-checksumsで、公式の配布物と違うファイルがないか調べる
問題があった日だけ、全サイト分をまとめて Slack に1通送ります。
全体の流れ
監視用のリポジトリを1つ作って、次のファイルを置きます:
.github/workflows/wp-security-daily.yml 毎日決まった時刻に実行
scripts/collect-site.sh SSHで1サイト分の情報を集める
scripts/report.php 照合して、Slackの通知文を作る
sites.json 監視するサイトの一覧
結果にはサイトの弱点が並ぶので、リポジトリは必ずプライベートにします。
ワークフローは4つのジョブに分けました。 sites.json を読む、脆弱性データを取る、サイトごとに情報を集める、照合して通知する、の順です。 サイトを増やすときは、sites.json に足すだけです:
{
"sites": [
{
"name": "example-corp",
"url": "https://www.example.com/",
"ssh_host": "sv1234.example-hosting.jp",
"ssh_user": "example",
"ssh_port": 10022,
"wp_path": "/home/example/example.com/public_html"
}
]
}
SSHの秘密鍵と known_hosts、Wordfence の API キー、Slack の Webhook URL は、リポジトリの Secrets に入れておきます。
脆弱性データは1日1回だけ取る
Wordfence Intelligence は、WordPress の脆弱性データを API で配っています。 wordfence.com にアカウントを作って API キーを発行すれば、商用でも無料で使えます。
データは全件を1回でダウンロードする形です。 短い間に何度も取ると 429 が返り、続けるとキーを止められることがあります。 そこで、キャッシュのキーに日本時間の日付を入れて、同じ日に何回実行しても取りに行かないようにしました:
feed:
runs-on: ubuntu-latest
steps:
- name: Today (JST)
id: date
run: echo "today=$(TZ=Asia/Tokyo date +%Y%m%d)" >> "$GITHUB_OUTPUT"
- name: Restore today's feed
id: cache
uses: actions/cache@v6
with:
path: feed/wordfence-production.json.gz
key: wordfence-feed-${{ steps.date.outputs.today }}
- name: Download (once per day)
if: steps.cache.outputs.cache-hit != 'true'
env:
WORDFENCE_API_KEY: ${{ secrets.WORDFENCE_API_KEY }}
run: |
mkdir -p feed
code=$(curl -sS --compressed --max-time 600 \
-H "Authorization: Bearer ${WORDFENCE_API_KEY}" \
-o feed/wordfence-production.json -w '%{http_code}' \
https://www.wordfence.com/api/intelligence/v3/vulnerabilities/production) || code=000
if [ "$code" != "200" ]; then
echo "::error::脆弱性データを取得できませんでした(HTTP ${code})"
exit 1
fi
gzip -9 feed/wordfence-production.json
失敗してもリトライはしません。 サイトが何件あっても、ダウンロードは1日1回で済みます。 取ったデータは artifact にして、最後のジョブに渡します。
サイトごとに SSH で集める
sites.json を jq で読んで matrix にし、サイトの数だけジョブを並べます:
sites:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.load.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- id: load
run: echo "matrix=$(jq -c '[.sites[] | select(.enabled != false)]' sites.json)" >> "$GITHUB_OUTPUT"
inventory:
needs: sites
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
site: ${{ fromJSON(needs.sites.outputs.matrix) }}
fail-fast: false を付けておかないと、1サイトの接続に失敗しただけで、ほかのサイトのジョブまで止まってしまいます。
各ジョブでは、SSHでサーバーに入って WP-CLI を実行します。 大事なのは --skip-plugins --skip-themes です。 WP-CLIは普段、プラグインとテーマを読み込んでから動きます。 改ざんを調べたいのに、改ざんされているかもしれないコードを実行してしまってはまずいので、読み込まずに動かします:
ssh_opts=(
-i "$HOME/.ssh/id_key" -p "$port"
-o BatchMode=yes -o ConnectTimeout=20 -o StrictHostKeyChecking=yes
-o ControlMaster=auto -o "ControlPath=$HOME/.ssh/cm-%C" -o ControlPersist=120
)
remote() { ssh "${ssh_opts[@]}" "$user@$host" "$1"; }
wp() { remote "$wp_cli $1 --path=$(printf %q "$wp_path") --skip-plugins --skip-themes"; }
wp "core version"
wp "plugin list --fields=name,version,status --format=json"
wp "theme list --fields=name,version,status --format=json"
wp "core verify-checksums"
wp "plugin verify-checksums --all"
$wp_cli はふだん wp です。 WP-CLIが入っていないサーバーや、コマンドラインの PHP が古いサーバーでは、sites.json で "wp_cli": "/usr/bin/php8.2 /home/example/bin/wp-cli.phar" のように指定できるようにしています。
ControlMaster は、最初の SSH 接続を使い回すための設定です。 コマンドごとに接続し直すと、レンタルサーバーによっては接続数の制限に当たったり、ログインのたびに通知メールが飛んだりします。
StrictHostKeyChecking=yes にして、Secrets に入れた known_hosts と違うホストには接続しないようにしています。 ポートが22以外のサーバーは、known_hosts に [sv1234.example-hosting.jp]:10022 の形で書きます。 ssh-keyscan -p 10022 sv1234.example-hosting.jp で取れば、この形で出てきます。
WP-CLIの出力には、PHPの警告(Deprecated など)が混ざることがあります。 そのまま jq に渡すと読めないので、JSONの行だけを拾っています:
json_line() { grep -E '^\s*[\[{]' | tail -n 1; }
wordpress.org にない有料プラグインや自作のプラグインは、plugin verify-checksums ではスキップされます。 エラーにはならないので、そのままで大丈夫です。
集めた結果はサイトごとに JSON に書き出して、これも artifact で最後のジョブに渡します。
照合して Slack に送る
最後のジョブで、全サイトの結果と脆弱性データを突き合わせます。 GitHubのランナーには PHP が入っているので、PHPで書きました。
Wordfence のデータでは、ソフトウェアごとに影響するバージョンの範囲が affected_versions に入っています。 範囲の端を含むかどうかも書いてあるので、version_compare() で判定します:
function in_range(string $version, array $range): bool
{
$from = $range['from_version'];
$to = $range['to_version'];
if ($from !== '*') {
$c = version_compare($from, $version);
if (!($c < 0 || ($range['from_inclusive'] && $c === 0))) {
return false;
}
}
if ($to !== '*') {
$c = version_compare($to, $version);
if (!($c > 0 || ($range['to_inclusive'] && $c === 0))) {
return false;
}
}
return true;
}
* は下限なし、上限なしの意味です。 データが大きいので、最初に plugin:スラッグ の形で引ける索引を作ってから照合しています。
チェックサムのほうは、WP-CLIの終了コードで判定します。 0なら公式の配布物と一致、127(コマンドがない)と255(SSHの失敗)は実行エラー、それ以外は違うファイルがあったとみなして通知します。
通知は、問題があった日だけ送ります。 毎日「問題なし」が届くと、そのうち誰も見なくなるからです。 全件の結果は、GitHubの実行ページの Summary に書き出しておきます。
Wordfenceのデータには、出典の表示が必要な記録があります。 Summaryの最後に出典を載せているので、レポートを人に渡すときも、この部分は残しておきます。
無料枠に収まるか
プライベートリポジトリの場合、GitHub Free で使える Actions の時間は月2,000分です。 ジョブごとに1分単位で切り上げて数えられるので、このワークフローは1日あたり「サイト数+約4分」を使います。 10サイトなら1か月で約420分、30サイトでも約1,020分です。
支払い方法を登録していなければ、枠を使い切った時点でその月は止まるだけで、課金はされません。 登録している場合は、Billing の予算で Actions の上限を0ドルにしておくと安心です。
ほかに使っているのは、Wordfence の無料の API キーと Slack の Incoming Webhook だけです。
レンタルサーバーで使うとき
SSHで入れて WP-CLI が動けば、レンタルサーバーでも使えます。 1つのサーバーに複数のサイトがある場合は、ssh_host と ssh_user が同じで wp_path だけが違うものを、サイトの数だけ書きます。
注意したいのは、SSHの接続元の制限です。 GitHubのランナーは海外から接続するので、接続元を国内に限っているサーバーには入れません。 エックスサーバーの「国内からのアクセスのみ許可」がこれに当たります。
その場合は、国内の固定IPのマシンにセルフホストランナーを入れて、SSHで集めるジョブだけをそちらで動かします:
inventory:
runs-on: ${{ vars.INVENTORY_RUNNER || 'ubuntu-latest' }}
変数 INVENTORY_RUNNER にランナーのラベルを入れたときだけ切り替わるので、制限のあるサーバーがなければ何もしなくて大丈夫です。 セルフホストランナーで動いたジョブは、無料枠の時間にも数えられません。
一度作ってしまえば、サイトが増えても sites.json に1件足して、公開鍵を登録するだけです。 見ているサイトが多い人ほど楽になると思います。
