feat(diagnostics): crypto 健檢 + backup 目錄探測 + 15 個 usermeta 欄位(PR-G)

- Crypto::is_key_derivable()(A v3.3.2):AUTH_KEY / SECURE_AUTH_SALT 皆缺
  時回 false,讓金鑰推導優雅短路
- wp tmdo doctor 新增兩項健檢:
  · backup 目錄 HTTP 可及性探測(200 = 紅旗並附 nginx 設定建議,
    403/404 = OK,0 = 離線時退回檢查 .htaccess)(A v3.1.9)
  · crypto 金鑰可推導性(缺常數時警告 notifier secrets 會以明文儲存)
- doctor_callback 呼叫簽章 3 參數 → 1 參數(A v3.0.3 修復)。**ABI 變更**:
  AddOn 註冊的 callback 若依賴 rows / full_name 需自行調整
- Member_Fields::register_admin_prefs_group() 由 7 欄擴到 22 欄(A v3.1.4),
  補上 wp_user_level / show_welcome_panel / wp_persisted_preferences /
  wp_user-settings / community-events-location 等 15 個 WP 原生 usermeta,
  這些先前全部落在 wp_usermeta
- capture_before_value 預設 true → false(A v3.1.5):每次受管寫入省一次
  DB read。**行為變更**:需要 value_before 的消費者(audit log)要
  add_filter( 'wpdo_capture_before_value', '__return_true' ) 明確開啟

unit 451 / integration 398 GREEN

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 06:11:52 +08:00
parent 56d534619e
commit d6c1540444
4 changed files with 168 additions and 10 deletions
+51 -3
View File
@@ -258,12 +258,13 @@ class TMDO_CLI {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_name}`" );
// Optional doctor_callback.
// 傳入 table_suffix + rows + full_name 作為 context;多餘參數對 callable 無害。
// Optional doctor_callback. Contract is a single argument: the raw
// table suffix as registered (A v3.0.3 fix — passing rows/full_name
// broke partner callbacks with a stricter signature).
$cb = $cfg['doctor_callback'] ?? null;
if ( is_callable( $cb ) ) {
try {
$result = call_user_func( $cb, $cfg['table_name'], $rows, $full_name );
$result = call_user_func( $cb, $tbl_raw );
$cb_ok = (bool) ( $result['ok'] ?? true );
$cb_msg = (string) ( $result['message'] ?? '' );
$status = $cb_ok ? '[OK]' : '[WARN]';
@@ -280,6 +281,53 @@ class TMDO_CLI {
}
}
// ── Backup directory security check ─────────────────────────────────────
$backup_dir = TMDO_Snapshot_Manager::backup_dir();
if ( file_exists( $backup_dir ) && is_dir( $backup_dir ) ) {
$htaccess_ok = file_exists( $backup_dir . '/.htaccess' );
$has_sql_file = (bool) glob( $backup_dir . '/*.sql' );
// Probe via HTTP — a properly blocked dir returns 403/404; 200 is a red flag.
$probe_url = trailingslashit( wp_upload_dir()['baseurl'] ) . TMDO_Snapshot_Manager::BACKUP_DIR_NAME . '/index.php';
$response = wp_remote_get(
$probe_url,
array(
'timeout' => 5,
'user-agent' => 'TMDO-Doctor/1.0',
'sslverify' => false,
)
);
$http_code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
if ( 200 === $http_code ) {
WP_CLI::warning( " [WARN] Backup dir is HTTP-accessible ({$probe_url} → 200)." );
WP_CLI::warning( ' For nginx, add: location ~* /wpdo-backups/ { deny all; }' );
$all_ok = false;
} elseif ( in_array( $http_code, array( 403, 404 ), true ) ) {
WP_CLI::log( " [OK] Backup dir blocked (HTTP {$http_code})" );
} elseif ( 0 === $http_code ) {
if ( $htaccess_ok ) {
WP_CLI::log( ' [OK] Backup dir has .htaccess deny rule (HTTP probe failed — offline or CLI-only mode)' );
} else {
WP_CLI::warning( ' [WARN] Backup dir missing .htaccess — run `wp tmdo install` to regenerate.' );
$all_ok = false;
}
}
if ( $has_sql_file && ! $htaccess_ok ) {
WP_CLI::warning( ' [WARN] SQL backup files present but .htaccess missing.' );
$all_ok = false;
}
}
// ── Crypto key health check ─────────────────────────────────────────────
if ( ! TMDO_Crypto::is_key_derivable() ) {
WP_CLI::warning( ' [WARN] AUTH_KEY and SECURE_AUTH_SALT are both absent or empty — TMDO_Crypto cannot derive an encryption key. Notifier secrets will be stored as plaintext. Set these constants in wp-config.php.' );
$all_ok = false;
} else {
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
}
// Check recent errors.
$errors = TMDO_Logger::get_recent( '', 5 );
if ( ! empty( $errors ) ) {
+30 -6
View File
@@ -23,7 +23,7 @@
* TMDO_Crypto::set_option( 'wpdo_slack_webhook', $url ); // writes v2
* $url = TMDO_Crypto::get_option( 'wpdo_slack_webhook' ); // reads v1 or v2
*
* @package WP_Data_Optimizer
* @package TMDO
* @since 2.6.4 (v1 CBC)
* @since 2.15.0 (v2 GCM, AEAD authentication)
*/
@@ -65,17 +65,34 @@ class TMDO_Crypto {
* elsewhere. Both v1 and v2 use the same derived key (same secret material,
* different cipher) so v1 ciphertext can be read after the v2 upgrade.
*
* Falls back to a sha256 of ABSPATH when constants are not defined
* (unit-test environments). The fallback must be stable per request.
* Returns empty string when both AUTH_KEY and SECURE_AUTH_SALT are absent —
* callers treat '' as "encryption unavailable" and store plaintext instead.
*
* @return string 32 raw bytes.
*/
private static function derived_key(): string {
$salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : '';
$salt .= defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : ABSPATH;
$has_auth_key = defined( 'AUTH_KEY' ) && AUTH_KEY !== '';
$has_auth_salt = defined( 'SECURE_AUTH_SALT' ) && SECURE_AUTH_SALT !== '';
if ( ! $has_auth_key && ! $has_auth_salt ) {
// Both salts missing — key would be derived from predictable ABSPATH. Refuse.
return '';
}
$salt = $has_auth_key ? AUTH_KEY : '';
$salt .= $has_auth_salt ? SECURE_AUTH_SALT : '';
return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
}
/**
* Whether the site has valid WP auth constants for key derivation.
*
* @return bool False when both AUTH_KEY and SECURE_AUTH_SALT are absent/empty.
*/
public static function is_key_derivable(): bool {
return self::derived_key() !== '';
}
/**
* Encrypt a plaintext string with AES-256-GCM (v2 format).
*
@@ -89,13 +106,17 @@ class TMDO_Crypto {
if ( ! function_exists( 'openssl_encrypt' ) ) {
return $plaintext;
}
$key = self::derived_key();
if ( '' === $key ) {
return $plaintext;
}
$iv = random_bytes( self::IV_LEN_V2 );
$tag = '';
// phpcs:ignore -- $tag is reference output for GCM auth tag.
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER_V2,
self::derived_key(),
$key,
OPENSSL_RAW_DATA,
$iv,
$tag,
@@ -127,6 +148,9 @@ class TMDO_Crypto {
if ( ! function_exists( 'openssl_decrypt' ) ) {
return $stored;
}
if ( '' === self::derived_key() ) {
return $stored;
}
if ( str_starts_with( $stored, self::PREFIX_V2 ) ) {
return self::decrypt_v2( $stored );
}
+5 -1
View File
@@ -182,9 +182,13 @@ final class TMDO_Hook_Bus {
array $field_def,
string $op
) {
// Default false since v3.1.5 (A): reading the previous flat value costs an
// extra DB read on every managed write. Sites that consume value_before —
// e.g. the audit log — must opt in:
// add_filter( 'wpdo_capture_before_value', '__return_true' );
$capture = apply_filters(
'wpdo_capture_before_value',
true,
false,
$type,
$meta_key,
$op
@@ -497,6 +497,88 @@ final class TMDO_Member_Fields {
'type' => 'textarea',
'label' => 'Comma-separated dismissed pointer IDs',
),
array(
'key' => 'wp_user_level',
'type' => 'integer',
'label' => 'WP legacy user level (0-10); written by WP on every role change',
),
// WP admin-UI prefs written on first dashboard visit or explicit user action.
array(
'key' => 'show_welcome_panel',
'type' => 'text',
'label' => 'Dashboard welcome panel visibility (true/false/1/0)',
),
array(
'key' => 'wp_persisted_preferences',
'type' => 'textarea',
'label' => 'Block editor persisted preferences (JSON blob)',
),
array(
'key' => 'nav_menu_recently_edited',
'type' => 'text',
'label' => 'ID of nav menu most recently edited',
),
array(
'key' => 'wp_dashboard_quick_press_last_post_id',
'type' => 'integer',
'label' => 'Post ID from last Quick Draft save',
),
array(
'key' => 'edit_page_per_page',
'type' => 'integer',
'label' => 'Rows-per-page in Pages list table',
),
array(
'key' => 'edit_post_per_page',
'type' => 'integer',
'label' => 'Rows-per-page in Posts list table',
),
array(
'key' => 'edit_hp_listing_per_page',
'type' => 'integer',
'label' => 'Rows-per-page in hp_listing list table',
),
// Keys with hyphens: sanitize_column_name() strips hyphens entirely.
// community-events-location → communityeventslocation (column name)
// wp_user-settings → wpusersettings
// wp_user-settings-time → wpusersettingstime
// managenav-menuscolumnshidden → managenavmenuscolumnshidden
// metaboxhidden_nav-menus → metaboxhidden_navmenus.
array(
'key' => 'community-events-location',
'type' => 'textarea',
'label' => 'Dashboard community-events saved location (JSON); column: communityeventslocation',
),
array(
'key' => 'wp_user-settings',
'type' => 'text',
'label' => 'WP admin UI settings string; column: wpusersettings',
),
array(
'key' => 'wp_user-settings-time',
'type' => 'integer',
'label' => 'Timestamp when wp_user-settings was last written; column: wpusersettingstime',
),
array(
'key' => 'managenav-menuscolumnshidden',
'type' => 'text',
'label' => 'Hidden columns in nav-menus screen; column: managenavmenuscolumnshidden',
),
array(
'key' => 'metaboxhidden_nav-menus',
'type' => 'text',
'label' => 'Hidden meta-boxes on nav-menus screen; column: metaboxhidden_navmenus',
),
array(
'key' => 'dismissed_no_secure_connection_notice',
'type' => 'text',
'label' => 'Admin dismissed "no secure connection" notice (1/empty)',
),
array(
'key' => 'meta-box-order_product',
'type' => 'textarea',
'label' => 'Meta-box order on WC Products screen (serialized); column: metaboxorder_product',
),
)
);
}