WordPressは誰が何を変えたか記録しない — 操作ログの実測

実測 に検証

「先月、誰がこのプラグインを有効化したのか」「設定はいつ変わったのか」。 社内で聞かれても、WordPress にはそれを答える材料がありません。

どこまでが記録され、どこからが記録されないのかを実測しました。

実測: 操作してもデータベースは増えない#

管理者と同じ操作を行い、直前と直後でテーブルの行数を比べました。

操作データベースの変化誰が・いつ の記録
プラグインを有効化変化なし残らない
サイト設定を変更(サイト名)変化なし残らない
投稿を編集wp_posts +1(リビジョン)残る(作成者 ID と日時)

プラグインの有効化で変わるのは wp_options の active_plugins の値だけです。

有効化前: ["lab-triggers/lab-triggers.php"]
有効化後: ["hello.php","lab-triggers/lab-triggers.php"]

前の値も、変更した人も、時刻も保存されません。 サイト名を変えたときも同じで、変更前の値を持つ行は作られませんでした。

記録が残るのは投稿と固定ページの本文だけです。実測したリビジョンには、 編集したユーザーの ID と日時が入っていました(lab_editor ID 2 で確認)。

監査用のテーブルは存在しない#

インストール直後の WordPress が持つテーブルを確認しました。

wp_commentmeta, wp_comments, wp_links, wp_options, wp_postmeta, wp_posts,
wp_term_relationships, wp_term_taxonomy, wp_termmeta, wp_terms, wp_usermeta, wp_users

12 個だけで、操作の履歴を書く場所がありません。 記録の仕組みとして用意されているのはリビジョンだけで、その対象は本文です。

ログインの記録も残らない#

こちらのほうが影響が大きいかもしれません。

知りたいこと実測結果
いつログインしたかsession_tokens に login と expiration の時刻だけ
どこからログインしたか記録なし(IP もブラウザ情報も保存されない)
最終ログイン日時wp_users に列が無く、wp_usermeta にも last_login は無い
ログインの失敗wp_options の行数は ±0。試行回数もロックも記録しない

実際に保存されていた値です。

{"expiration":1789572728,"login":1789565528}

wp_users の列は ID, user_login, user_pass, user_nicename, user_email, user_url, user_registered, user_activation_key, user_status, display_name で、 最後にログインした日時を持つ列はありません。

アクセスログでも成功と失敗を区別できない#

Web サーバーのログなら追えるのでは、と考えて確かめました。 ログイン成功と、パスワードを間違えた 3 回の記録です。

192.168.65.1 - - [16/Sep/2026:15:12:05 +0000] "POST /wp-login.php HTTP/1.1" 200 10373
192.168.65.1 - - [16/Sep/2026:15:12:55 +0000] "POST /wp-login.php HTTP/1.1" 200 10362
192.168.65.1 - - [16/Sep/2026:15:12:55 +0000] "POST /wp-login.php HTTP/1.1" 200 10362
192.168.65.1 - - [16/Sep/2026:15:12:55 +0000] "POST /wp-login.php HTTP/1.1" 200 10362

成功も失敗も 200 です。ユーザー名も残りません。 本文の長さがわずかに違うだけで、ログから「誰がログインしたか」「何回失敗したか」は 読み取れません。

→ どのログに何が出るかは ログはどこにあり、何を見るのか

なぜそうなるのか#

WordPress は設定を wp_options に上書きで保存します。 update_option() は値を差し替えるだけで、前の値も操作者も残しません。

リビジョンだけが例外なのは、投稿の編集を取り消せるようにするための機能だからです。 監査のための仕組みではないので、対象も本文に限られます。

つまり「記録が無い」のは不具合ではなく、そういう設計です。

運用で決めておくこと#

記録が残らない以上、事前に決めておかないと後から追えません。 あわせて実測した、既定のままだと危ういものを挙げます。

1. 更新を誰がやるか(自動更新は既定ではほぼ働かない)

