Skip to content

Instantly share code, notes, and snippets.

@patrickfreitasdev
Created July 19, 2026 01:10
Show Gist options
  • Select an option

  • Save patrickfreitasdev/14ac6a0f1433d1246da9ff619b3d1f62 to your computer and use it in GitHub Desktop.

Select an option

Save patrickfreitasdev/14ac6a0f1433d1246da9ff619b3d1f62 to your computer and use it in GitHub Desktop.
[Defender Pro] Cron and CLI debug
<?php
/**
* Plugin Name: [Defender Pro] WP-CLI / deactivation debug (Rev.1)
* Description: Captures WP-CLI runtime details and full backtraces when Defender is deactivated. Drop into wp-content/mu-plugins/. Adapted from https://gist.github.com/wpmudev-sls/3600f4c2f5e1c93d4b4afa81b0423f4c
* Author: WPMU DEV Support
* License: GPLv2 or later
*
* IMPORTANT: Must live in mu-plugins/ so it loads before Defender. If deactivate_plugins()
* runs during wp-defender.php include, a normal plugins_loaded hook is too late.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class WPMUDEV_Defender_CLI_Debug {
private const OPTION_CLI = 'wd_cli_info';
private const OPTION_CRON = 'wd_cron_info';
private const OPTION_LOG = 'wd_defender_deactivate_log';
private const MAX_LOG = 20;
private const DEFENDER_SLUGS = array(
'wp-defender/wp-defender.php',
'defender-security/wp-defender.php',
);
/** @var self|null */
private static $instance = null;
public static function get(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
// Capture every WP-CLI bootstrap (before / while plugins load).
if ( defined( 'WP_CLI' ) && WP_CLI ) {
$this->store_cli_snapshot( 'bootstrap' );
}
// Same idea as the cron debug MU-plugin, kept for comparison.
add_action(
'plugins_loaded',
function () {
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
$this->store_cron_snapshot();
}
},
1
);
// Fires inside deactivate_plugins() — backtrace still includes the caller.
add_action( 'deactivated_plugin', array( $this, 'on_deactivated_plugin' ), 0, 2 );
add_action( 'admin_menu', array( $this, 'admin_menu' ), 11 );
}
/**
* @param string $plugin Plugin basename.
* @param bool $network_deactivating Network deactivation flag.
*/
public function on_deactivated_plugin( $plugin, $network_deactivating = false ): void {
if ( ! in_array( (string) $plugin, self::DEFENDER_SLUGS, true ) ) {
return;
}
$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 40 );
$frames = array();
foreach ( $trace as $frame ) {
$file = isset( $frame['file'] ) ? $frame['file'] : '[internal]';
$line = isset( $frame['line'] ) ? $frame['line'] : 0;
$fn = '';
if ( ! empty( $frame['class'] ) ) {
$fn = $frame['class'] . ( $frame['type'] ?? '::' ) . ( $frame['function'] ?? '' );
} else {
$fn = $frame['function'] ?? '';
}
$frames[] = sprintf( '%s() @ %s:%d', $fn, $file, $line );
}
$entry = array(
'time' => gmdate( 'Y-m-d H:i:s' ) . ' UTC',
'plugin' => $plugin,
'network_deactivating' => (bool) $network_deactivating,
'php_version' => PHP_VERSION,
'php_sapi' => PHP_SAPI,
'php_binary' => defined( 'PHP_BINARY' ) ? PHP_BINARY : '',
'wp_cli' => defined( 'WP_CLI' ) && WP_CLI,
'wp_cli_version' => $this->wp_cli_version(),
'doing_cron' => function_exists( 'wp_doing_cron' ) && wp_doing_cron(),
'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0,
'request_uri' => isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '',
'argv' => ( PHP_SAPI === 'cli' && isset( $GLOBALS['argv'] ) ) ? $GLOBALS['argv'] : array(),
'defender_constants' => array(
'DEFENDER_PLUGIN_BASENAME' => defined( 'DEFENDER_PLUGIN_BASENAME' ) ? DEFENDER_PLUGIN_BASENAME : null,
'WP_DEFENDER_PRO_PATH' => defined( 'WP_DEFENDER_PRO_PATH' ) ? WP_DEFENDER_PRO_PATH : null,
'WP_DEFENDER_MIN_PHP_VERSION'=> defined( 'WP_DEFENDER_MIN_PHP_VERSION' ) ? WP_DEFENDER_MIN_PHP_VERSION : null,
'WP_DEFENDER_PRO' => defined( 'WP_DEFENDER_PRO' ) ? WP_DEFENDER_PRO : null,
'DEFENDER_VERSION' => defined( 'DEFENDER_VERSION' ) ? DEFENDER_VERSION : null,
),
'active_plugins' => (array) get_option( 'active_plugins', array() ),
'call_chain' => $frames,
);
$log = get_site_option( self::OPTION_LOG, array() );
if ( ! is_array( $log ) ) {
$log = array();
}
array_unshift( $log, $entry );
$log = array_slice( $log, 0, self::MAX_LOG );
update_site_option( self::OPTION_LOG, $log );
// Also mirror into error_log for hosts that only expose that.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( '[Defender deactivate debug] ' . wp_json_encode( $entry ) );
}
private function store_cli_snapshot( string $phase ): void {
$info = array(
'phase' => $phase,
'time' => gmdate( 'Y-m-d H:i:s' ) . ' UTC',
'php_version' => PHP_VERSION,
'php_version_id' => PHP_VERSION_ID,
'php_sapi' => PHP_SAPI,
'php_binary' => defined( 'PHP_BINARY' ) ? PHP_BINARY : '',
'php_ini' => function_exists( 'php_ini_loaded_file' ) ? (string) php_ini_loaded_file() : '',
'wp_cli_version' => $this->wp_cli_version(),
'argv' => isset( $GLOBALS['argv'] ) ? $GLOBALS['argv'] : array(),
'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0,
);
update_site_option( self::OPTION_CLI, $info );
}
private function store_cron_snapshot(): void {
update_option(
self::OPTION_CRON,
array(
'php_version' => PHP_VERSION,
'php_version_id' => PHP_VERSION_ID,
'php_sapi' => PHP_SAPI,
'php_binary' => defined( 'PHP_BINARY' ) ? PHP_BINARY : '',
'time' => gmdate( 'Y-m-d H:i:s' ) . ' UTC',
'server' => isset( $_SERVER ) ? $_SERVER : array(),
)
);
}
private function wp_cli_version(): string {
if ( defined( 'WP_CLI_VERSION' ) ) {
return (string) WP_CLI_VERSION;
}
if ( class_exists( '\WP_CLI' ) && defined( '\WP_CLI::VERSION' ) ) {
return (string) \WP_CLI::VERSION;
}
return '';
}
public function admin_menu(): void {
add_menu_page(
'Defender CLI Debug',
'Defender CLI Debug',
'manage_options',
'wd_cli_debug',
array( $this, 'admin_debug_page' )
);
}
public function admin_debug_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$cli = get_site_option( self::OPTION_CLI );
$cron = get_option( self::OPTION_CRON );
$log = get_site_option( self::OPTION_LOG, array() );
$cli_php = is_array( $cli ) ? ( $cli['php_version'] ?? null ) : null;
$cron_php = is_array( $cron ) ? ( $cron['php_version'] ?? null ) : null;
$cli_ok = $cli_php ? version_compare( $cli_php, '8.0.0', '>=' ) : null;
$cron_ok = $cron_php ? version_compare( $cron_php, '8.0.0', '>=' ) : null;
?>
<div style="max-width:900px;background:#fff;border:1px solid #dadada;padding:0 20px 20px;margin-top:20px;">
<h1>Defender WP-CLI / deactivation debug</h1>
<p>Must-use plugin. Records CLI/cron PHP snapshots and full call chains when Defender is deactivated.</p>
<h2>WP-CLI snapshot</h2>
<?php if ( empty( $cli ) ) : ?>
<p><strong style="color:#df901c">Waiting for WP-CLI request</strong></p>
<p>Run any WP-CLI command that loads WordPress, e.g. <code>wp plugin list</code>, then refresh.</p>
<?php else : ?>
<p>
Status:
<strong style="color:<?php echo $cli_ok ? 'green' : 'red'; ?>">
<?php echo $cli_ok ? 'Compatible' : 'Not compatible'; ?>
</strong>
</p>
<p>CLI PHP: <strong><?php echo esc_html( (string) $cli_php ); ?></strong></p>
<p>Binary: <code><?php echo esc_html( (string) ( $cli['php_binary'] ?? '' ) ); ?></code></p>
<p>SAPI: <code><?php echo esc_html( (string) ( $cli['php_sapi'] ?? '' ) ); ?></code></p>
<p>WP-CLI: <code><?php echo esc_html( (string) ( $cli['wp_cli_version'] ?? '' ) ); ?></code></p>
<p>Last argv: <code><?php echo esc_html( wp_json_encode( $cli['argv'] ?? array() ) ); ?></code></p>
<p>Captured: <code><?php echo esc_html( (string) ( $cli['time'] ?? '' ) ); ?></code></p>
<?php endif; ?>
<hr />
<h2>Cron snapshot (same check as Rev.2 cron debug)</h2>
<?php if ( empty( $cron ) ) : ?>
<p><strong style="color:#df901c">Waiting for cron request</strong></p>
<?php else : ?>
<p>
Status:
<strong style="color:<?php echo $cron_ok ? 'green' : 'red'; ?>">
<?php echo $cron_ok ? 'Compatible' : 'Not compatible'; ?>
</strong>
</p>
<p>Cron PHP: <strong><?php echo esc_html( (string) $cron_php ); ?></strong></p>
<p>Captured: <code><?php echo esc_html( (string) ( $cron['time'] ?? '' ) ); ?></code></p>
<?php endif; ?>
<hr />
<h2>Defender deactivation log</h2>
<?php if ( empty( $log ) || ! is_array( $log ) ) : ?>
<p>No Defender deactivations captured yet.</p>
<?php else : ?>
<?php foreach ( $log as $i => $entry ) : ?>
<details <?php echo 0 === (int) $i ? 'open' : ''; ?> style="margin-bottom:12px;">
<summary>
<strong><?php echo esc_html( (string) ( $entry['time'] ?? '' ) ); ?></strong>
<?php echo ! empty( $entry['wp_cli'] ) ? 'WP-CLI' : ( ! empty( $entry['doing_cron'] ) ? 'cron' : 'web' ); ?>
PHP <?php echo esc_html( (string) ( $entry['php_version'] ?? '' ) ); ?>
</summary>
<pre style="white-space:pre-wrap;background:#f6f7f7;padding:12px;overflow:auto;"><?php echo esc_html( wp_json_encode( $entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); ?></pre>
</details>
<?php endforeach; ?>
<?php endif; ?>
<hr />
<p style="margin-bottom:0"><strong>Note:</strong> Defender requires PHP ≥ 8.0.0. With CLI already on 8.4, the next deactivation’s <code>call_chain</code> is the important signal (PHP guard vs free/pro conflict vs something else).</p>
</div>
<?php
}
}
WPMUDEV_Defender_CLI_Debug::get();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment