diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index a8ae9fb..db195d2 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -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 ) ) { diff --git a/includes/class-tmdo-crypto.php b/includes/class-tmdo-crypto.php index dddf61f..c416790 100644 --- a/includes/class-tmdo-crypto.php +++ b/includes/class-tmdo-crypto.php @@ -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 ); } diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 3f6ba30..0aaccca 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -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 diff --git a/includes/integrations/class-tmdo-member-fields.php b/includes/integrations/class-tmdo-member-fields.php index e4ed84a..785a4df 100644 --- a/includes/integrations/class-tmdo-member-fields.php +++ b/includes/integrations/class-tmdo-member-fields.php @@ -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', + ), ) ); }