対象既定
本体のマイナー更新(7.1 → 7.1.1)自動で更新する
本体のメジャー更新(7.1 → 7.2)自動で更新しない
プラグイン自動で更新しない(1 つずつ有効化が必要)
テーマ自動で更新しない

「自動更新に任せている」と思っていても、プラグインは 1 つずつ有効にしない限り 更新されません。担当者と頻度を決めておきます。

2. パスワードの強度は強制されない

"1234" でユーザーを作成でき、そのままログインできました。 WordPress のコアに、長さや複雑さの下限を決める設定はありません。 画面に出る強度メーターは警告するだけで、登録は止まりません。

3. アプリケーションパスワードは既定で有効

wp_is_application_passwords_available() は既定で true でした。 これは REST API 用の別パスワードで、通常のログイン画面を守っても、 こちらが残っていれば外部から操作できます。使わないなら止める判断が要ります。

4. 退職者のアカウントを残さない

誰がいつ作ったアカウントかは追えないので、定期的に棚卸しするしかありません。 ユーザーを削除するときは投稿の扱いに注意します(--reassign を付けないと投稿も消えます)。 → 乗っ取られた・改ざんされた時の確認と対処法

記録を残したいなら#

WordPress の外側に自分で足すことになります。記録を書くコードは、 通常のプラグインではなく wp-content/mu-plugins/ に置きます。 プラグインが Fatal で止められても、リカバリーモードで全プラグインが 読み込まれなくても動く層だからです。

// wp-content/mu-plugins/audit.php
add_action( 'activated_plugin', function ( $plugin ) {
	error_log( sprintf( '[audit] plugin activated: %s by user %d', $plugin, get_current_user_id() ) );
} );
add_action( 'updated_option', function ( $option ) {
	if ( in_array( $option, array( 'blogname', 'siteurl', 'home', 'users_can_register' ), true ) ) {
		error_log( sprintf( '[audit] option updated: %s by user %d', $option, get_current_user_id() ) );
	}
} );
add_action( 'wp_login', function ( $login ) {
	error_log( sprintf( '[audit] login: %s from %s', $login, $_SERVER['REMOTE_ADDR'] ?? '-' ) );
} );

出力先は公開領域の外に置きます。wp-content/debug.log は Web から読めてしまうためです。 → ログはどこにあり、何を見るのか

再現手順#

# 操作の前後で行数を比べる
wp eval '
require_once ABSPATH . "wp-admin/includes/plugin.php";
global $wpdb;
$c = function () use ($wpdb) { return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->options}"); };
$before = $c();
activate_plugin("hello.php");
printf("options: %d → %d\n", $before, $c());
printf("active_plugins: %s\n", json_encode(get_option("active_plugins")));
deactivate_plugins("hello.php");'

# ログインの記録
wp eval '
$u = get_user_by("login", "admin");
var_dump(get_user_meta($u->ID, "session_tokens", true));
var_dump(get_user_meta($u->ID, "last_login", true));'

# 自動更新の既定(AUTOMATIC_UPDATER_DISABLED などの定数を外した状態で測る)
wp eval '
require_once ABSPATH . "wp-admin/includes/class-wp-upgrader.php";
$u = new WP_Automatic_Updater();
$item = (object) ["response" => "upgrade", "current" => "7.1.1", "new_files" => false,
  "locale" => "ja", "packages" => (object) ["full" => "https://example.com/wp.zip"],
  "php_version" => "7.2.24", "mysql_version" => "5.5.5", "download" => "https://example.com/wp.zip"];
var_dump($u->should_update("core", $item, ABSPATH));'

# パスワードの強度
wp user create weakpw weakpw@example.com --user_pass=1234   # 作成できてしまう
wp user delete weakpw --yes

wp_is_auto_update_enabled_for_type() は本体の判定に使えません。 この関数は plugin と theme しか扱わず、core を渡すと常に false を返します。 本体は WP_Automatic_Updater::should_update() で測ります。