/ #WordPress #GitHub Actions

無料枠だけで、複数の 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件足して、公開鍵を登録するだけです。 見ているサイトが多い人ほど楽になると思います。