ARGUS WordPress Defence 1.0.0 — first production release
Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Actions {
|
||||
|
||||
public static function run( $type, array $params ) {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'You do not have permission to do this.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
switch ( $type ) {
|
||||
case 'update_core':
|
||||
return self::update_core();
|
||||
case 'update_plugin':
|
||||
return self::update_plugin( $params['plugin'] ?? '' );
|
||||
case 'update_theme':
|
||||
return self::update_theme( $params['theme'] ?? '' );
|
||||
case 'restore_quarantined_file':
|
||||
return self::restore_quarantined_file( $params['path'] ?? '' );
|
||||
default:
|
||||
return array( 'success' => false, 'message' => __( 'Unknown action.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
}
|
||||
|
||||
protected static function restore_quarantined_file( $rel_path ) {
|
||||
if ( '' === $rel_path || Argus_Malware_Scanner::QUARANTINE_SUFFIX !== substr( $rel_path, -strlen( Argus_Malware_Scanner::QUARANTINE_SUFFIX ) ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'Not a quarantined file.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$uploads = wp_get_upload_dir();
|
||||
$base = wp_normalize_path( trailingslashit( ABSPATH ) );
|
||||
$abs = wp_normalize_path( ABSPATH . ltrim( $rel_path, '/' ) );
|
||||
$uploads_base = wp_normalize_path( trailingslashit( $uploads['basedir'] ?? '' ) );
|
||||
|
||||
if ( '' === $uploads_base || 0 !== strpos( $abs, $uploads_base ) || false !== strpos( $rel_path, '..' ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'Refusing to restore a path outside the uploads directory.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
if ( ! file_exists( $abs ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'That file no longer exists.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$restored_abs = substr( $abs, 0, -strlen( Argus_Malware_Scanner::QUARANTINE_SUFFIX ) );
|
||||
if ( file_exists( $restored_abs ) || ! @rename( $abs, $restored_abs ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
return array( 'success' => false, 'message' => __( 'Could not restore the file (a file already exists at the original name, or the rename failed).', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$restored_rel = str_replace( $base, '', $restored_abs );
|
||||
|
||||
Argus_Malware_Scanner::mark_restored_trusted( $restored_rel, hash_file( 'sha256', $restored_abs ) );
|
||||
|
||||
Argus_Events::record( 'action_completed', 'info', 'Restored quarantined file: ' . $restored_rel, array( 'type' => 'restore_quarantined_file', 'path' => $restored_rel ) );
|
||||
return array( 'success' => true, 'message' => sprintf( __( 'Restored %s to its original name.', 'argus-wordpress-defence' ), $restored_rel ) );
|
||||
}
|
||||
|
||||
protected static function update_core() {
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/misc.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/template.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/update.php';
|
||||
|
||||
wp_version_check();
|
||||
$updates = get_core_updates();
|
||||
if ( empty( $updates ) || 'upgrade' !== ( $updates[0]->response ?? '' ) ) {
|
||||
return array( 'success' => true, 'message' => __( 'WordPress core is already up to date.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$upgrader = new Core_Upgrader();
|
||||
$result = $upgrader->upgrade( $updates[0] );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
Argus_Events::record( 'action_failed', 'medium', 'Core update failed: ' . $result->get_error_message(), array( 'type' => 'update_core' ) );
|
||||
return array( 'success' => false, 'message' => $result->get_error_message() );
|
||||
}
|
||||
|
||||
Argus_Events::record( 'action_completed', 'info', 'Updated WordPress core to ' . $updates[0]->version, array( 'type' => 'update_core', 'version' => $updates[0]->version ) );
|
||||
return array( 'success' => true, 'message' => sprintf( __( 'Updated WordPress core to %s.', 'argus-wordpress-defence' ), $updates[0]->version ) );
|
||||
}
|
||||
|
||||
protected static function update_plugin( $plugin_file ) {
|
||||
if ( '' === $plugin_file ) {
|
||||
return array( 'success' => false, 'message' => __( 'Missing plugin.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/misc.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/template.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/update.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
|
||||
wp_update_plugins();
|
||||
|
||||
$upgrader = new Plugin_Upgrader();
|
||||
$result = $upgrader->upgrade( $plugin_file );
|
||||
|
||||
if ( is_wp_error( $result ) || false === $result ) {
|
||||
$message = is_wp_error( $result ) ? $result->get_error_message() : __( 'Plugin update failed.', 'argus-wordpress-defence' );
|
||||
Argus_Events::record( 'action_failed', 'medium', 'Plugin update failed: ' . $message, array( 'type' => 'update_plugin', 'plugin' => $plugin_file ) );
|
||||
return array( 'success' => false, 'message' => $message );
|
||||
}
|
||||
|
||||
Argus_Events::record( 'action_completed', 'info', 'Updated plugin ' . $plugin_file, array( 'type' => 'update_plugin', 'plugin' => $plugin_file ) );
|
||||
return array( 'success' => true, 'message' => __( 'Plugin updated.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
protected static function update_theme( $stylesheet ) {
|
||||
if ( '' === $stylesheet ) {
|
||||
return array( 'success' => false, 'message' => __( 'Missing theme.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/misc.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/template.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/update.php';
|
||||
|
||||
wp_update_themes();
|
||||
|
||||
$upgrader = new Theme_Upgrader();
|
||||
$result = $upgrader->upgrade( $stylesheet );
|
||||
|
||||
if ( is_wp_error( $result ) || false === $result ) {
|
||||
$message = is_wp_error( $result ) ? $result->get_error_message() : __( 'Theme update failed.', 'argus-wordpress-defence' );
|
||||
Argus_Events::record( 'action_failed', 'medium', 'Theme update failed: ' . $message, array( 'type' => 'update_theme', 'theme' => $stylesheet ) );
|
||||
return array( 'success' => false, 'message' => $message );
|
||||
}
|
||||
|
||||
Argus_Events::record( 'action_completed', 'info', 'Updated theme ' . $stylesheet, array( 'type' => 'update_theme', 'theme' => $stylesheet ) );
|
||||
return array( 'success' => true, 'message' => __( 'Theme updated.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Activator {
|
||||
|
||||
public static function activate() {
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-db.php';
|
||||
Argus_DB::install();
|
||||
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-mu-installer.php';
|
||||
Argus_MU_Installer::install();
|
||||
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-malware-scanner.php';
|
||||
Argus_Malware_Scanner::ensure_uploads_lockdown();
|
||||
|
||||
self::schedule_cron();
|
||||
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-anis-client.php';
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-ban-engine.php';
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-events.php';
|
||||
if ( Argus_ANIS_Client::is_enabled() ) {
|
||||
$result = Argus_ANIS_Client::register();
|
||||
if ( $result['success'] ) {
|
||||
Argus_ANIS_Client::scheduled_sync();
|
||||
}
|
||||
}
|
||||
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-vuln-intel.php';
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-findings.php';
|
||||
require_once ARGUS_WPD_DIR . 'includes/class-argus-explain.php';
|
||||
if ( Argus_Settings::get( 'vuln_intel_enabled', true ) ) {
|
||||
Argus_Vuln_Intel::scheduled_check();
|
||||
}
|
||||
|
||||
update_option( 'argus_wpd_activated_at', current_time( 'mysql', true ), false );
|
||||
}
|
||||
|
||||
protected static function schedule_cron() {
|
||||
|
||||
add_filter( 'cron_schedules', array( 'Argus_Plugin', 'register_cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval
|
||||
|
||||
if ( ! wp_next_scheduled( 'argus_wpd_hourly' ) ) {
|
||||
wp_schedule_event( time(), 'hourly', 'argus_wpd_hourly' );
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'argus_wpd_daily' ) ) {
|
||||
wp_schedule_event( time(), 'daily', 'argus_wpd_daily' );
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'argus_wpd_five_minutes' ) ) {
|
||||
wp_schedule_event( time(), 'argus_wpd_five_minutes', 'argus_wpd_five_minutes' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_AJAX_Inventory {
|
||||
|
||||
public static function snapshot() {
|
||||
global $wp_filter;
|
||||
|
||||
if ( empty( $wp_filter ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$nopriv_actions = array();
|
||||
|
||||
foreach ( $wp_filter as $hook_name => $hook_obj ) {
|
||||
if ( 0 !== strpos( (string) $hook_name, 'wp_ajax_nopriv_' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$action = substr( (string) $hook_name, strlen( 'wp_ajax_nopriv_' ) );
|
||||
$plugin = self::owning_plugin_for_hook( $hook_obj );
|
||||
|
||||
$nopriv_actions[] = array( 'action' => $action, 'plugin' => $plugin );
|
||||
}
|
||||
|
||||
update_option( 'argus_wpd_ajax_inventory', array( 'checked_at' => current_time( 'mysql', true ), 'nopriv_actions' => $nopriv_actions ), false );
|
||||
|
||||
if ( ! empty( $nopriv_actions ) ) {
|
||||
Argus_Findings::record(
|
||||
'ajax_inventory',
|
||||
'info',
|
||||
array(
|
||||
|
||||
'what_happened' => 'Unauthenticated-reachable AJAX actions inventoried',
|
||||
'why_it_matters' => 'wp_ajax_nopriv_* handlers are callable by anyone, logged in or not -- this is a completely normal, widely-used WordPress mechanism (contact forms, AJAX search, and many other legitimate features all use it), not inherently a problem.',
|
||||
'what_argus_found' => sprintf( '%d action(s) found: %s', count( $nopriv_actions ), implode( ', ', array_map( function ( $a ) { return $a['action'] . ( $a['plugin'] ? ' (' . $a['plugin'] . ')' : '' ); }, $nopriv_actions ) ) ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'This is an inventory, not a detection -- shown so you know your site\'s full unauthenticated attack surface in one place.',
|
||||
'what_could_be_affected' => 'Depends on what each handler does -- review any you don\'t recognize.',
|
||||
'what_should_you_do' => 'No action needed unless you see an action name you don\'t recognize from a plugin you don\'t remember installing.',
|
||||
),
|
||||
array( 'nopriv_actions' => $nopriv_actions )
|
||||
);
|
||||
}
|
||||
|
||||
return $nopriv_actions;
|
||||
}
|
||||
|
||||
protected static function owning_plugin_for_hook( $hook_obj ) {
|
||||
if ( ! isset( $hook_obj->callbacks ) || ! is_array( $hook_obj->callbacks ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ( $hook_obj->callbacks as $priority_group ) {
|
||||
foreach ( $priority_group as $cb ) {
|
||||
$callback = $cb['function'] ?? null;
|
||||
$plugin = Argus_REST_Inventory::owning_plugin( $callback );
|
||||
if ( $plugin ) {
|
||||
return $plugin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_ANIS_Client {
|
||||
|
||||
const STATUS_OPTION = 'argus_wpd_anis_status';
|
||||
const TIER_OPTION = 'argus_wpd_anis_tier';
|
||||
const LAST_ERROR_OPTION = 'argus_wpd_anis_last_error';
|
||||
const LAST_REGISTER_OPTION = 'argus_wpd_anis_last_register';
|
||||
|
||||
const LAST_SYNC_OPTION = 'argus_wpd_anis_last_sync';
|
||||
const LAST_SUCCESS_OPTION = 'argus_wpd_anis_last_success';
|
||||
const RETRY_STAGE_OPTION = 'argus_wpd_anis_retry_stage';
|
||||
const NEXT_RETRY_AT_OPTION = 'argus_wpd_anis_next_retry_at';
|
||||
const SYNC_WATERMARK_OPTION = 'argus_wpd_anis_sync_watermark';
|
||||
const FEATURES_OPTION = 'argus_wpd_anis_features';
|
||||
|
||||
const SINCE_FORMAT = 'Y-m-d H:i:s';
|
||||
|
||||
const DELTA_RETENTION_DAYS = 7;
|
||||
const SAFETY_MARGIN_DAYS = 6;
|
||||
const DRIFT_CORRECTION_HOURS = 24;
|
||||
|
||||
const REPORT_SCENARIOS = array( 'http_crawl', 'brute_force', 'waf_block', 'scanner', 'exploit_attempt', 'spam' );
|
||||
|
||||
const CIRCUIT_OPTION = 'argus_wpd_anis_circuit';
|
||||
const CIRCUIT_FAILURE_THRESHOLD = 3;
|
||||
const CIRCUIT_COOLDOWN_SECS = 300;
|
||||
|
||||
const AUTO_BAN_CONFIDENCE_THRESHOLD = 70;
|
||||
|
||||
const MAX_LOCAL_REPUTATION_ROWS = 50000;
|
||||
|
||||
public static function init() {
|
||||
add_action( 'argus_wpd_ip_banned', array( __CLASS__, 'maybe_report' ), 10, 4 );
|
||||
}
|
||||
|
||||
public static function is_configured() {
|
||||
return '' !== trim( self::base_url() );
|
||||
}
|
||||
|
||||
public static function is_enabled() {
|
||||
if ( defined( 'ARGUS_WPD_ANIS_DISABLED' ) && ARGUS_WPD_ANIS_DISABLED ) {
|
||||
return false;
|
||||
}
|
||||
return (bool) Argus_Settings::get( 'anis_enabled', true ) && self::is_configured();
|
||||
}
|
||||
|
||||
public static function is_connected() {
|
||||
return self::is_enabled() && 'registered' === get_option( self::STATUS_OPTION, '' );
|
||||
}
|
||||
|
||||
protected static function circuit_state() {
|
||||
return wp_parse_args(
|
||||
get_option( self::CIRCUIT_OPTION, array() ),
|
||||
array( 'state' => 'closed', 'failures' => 0, 'opened_at' => 0 )
|
||||
);
|
||||
}
|
||||
|
||||
protected static function circuit_allows_request() {
|
||||
$circuit = self::circuit_state();
|
||||
|
||||
if ( 'closed' === $circuit['state'] ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( 'open' === $circuit['state'] ) {
|
||||
if ( time() - (int) $circuit['opened_at'] >= self::CIRCUIT_COOLDOWN_SECS ) {
|
||||
|
||||
$circuit['state'] = 'half_open';
|
||||
update_option( self::CIRCUIT_OPTION, $circuit, false );
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected static function circuit_record_success() {
|
||||
update_option( self::CIRCUIT_OPTION, array( 'state' => 'closed', 'failures' => 0, 'opened_at' => 0 ), false );
|
||||
}
|
||||
|
||||
protected static function circuit_record_failure() {
|
||||
$circuit = self::circuit_state();
|
||||
$failures = (int) $circuit['failures'] + 1;
|
||||
|
||||
if ( 'half_open' === $circuit['state'] || $failures >= self::CIRCUIT_FAILURE_THRESHOLD ) {
|
||||
if ( 'open' !== $circuit['state'] ) {
|
||||
Argus_Events::record( 'anis_circuit_open', 'medium', 'ANIS appears unreachable -- pausing outbound requests temporarily.', array( 'failures' => $failures ) );
|
||||
}
|
||||
update_option( self::CIRCUIT_OPTION, array( 'state' => 'open', 'failures' => $failures, 'opened_at' => time() ), false );
|
||||
return;
|
||||
}
|
||||
|
||||
update_option( self::CIRCUIT_OPTION, array( 'state' => 'closed', 'failures' => $failures, 'opened_at' => 0 ), false );
|
||||
}
|
||||
|
||||
public static function circuit_is_open() {
|
||||
return 'open' === self::circuit_state()['state'];
|
||||
}
|
||||
|
||||
protected static function base_url() {
|
||||
if ( defined( 'ARGUS_WPD_ANIS_BASE_URL' ) && ARGUS_WPD_ANIS_BASE_URL ) {
|
||||
return untrailingslashit( ARGUS_WPD_ANIS_BASE_URL );
|
||||
}
|
||||
return untrailingslashit( (string) Argus_Settings::get( 'anis_base_url', '' ) );
|
||||
}
|
||||
|
||||
protected static function license_key() {
|
||||
if ( defined( 'ARGUS_WPD_ANIS_LICENSE_KEY' ) && ARGUS_WPD_ANIS_LICENSE_KEY ) {
|
||||
return trim( ARGUS_WPD_ANIS_LICENSE_KEY );
|
||||
}
|
||||
return trim( (string) Argus_Settings::get( 'anis_license_key', '' ) );
|
||||
}
|
||||
|
||||
const INSTALL_SECRET_OPTION = 'argus_wpd_anis_install_secret';
|
||||
|
||||
public static function instance_hash() {
|
||||
$secret = get_option( self::INSTALL_SECRET_OPTION );
|
||||
if ( ! $secret ) {
|
||||
$secret = bin2hex( random_bytes( 24 ) );
|
||||
update_option( self::INSTALL_SECRET_OPTION, $secret, false );
|
||||
}
|
||||
return substr( hash( 'sha256', $secret ), 0, 16 );
|
||||
}
|
||||
|
||||
public static function register() {
|
||||
if ( ! self::is_configured() ) {
|
||||
return array( 'success' => false, 'message' => __( 'Set an ANIS server address first.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
if ( ! self::circuit_allows_request() ) {
|
||||
return array( 'success' => false, 'message' => __( 'ANIS is temporarily unreachable -- will retry automatically.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$license_key = self::license_key();
|
||||
$body = array( 'instance_hash' => self::instance_hash(), 'version' => ARGUS_WPD_VERSION );
|
||||
if ( '' !== $license_key ) {
|
||||
$body['license_key'] = $license_key;
|
||||
}
|
||||
|
||||
$response = wp_remote_post(
|
||||
self::base_url() . '/api/v1/instances/register',
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => wp_json_encode( $body ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
self::circuit_record_failure();
|
||||
self::record_error( $response->get_error_message() );
|
||||
return array( 'success' => false, 'message' => $response->get_error_message() );
|
||||
}
|
||||
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
self::circuit_record_failure();
|
||||
$message = self::extract_error_message( $response );
|
||||
self::record_error( $message );
|
||||
return array( 'success' => false, 'message' => $message );
|
||||
}
|
||||
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( ! is_array( $data ) || empty( $data['status'] ) ) {
|
||||
self::circuit_record_failure();
|
||||
self::record_error( __( 'ANIS returned an unexpected response.', 'argus-wordpress-defence' ) );
|
||||
return array( 'success' => false, 'message' => __( 'ANIS returned an unexpected response.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
self::circuit_record_success();
|
||||
update_option( self::STATUS_OPTION, 'registered', false );
|
||||
update_option( self::TIER_OPTION, $data['tier'] ?? 'community', false );
|
||||
update_option( self::LAST_REGISTER_OPTION, current_time( 'mysql', true ), false );
|
||||
update_option( self::LAST_ERROR_OPTION, '', false );
|
||||
if ( isset( $data['features'] ) && is_array( $data['features'] ) ) {
|
||||
update_option( self::FEATURES_OPTION, $data['features'], false );
|
||||
}
|
||||
|
||||
Argus_Events::record( 'anis_registered', 'info', sprintf( 'Connected to ANIS (%s tier)', $data['tier'] ?? 'community' ), array( 'tier' => $data['tier'] ?? 'community' ) );
|
||||
|
||||
return array( 'success' => true, 'message' => sprintf( __( 'Connected -- %s tier.', 'argus-wordpress-defence' ), $data['tier'] ?? 'community' ) );
|
||||
}
|
||||
|
||||
protected static function record_error( $message ) {
|
||||
update_option( self::STATUS_OPTION, 'error', false );
|
||||
update_option( self::LAST_ERROR_OPTION, $message, false );
|
||||
}
|
||||
|
||||
protected static function extract_error_message( $response ) {
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( is_array( $body ) && ! empty( $body['message'] ) ) {
|
||||
return sprintf( 'ANIS returned %d: %s', $code, $body['message'] );
|
||||
}
|
||||
return sprintf( 'ANIS returned HTTP %d.', $code );
|
||||
}
|
||||
|
||||
public static function sync() {
|
||||
if ( ! self::is_connected() ) {
|
||||
return array( 'success' => false, 'message' => __( 'Not connected to ANIS.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$watermark = get_option( self::SYNC_WATERMARK_OPTION, '' );
|
||||
$last_full = get_option( self::LAST_SYNC_OPTION, '' );
|
||||
|
||||
$needs_snapshot = '' === $watermark
|
||||
|| ( '' !== $watermark && strtotime( $watermark . ' UTC' ) < strtotime( '-' . self::SAFETY_MARGIN_DAYS . ' days', current_time( 'timestamp', true ) ) )
|
||||
|| ( '' !== $last_full && strtotime( $last_full . ' UTC' ) < strtotime( '-' . self::DRIFT_CORRECTION_HOURS . ' hours', current_time( 'timestamp', true ) ) );
|
||||
|
||||
if ( $needs_snapshot ) {
|
||||
return self::sync_snapshot();
|
||||
}
|
||||
|
||||
$result = self::sync_delta();
|
||||
if ( ! $result['success'] ) {
|
||||
return self::sync_snapshot();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_MINUTES = array( 5, 5, 15, 30 );
|
||||
|
||||
public static function scheduled_sync() {
|
||||
$result = self::sync();
|
||||
if ( $result['success'] ) {
|
||||
self::clear_retry_ladder();
|
||||
} else {
|
||||
self::start_retry_ladder();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected static function start_retry_ladder() {
|
||||
update_option( self::RETRY_STAGE_OPTION, 0, false );
|
||||
update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[0] * MINUTE_IN_SECONDS ), false );
|
||||
}
|
||||
|
||||
protected static function clear_retry_ladder() {
|
||||
delete_option( self::RETRY_STAGE_OPTION );
|
||||
delete_option( self::NEXT_RETRY_AT_OPTION );
|
||||
}
|
||||
|
||||
public static function maybe_retry_sync() {
|
||||
$stage = get_option( self::RETRY_STAGE_OPTION, null );
|
||||
if ( null === $stage ) {
|
||||
return;
|
||||
}
|
||||
if ( time() < (int) get_option( self::NEXT_RETRY_AT_OPTION, 0 ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = self::sync();
|
||||
if ( $result['success'] ) {
|
||||
self::clear_retry_ladder();
|
||||
return;
|
||||
}
|
||||
|
||||
$next_stage = (int) $stage + 1;
|
||||
if ( ! isset( self::RETRY_DELAYS_MINUTES[ $next_stage ] ) ) {
|
||||
self::clear_retry_ladder();
|
||||
return;
|
||||
}
|
||||
update_option( self::RETRY_STAGE_OPTION, $next_stage, false );
|
||||
update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[ $next_stage ] * MINUTE_IN_SECONDS ), false );
|
||||
}
|
||||
|
||||
public static function sync_snapshot() {
|
||||
$response = self::get( '/api/v1/intelligence/decisions' );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array( 'success' => false, 'message' => $response->get_error_message() );
|
||||
}
|
||||
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( ! is_array( $data ) || ! isset( $data['decisions'] ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'ANIS returned an unexpected decisions response.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'anis_reputation' );
|
||||
|
||||
$wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore
|
||||
|
||||
foreach ( (array) $data['decisions'] as $decision ) {
|
||||
self::upsert_decision( $decision );
|
||||
}
|
||||
|
||||
self::enforce_reputation_cap();
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
update_option( self::LAST_SYNC_OPTION, $now, false );
|
||||
update_option( self::LAST_SUCCESS_OPTION, $now, false );
|
||||
update_option( self::SYNC_WATERMARK_OPTION, gmdate( self::SINCE_FORMAT ), false );
|
||||
|
||||
return array( 'success' => true, 'message' => sprintf( __( 'Synced %d decision(s) from ANIS (full snapshot).', 'argus-wordpress-defence' ), count( $data['decisions'] ) ) );
|
||||
}
|
||||
|
||||
public static function sync_delta() {
|
||||
$since = get_option( self::SYNC_WATERMARK_OPTION, '' );
|
||||
if ( '' === $since ) {
|
||||
return array( 'success' => false, 'message' => 'no watermark' );
|
||||
}
|
||||
|
||||
$response = self::get( '/api/v1/intelligence/decisions/delta?since=' . rawurlencode( $since ) );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array( 'success' => false, 'message' => $response->get_error_message() );
|
||||
}
|
||||
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( ! is_array( $data ) || ! isset( $data['events'] ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'ANIS returned an unexpected delta response.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'anis_reputation' );
|
||||
|
||||
foreach ( (array) $data['events'] as $event ) {
|
||||
if ( 'remove' === ( $event['event'] ?? '' ) ) {
|
||||
$wpdb->delete( $table, array( 'ip' => $event['ip'] ?? '' ), array( '%s' ) );
|
||||
continue;
|
||||
}
|
||||
self::upsert_decision( $event );
|
||||
}
|
||||
|
||||
update_option( self::SYNC_WATERMARK_OPTION, gmdate( self::SINCE_FORMAT ), false );
|
||||
update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false );
|
||||
self::enforce_reputation_cap();
|
||||
|
||||
return array( 'success' => true, 'message' => sprintf( __( 'Synced %d change(s) from ANIS (incremental).', 'argus-wordpress-defence' ), count( $data['events'] ) ) );
|
||||
}
|
||||
|
||||
protected static function enforce_reputation_cap() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'anis_reputation' );
|
||||
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
||||
if ( $total <= self::MAX_LOCAL_REPUTATION_ROWS ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overflow = $total - self::MAX_LOCAL_REPUTATION_ROWS;
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$table} ORDER BY confidence ASC, cached_at ASC LIMIT %d", // phpcs:ignore
|
||||
$overflow
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected static function upsert_decision( array $decision ) {
|
||||
if ( empty( $decision['ip'] ) ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'anis_reputation' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$decided_at = ! empty( $decision['created_at'] ) ? "'" . esc_sql( gmdate( 'Y-m-d H:i:s', strtotime( $decision['created_at'] ) ) ) . "'" : 'NULL';
|
||||
$expires_at = ! empty( $decision['expires_at'] ) ? "'" . esc_sql( gmdate( 'Y-m-d H:i:s', strtotime( $decision['expires_at'] ) ) ) . "'" : 'NULL';
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table} (ip, action, confidence, reports, source, reason, decided_at, expires_at, cached_at)
|
||||
VALUES (%s, %s, %d, %d, %s, %s, {$decided_at}, {$expires_at}, %s)
|
||||
ON DUPLICATE KEY UPDATE action = VALUES(action), confidence = VALUES(confidence), reports = VALUES(reports), source = VALUES(source), reason = VALUES(reason), decided_at = VALUES(decided_at), expires_at = VALUES(expires_at), cached_at = VALUES(cached_at)", // phpcs:ignore
|
||||
$decision['ip'],
|
||||
$decision['action'] ?? 'allow',
|
||||
(int) ( $decision['confidence'] ?? 0 ),
|
||||
(int) ( $decision['reports'] ?? 0 ),
|
||||
$decision['source'] ?? '',
|
||||
$decision['reason'] ?? '',
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function cached_reputation( $ip ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'anis_reputation' );
|
||||
return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE ip = %s", $ip ) ); // phpcs:ignore
|
||||
}
|
||||
|
||||
public static function maybe_enforce( $ip ) {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return false;
|
||||
}
|
||||
$row = self::cached_reputation( $ip );
|
||||
if ( ! $row || 'ban' !== $row->action || (int) $row->confidence < self::AUTO_BAN_CONFIDENCE_THRESHOLD ) {
|
||||
return false;
|
||||
}
|
||||
if ( $row->expires_at && strtotime( $row->expires_at . ' UTC' ) < time() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Argus_Ban_Engine::ban(
|
||||
$ip,
|
||||
Argus_Ban_Engine::SOURCE_ARGUS_CLOUD,
|
||||
sprintf( 'Flagged by ANIS community threat intelligence (confidence %d, %d report(s))', $row->confidence, $row->reports ),
|
||||
array( 'anis_reason' => $row->reason, 'anis_source' => $row->source ),
|
||||
Argus_Ban_Engine::LEVEL_EXTENDED
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function maybe_report( $ip, $source, $reason, $level ) {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
$scenario = self::scenario_for_source( $source );
|
||||
if ( ! $scenario ) {
|
||||
return;
|
||||
}
|
||||
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::report_async( $ip, $scenario );
|
||||
}
|
||||
|
||||
protected static function scenario_for_source( $source ) {
|
||||
$map = array(
|
||||
Argus_Ban_Engine::SOURCE_LOCAL_WAF => 'waf_block',
|
||||
Argus_Ban_Engine::SOURCE_BRUTE_FORCE => 'brute_force',
|
||||
);
|
||||
return $map[ $source ] ?? null;
|
||||
}
|
||||
|
||||
public static function report_async( $ip, $scenario, $confidence = null, $country = null ) {
|
||||
if ( ! in_array( $scenario, self::REPORT_SCENARIOS, true ) ) {
|
||||
return;
|
||||
}
|
||||
if ( self::circuit_is_open() ) {
|
||||
return;
|
||||
}
|
||||
$body = array(
|
||||
'instance_hash' => self::instance_hash(),
|
||||
'ip' => $ip,
|
||||
'scenario' => $scenario,
|
||||
);
|
||||
if ( null !== $confidence ) {
|
||||
$body['confidence'] = max( 1, min( 100, (int) $confidence ) );
|
||||
}
|
||||
if ( $country ) {
|
||||
$body['country'] = $country;
|
||||
}
|
||||
|
||||
wp_remote_post(
|
||||
self::base_url() . '/api/v1/intelligence/report',
|
||||
array(
|
||||
'timeout' => 5,
|
||||
'blocking' => false,
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => wp_json_encode( $body ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function check_ip_live( $ip ) {
|
||||
if ( ! self::is_enabled() || ! self::circuit_allows_request() ) {
|
||||
return null;
|
||||
}
|
||||
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$instance = self::instance_hash();
|
||||
$args = array( 'timeout' => 2 );
|
||||
if ( $instance ) {
|
||||
$args['headers'] = array( 'X-ANIS-Instance' => $instance );
|
||||
}
|
||||
|
||||
$response = wp_remote_get( self::base_url() . '/api/v1/intelligence/check/' . rawurlencode( $ip ), $args );
|
||||
|
||||
if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) >= 500 ) {
|
||||
self::circuit_record_failure();
|
||||
return null;
|
||||
}
|
||||
self::circuit_record_success();
|
||||
|
||||
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( ! is_array( $data ) || ! isset( $data['action'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array( 'action' => $data['action'], 'score' => (int) ( $data['score'] ?? 0 ) );
|
||||
}
|
||||
|
||||
protected static function get( $path ) {
|
||||
if ( ! self::circuit_allows_request() ) {
|
||||
return new WP_Error( 'argus_anis_circuit_open', __( 'ANIS is temporarily unreachable.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$args = array( 'timeout' => 15 );
|
||||
$instance = self::instance_hash();
|
||||
if ( $instance ) {
|
||||
$args['headers'] = array( 'X-ANIS-Instance' => $instance );
|
||||
}
|
||||
$response = wp_remote_get( self::base_url() . $path, $args );
|
||||
|
||||
if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) >= 500 ) {
|
||||
|
||||
self::circuit_record_failure();
|
||||
} else {
|
||||
self::circuit_record_success();
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public static function stats() {
|
||||
if ( ! self::is_connected() ) {
|
||||
return null;
|
||||
}
|
||||
$response = self::get( '/api/v1/intelligence/stats' );
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
return null;
|
||||
}
|
||||
return json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
}
|
||||
|
||||
public static function feeds_status() {
|
||||
if ( ! self::is_connected() ) {
|
||||
return null;
|
||||
}
|
||||
$response = self::get( '/api/v1/feeds/status' );
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
return is_array( $data ) ? ( $data['feeds'] ?? array() ) : array();
|
||||
}
|
||||
|
||||
public static function local_reputation_count() {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'anis_reputation' ) ); // phpcs:ignore
|
||||
}
|
||||
|
||||
public static function blocked_count() {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
'SELECT COUNT(*) FROM ' . Argus_DB::table( 'bans' ) . ' WHERE source = %s', // phpcs:ignore
|
||||
Argus_Ban_Engine::SOURCE_ARGUS_CLOUD
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const SYNC_INTERVAL_SECS = HOUR_IN_SECONDS;
|
||||
|
||||
public static function status() {
|
||||
$last_success = get_option( self::LAST_SUCCESS_OPTION, '' );
|
||||
|
||||
return array(
|
||||
'configured' => self::is_configured(),
|
||||
'enabled' => (bool) Argus_Settings::get( 'anis_enabled', true ),
|
||||
'connected' => self::is_connected(),
|
||||
'status' => get_option( self::STATUS_OPTION, 'unconfigured' ),
|
||||
'last_error' => get_option( self::LAST_ERROR_OPTION, '' ),
|
||||
|
||||
'last_sync' => $last_success,
|
||||
'next_sync' => $last_success ? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + self::SYNC_INTERVAL_SECS ) : '',
|
||||
'local_count' => self::local_reputation_count(),
|
||||
'blocked_count' => self::blocked_count(),
|
||||
'circuit_open' => self::circuit_is_open(),
|
||||
|
||||
'protection' => self::protection_state(),
|
||||
);
|
||||
}
|
||||
|
||||
protected static function protection_state() {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return 'not_connected';
|
||||
}
|
||||
if ( self::circuit_is_open() && self::is_connected() ) {
|
||||
return 'limited';
|
||||
}
|
||||
return self::is_connected() ? 'active' : 'not_connected';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_API_Guard {
|
||||
|
||||
public static function init() {
|
||||
add_filter( 'xmlrpc_methods', array( __CLASS__, 'strip_pingback' ) );
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'guard_user_enumeration' ) );
|
||||
}
|
||||
|
||||
public static function strip_pingback( $methods ) {
|
||||
if ( ! Argus_Settings::get( 'xmlrpc_block_pingback', true ) ) {
|
||||
return $methods;
|
||||
}
|
||||
unset( $methods['pingback.ping'], $methods['pingback.extensions.getPingbacks'] );
|
||||
return $methods;
|
||||
}
|
||||
|
||||
public static function guard_user_enumeration() {
|
||||
if ( ! Argus_Settings::get( 'rest_api_protection_enabled', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter(
|
||||
'rest_pre_dispatch',
|
||||
function ( $result, $server, $request ) {
|
||||
if ( is_user_logged_in() ) {
|
||||
return $result;
|
||||
}
|
||||
$route = $request->get_route();
|
||||
if ( 0 !== strpos( $route, '/wp/v2/users' ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$ip = Argus_Request_Inputs::client_ip();
|
||||
|
||||
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
|
||||
Argus_Policy_Engine::deny_already_banned( $ip );
|
||||
return $result;
|
||||
}
|
||||
|
||||
$recent = self::recent_user_enumeration_hits( $ip );
|
||||
|
||||
if ( $recent >= 10 ) {
|
||||
|
||||
$decision = Argus_Policy_Engine::evaluate( $ip, 'rest_abuse', array( 'route' => $route, 'recent_hits' => $recent ) );
|
||||
|
||||
Argus_Findings::record(
|
||||
'account',
|
||||
'high',
|
||||
array(
|
||||
|
||||
'what_happened' => sprintf( 'Unauthenticated user-enumeration probing from %s targeting %s', $ip, $route ),
|
||||
'why_it_matters' => 'Repeated unauthenticated requests to the users endpoint are a well-known reconnaissance step -- enumerating valid usernames to fuel a subsequent brute-force run.',
|
||||
'what_argus_found' => sprintf( '%d unauthenticated requests to %s from %s. Action taken: %s (%s).', $recent, $route, $ip, $decision['action'], $decision['observation_only'] ? 'observed only, MONITOR mode' : 'enforced' ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'A real client has no reason to request this endpoint repeatedly without authenticating -- this pattern is consistent with an automated recon/scanning tool.',
|
||||
'what_could_be_affected' => 'Enumerated usernames are commonly fed into a follow-up brute-force or credential-stuffing attack against wp-login.php.',
|
||||
'what_should_you_do' => $decision['observation_only']
|
||||
? 'ARGUS is in MONITOR mode and did not block this. Review recent activity and switch to BLOCK mode once you are confident legitimate traffic is not being flagged.'
|
||||
: 'No action needed -- ARGUS already blocked this IP.',
|
||||
),
|
||||
array( 'ip' => $ip, 'route' => $route, 'recent_hits' => $recent, 'decision' => $decision )
|
||||
);
|
||||
|
||||
Argus_Policy_Engine::enforce_decision( $ip, 'rest_abuse', $decision, array( 'route' => $route, 'recent_hits' => $recent ) );
|
||||
} else {
|
||||
Argus_Events::record( 'rest_user_enum_probe', 'low', 'Unauthenticated request to ' . $route, array( 'route' => $route ), $ip );
|
||||
}
|
||||
|
||||
return $result;
|
||||
},
|
||||
10,
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
protected static function recent_user_enumeration_hits( $ip ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'events' );
|
||||
$since = gmdate( 'Y-m-d H:i:s', time() - 300 );
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE event_type = %s AND ip = %s AND created_at >= %s", // phpcs:ignore
|
||||
'rest_user_enum_probe',
|
||||
$ip,
|
||||
$since
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Auto_Update {
|
||||
|
||||
public static function init() {
|
||||
add_filter( 'auto_update_plugin', array( __CLASS__, 'force_auto_update' ), 10, 2 );
|
||||
add_filter( 'plugin_auto_update_setting_html', array( __CLASS__, 'lock_admin_toggle' ), 10, 3 );
|
||||
add_filter( 'site_option_auto_update_plugins', array( __CLASS__, 'ensure_in_list' ) );
|
||||
add_filter( 'option_auto_update_plugins', array( __CLASS__, 'ensure_in_list' ) );
|
||||
}
|
||||
|
||||
public static function force_auto_update( $update, $item ) {
|
||||
if ( isset( $item->plugin ) && ARGUS_WPD_BASENAME === $item->plugin ) {
|
||||
return true;
|
||||
}
|
||||
return $update;
|
||||
}
|
||||
|
||||
public static function ensure_in_list( $list ) {
|
||||
$list = is_array( $list ) ? $list : array();
|
||||
if ( ! in_array( ARGUS_WPD_BASENAME, $list, true ) ) {
|
||||
$list[] = ARGUS_WPD_BASENAME;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function lock_admin_toggle( $html, $plugin_file, $plugin_data ) {
|
||||
if ( ARGUS_WPD_BASENAME !== $plugin_file ) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return '<span style="color:#00a86b;font-weight:600;">● ' . esc_html__( 'Automatic security updates: Enabled and protected', 'argus-wordpress-defence' ) . '</span>';
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
return array(
|
||||
'label' => __( 'Enabled and Protected', 'argus-wordpress-defence' ),
|
||||
'detail' => __( 'ARGUS Defence automatically installs important security, protection, stability, and compatibility updates to help keep your website protected. This setting is managed automatically and cannot be disabled from the plugin.', 'argus-wordpress-defence' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Autoloader {
|
||||
|
||||
public static function register() {
|
||||
spl_autoload_register( array( __CLASS__, 'autoload' ) );
|
||||
}
|
||||
|
||||
public static function autoload( $class ) {
|
||||
if ( 0 !== strpos( $class, 'Argus_' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file_slug = strtolower( str_replace( '_', '-', $class ) );
|
||||
$candidates = array(
|
||||
ARGUS_WPD_DIR . 'includes/class-' . $file_slug . '.php',
|
||||
ARGUS_WPD_DIR . 'admin/class-' . $file_slug . '.php',
|
||||
);
|
||||
|
||||
foreach ( $candidates as $path ) {
|
||||
if ( file_exists( $path ) ) {
|
||||
require_once $path;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Backup {
|
||||
|
||||
const TYPE_MANUAL = 'manual';
|
||||
const TYPE_SCHEDULED = 'scheduled';
|
||||
|
||||
const STATUS_COMPLETE = 'complete';
|
||||
const STATUS_FAILED = 'failed';
|
||||
|
||||
const LOCKDOWN_STATUS_OPTION = 'argus_wpd_backup_lockdown_status';
|
||||
const HTACCESS_MARKER = 'ARGUS WordPress Defence -- backup store, deny all direct access';
|
||||
const LAST_SUCCESS_OPTION = 'argus_wpd_backup_last_success';
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
const DB_DUMP_BATCH_SIZE = 500;
|
||||
|
||||
public static function backup_dir() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
return trailingslashit( $uploads['basedir'] ?? '' ) . 'argus-wpd-data/backups';
|
||||
}
|
||||
|
||||
public static function ensure_lockdown() {
|
||||
$dir = self::backup_dir();
|
||||
|
||||
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_mkdir', false );
|
||||
return false;
|
||||
}
|
||||
if ( ! is_writable( $dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false );
|
||||
return false;
|
||||
}
|
||||
|
||||
$htaccess = trailingslashit( $dir ) . '.htaccess';
|
||||
$rule = "# " . self::HTACCESS_MARKER . "\nRequire all denied\n";
|
||||
$existing = file_exists( $htaccess ) ? file_get_contents( $htaccess ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
if ( false === $existing || false === strpos( $existing, 'Require all denied' ) ) {
|
||||
$written = file_put_contents( $htaccess, $rule ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( false === $written ) {
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$index = trailingslashit( $dir ) . 'index.php';
|
||||
if ( ! file_exists( $index ) ) {
|
||||
file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false );
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function lockdown_status() {
|
||||
return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' );
|
||||
}
|
||||
|
||||
public static function create( $type = self::TYPE_MANUAL ) {
|
||||
if ( ! self::ensure_lockdown() ) {
|
||||
return self::record_failure( $type, __( 'Could not prepare the protected backup storage location.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
if ( ! class_exists( 'ZipArchive' ) ) {
|
||||
return self::record_failure( $type, __( 'The PHP zip extension is not available on this server -- ARGUS cannot create a backup archive without it. Ask your hosting provider to enable it.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$filename = sprintf( 'argus-backup-%s-%s.zip', gmdate( 'Y-m-d-His' ), bin2hex( random_bytes( 4 ) ) );
|
||||
$path = trailingslashit( self::backup_dir() ) . $filename;
|
||||
|
||||
$sql_tmp = trailingslashit( self::backup_dir() ) . 'tmp-' . bin2hex( random_bytes( 4 ) ) . '.sql';
|
||||
$dumped = self::dump_database( $sql_tmp );
|
||||
if ( ! $dumped ) {
|
||||
@unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
||||
return self::record_failure( $type, __( 'Could not export the database.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
|
||||
@unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
||||
return self::record_failure( $type, __( 'Could not create the backup archive.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$zip->addFile( $sql_tmp, 'database.sql' );
|
||||
self::add_directory_to_zip( $zip, WP_CONTENT_DIR, 'wp-content', array(
|
||||
wp_normalize_path( self::backup_dir() ),
|
||||
wp_normalize_path( WP_CONTENT_DIR . '/uploads/argus-wpd-data/html-cache' ),
|
||||
) );
|
||||
$zip->close();
|
||||
@unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
||||
|
||||
if ( ! file_exists( $path ) ) {
|
||||
return self::record_failure( $type, __( 'The backup archive could not be written to disk.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'backups' ),
|
||||
array(
|
||||
'filename' => $filename,
|
||||
'type' => $type,
|
||||
'size_bytes' => filesize( $path ),
|
||||
'status' => self::STATUS_COMPLETE,
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
)
|
||||
);
|
||||
|
||||
update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false );
|
||||
Argus_Events::record( 'backup_created', 'info', sprintf( 'Backup created (%s).', $type ), array( 'filename' => $filename, 'type' => $type ) );
|
||||
|
||||
self::enforce_retention();
|
||||
|
||||
return array( 'success' => true, 'message' => __( 'Backup created successfully.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
protected static function record_failure( $type, $message ) {
|
||||
global $wpdb;
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'backups' ),
|
||||
array(
|
||||
'filename' => 'failed-' . gmdate( 'Y-m-d-His' ) . '-' . bin2hex( random_bytes( 4 ) ),
|
||||
'type' => $type,
|
||||
'size_bytes' => 0,
|
||||
'status' => self::STATUS_FAILED,
|
||||
'error_message' => $message,
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
)
|
||||
);
|
||||
Argus_Events::record( 'backup_failed', 'medium', $message, array( 'type' => $type ) );
|
||||
return array( 'success' => false, 'message' => $message );
|
||||
}
|
||||
|
||||
protected static function dump_database( $dest_path ) {
|
||||
global $wpdb;
|
||||
$handle = fopen( $dest_path, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( ! $handle ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fwrite( $handle, "-- ARGUS Defence database backup\n-- Generated: " . current_time( 'mysql', true ) . " UTC\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
fwrite( $handle, "SET FOREIGN_KEY_CHECKS=0;\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
$tables = $wpdb->get_col( 'SHOW TABLES' ); // phpcs:ignore
|
||||
foreach ( $tables as $table ) {
|
||||
$create = $wpdb->get_row( "SHOW CREATE TABLE `{$table}`", ARRAY_N ); // phpcs:ignore
|
||||
if ( ! $create ) {
|
||||
continue;
|
||||
}
|
||||
fwrite( $handle, "DROP TABLE IF EXISTS `{$table}`;\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
fwrite( $handle, $create[1] . ";\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore
|
||||
$offset = 0;
|
||||
while ( $offset < $total ) {
|
||||
$rows = $wpdb->get_results( "SELECT * FROM `{$table}` LIMIT " . self::DB_DUMP_BATCH_SIZE . " OFFSET {$offset}", ARRAY_A ); // phpcs:ignore
|
||||
foreach ( $rows as $row ) {
|
||||
$columns = array_map( function ( $c ) { return '`' . $c . '`'; }, array_keys( $row ) );
|
||||
$values = array_map( function ( $v ) use ( $wpdb ) {
|
||||
return null === $v ? 'NULL' : "'" . esc_sql( $v ) . "'";
|
||||
}, array_values( $row ) );
|
||||
fwrite( $handle, "INSERT INTO `{$table}` (" . implode( ',', $columns ) . ') VALUES (' . implode( ',', $values ) . ");\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
$offset += self::DB_DUMP_BATCH_SIZE;
|
||||
}
|
||||
fwrite( $handle, "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
|
||||
fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
return true;
|
||||
}
|
||||
|
||||
protected static function add_directory_to_zip( ZipArchive $zip, $source_dir, $zip_root, array $exclude_prefixes ) {
|
||||
$source_dir = wp_normalize_path( $source_dir );
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $source_dir, FilesystemIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ( $iterator as $item ) {
|
||||
$item_path = wp_normalize_path( $item->getPathname() );
|
||||
foreach ( $exclude_prefixes as $prefix ) {
|
||||
if ( 0 === strpos( $item_path, $prefix ) ) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$relative = $zip_root . '/' . ltrim( str_replace( $source_dir, '', $item_path ), '/' );
|
||||
if ( $item->isDir() ) {
|
||||
$zip->addEmptyDir( $relative );
|
||||
} else {
|
||||
$zip->addFile( $item_path, $relative );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function paginated( $page = 1, $per_page = self::PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'backups' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore
|
||||
);
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
|
||||
public static function get( $id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare( 'SELECT * FROM ' . Argus_DB::table( 'backups' ) . ' WHERE id = %d', $id ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
public static function delete( $id ) {
|
||||
$row = self::get( $id );
|
||||
if ( ! $row ) {
|
||||
return array( 'success' => false, 'message' => __( 'Backup not found.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$path = trailingslashit( self::backup_dir() ) . $row->filename;
|
||||
if ( file_exists( $path ) ) {
|
||||
wp_delete_file( $path );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->delete( Argus_DB::table( 'backups' ), array( 'id' => $id ), array( '%d' ) );
|
||||
|
||||
Argus_Events::record( 'backup_deleted', 'info', 'Backup deleted.', array( 'filename' => $row->filename ) );
|
||||
|
||||
return array( 'success' => true, 'message' => __( 'Backup deleted.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
protected static function enforce_retention() {
|
||||
$keep = max( 1, (int) Argus_Settings::get( 'backup_retention_count', 5 ) );
|
||||
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'backups' );
|
||||
$ids = $wpdb->get_col(
|
||||
$wpdb->prepare( "SELECT id FROM {$table} WHERE status = %s ORDER BY created_at DESC", self::STATUS_COMPLETE ) // phpcs:ignore
|
||||
);
|
||||
|
||||
foreach ( array_slice( $ids, $keep ) as $id ) {
|
||||
self::delete( (int) $id );
|
||||
}
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
$last_success = get_option( self::LAST_SUCCESS_OPTION, '' );
|
||||
$interval_secs = max( 1, (int) Argus_Settings::get( 'backup_interval_hours', 24 ) ) * HOUR_IN_SECONDS;
|
||||
|
||||
global $wpdb;
|
||||
$available = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'backups' ) . ' WHERE status = %s', self::STATUS_COMPLETE ) // phpcs:ignore
|
||||
);
|
||||
|
||||
return array(
|
||||
'last_backup' => $last_success,
|
||||
'next_backup' => ( 'manual' !== Argus_Settings::get( 'backup_schedule_mode', 'automatic' ) && $last_success )
|
||||
? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + $interval_secs )
|
||||
: '',
|
||||
'available' => $available,
|
||||
);
|
||||
}
|
||||
|
||||
public static function maybe_run_scheduled() {
|
||||
if ( 'manual' === Argus_Settings::get( 'backup_schedule_mode', 'automatic' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last_success = get_option( self::LAST_SUCCESS_OPTION, '' );
|
||||
$interval_secs = max( 1, (int) Argus_Settings::get( 'backup_interval_hours', 24 ) ) * HOUR_IN_SECONDS;
|
||||
|
||||
if ( $last_success && ( strtotime( $last_success . ' UTC' ) + $interval_secs ) > time() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::create( self::TYPE_SCHEDULED );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Ban_Engine {
|
||||
|
||||
const SOURCE_LOCAL_WAF = 'local_waf';
|
||||
const SOURCE_BRUTE_FORCE = 'brute_force';
|
||||
const SOURCE_POLICY_ENGINE = 'policy_engine';
|
||||
const SOURCE_MANUAL = 'manual';
|
||||
const SOURCE_ARGUS_CLOUD = 'argus_cloud_reputation';
|
||||
|
||||
const LEVEL_TEMPORARY = 'temporary';
|
||||
const LEVEL_EXTENDED = 'extended';
|
||||
const LEVEL_PERMANENT = 'permanent';
|
||||
|
||||
const CACHE_GROUP = 'argus_wpd_bans';
|
||||
const CACHE_TTL = 30;
|
||||
|
||||
protected static $request_cache = array();
|
||||
|
||||
public static function ban( $ip, $source, $reason, array $evidence = array(), $level = self::LEVEL_TEMPORARY ) {
|
||||
global $wpdb;
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
$ttl = self::ttl_for_level( $level );
|
||||
|
||||
$existing = self::active_ban( $ip );
|
||||
if ( $existing ) {
|
||||
|
||||
$new_level = self::escalate( $existing->ban_level, $level );
|
||||
$wpdb->update(
|
||||
Argus_DB::table( 'bans' ),
|
||||
array(
|
||||
'source' => $source,
|
||||
'reason' => $reason,
|
||||
'evidence' => wp_json_encode( $evidence ),
|
||||
'ban_level' => $new_level,
|
||||
'expires_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + self::ttl_for_level( $new_level ) ) : null,
|
||||
'recovery_eligible_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + self::ttl_for_level( $new_level ) ) : null,
|
||||
),
|
||||
array( 'id' => $existing->id ),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
$id = (int) $existing->id;
|
||||
} else {
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'bans' ),
|
||||
array(
|
||||
'ip' => $ip,
|
||||
'source' => $source,
|
||||
'reason' => $reason,
|
||||
'evidence' => wp_json_encode( $evidence ),
|
||||
'ban_level' => $level,
|
||||
'created_at' => $now,
|
||||
'expires_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + $ttl ) : null,
|
||||
'recovery_eligible_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + $ttl ) : null,
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
$id = (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
self::invalidate_cache( $ip );
|
||||
|
||||
Argus_Events::record(
|
||||
'ban',
|
||||
'high',
|
||||
sprintf( 'Banned %s (%s)', $ip, $reason ),
|
||||
array( 'ip' => $ip, 'source' => $source, 'reason' => $reason, 'evidence' => $evidence, 'level' => $level ),
|
||||
$ip
|
||||
);
|
||||
|
||||
do_action( 'argus_wpd_ip_banned', $ip, $source, $reason, $level );
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function is_banned( $ip ) {
|
||||
if ( array_key_exists( $ip, self::$request_cache ) ) {
|
||||
return self::$request_cache[ $ip ];
|
||||
}
|
||||
|
||||
$cache_key = self::cache_key( $ip );
|
||||
$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
|
||||
if ( false !== $cached ) {
|
||||
self::$request_cache[ $ip ] = (bool) $cached;
|
||||
return self::$request_cache[ $ip ];
|
||||
}
|
||||
|
||||
$result = null !== self::active_ban( $ip );
|
||||
self::$request_cache[ $ip ] = $result;
|
||||
|
||||
wp_cache_set( $cache_key, $result ? 'yes' : 'no', self::CACHE_GROUP, self::CACHE_TTL );
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected static function cache_key( $ip ) {
|
||||
return 'is_banned_' . md5( $ip );
|
||||
}
|
||||
|
||||
protected static function invalidate_cache( $ip ) {
|
||||
unset( self::$request_cache[ $ip ] );
|
||||
wp_cache_delete( self::cache_key( $ip ), self::CACHE_GROUP );
|
||||
}
|
||||
|
||||
public static function active_ban( $ip ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'bans' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE ip = %s AND lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC LIMIT 1", // phpcs:ignore
|
||||
$ip,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function lift( $ban_id ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'bans' );
|
||||
|
||||
$ip = $wpdb->get_var( $wpdb->prepare( "SELECT ip FROM {$table} WHERE id = %d", $ban_id ) ); // phpcs:ignore
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
array( 'lifted_at' => current_time( 'mysql', true ) ),
|
||||
array( 'id' => $ban_id ),
|
||||
array( '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $ip ) {
|
||||
self::invalidate_cache( $ip );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function sweep_expired() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'bans' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$expired = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, ip FROM {$table} WHERE lifted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= %s", // phpcs:ignore
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $expired as $row ) {
|
||||
self::lift( $row->id );
|
||||
Argus_Events::record( 'ban_expired', 'info', sprintf( 'Ban on %s expired and was lifted', $row->ip ), array( 'ip' => $row->ip ), $row->ip );
|
||||
}
|
||||
|
||||
return count( $expired );
|
||||
}
|
||||
|
||||
public static function recent( $limit = 50 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'bans' );
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) ); // phpcs:ignore
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->evidence = json_decode( $row->evidence, true );
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
public static function paginated( $page = 1, $per_page = self::PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'bans' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$total = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s)", $now ) // phpcs:ignore
|
||||
);
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore
|
||||
$now,
|
||||
$per_page,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->evidence = json_decode( $row->evidence, true );
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
|
||||
public static function manual_ban( $ip, $reason ) {
|
||||
return self::ban( $ip, self::SOURCE_MANUAL, $reason ?: 'Manually banned by administrator', array(), self::LEVEL_EXTENDED );
|
||||
}
|
||||
|
||||
protected static function ttl_for_level( $level ) {
|
||||
switch ( $level ) {
|
||||
case self::LEVEL_TEMPORARY:
|
||||
return 15 * MINUTE_IN_SECONDS;
|
||||
case self::LEVEL_EXTENDED:
|
||||
return DAY_IN_SECONDS;
|
||||
case self::LEVEL_PERMANENT:
|
||||
return 0;
|
||||
default:
|
||||
return 15 * MINUTE_IN_SECONDS;
|
||||
}
|
||||
}
|
||||
|
||||
protected static function escalate( $current, $incoming ) {
|
||||
$rank = array( self::LEVEL_TEMPORARY => 1, self::LEVEL_EXTENDED => 2, self::LEVEL_PERMANENT => 3 );
|
||||
return ( $rank[ $incoming ] ?? 1 ) > ( $rank[ $current ] ?? 1 ) ? $incoming : $current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Cache_Discovery {
|
||||
|
||||
const MAX_URLS = 5000;
|
||||
const MAX_SITEMAP_FILES = 50;
|
||||
const LAST_RUN_OPTION = 'argus_wpd_cache_discovery_last_run';
|
||||
const LAST_ERROR_OPTION = 'argus_wpd_cache_discovery_last_error';
|
||||
|
||||
public static function run_discovery() {
|
||||
delete_option( self::LAST_ERROR_OPTION );
|
||||
$urls = array();
|
||||
$sources = array();
|
||||
|
||||
foreach ( array( '/wp-sitemap.xml', '/sitemap_index.xml', '/sitemap.xml' ) as $path ) {
|
||||
$found = self::fetch_sitemap( home_url( $path ) );
|
||||
if ( false !== $found ) {
|
||||
$sources[] = $path;
|
||||
$urls = array_merge( $urls, $found );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( self::sitemaps_from_robots() as $sitemap_url ) {
|
||||
$found = self::fetch_sitemap( $sitemap_url );
|
||||
if ( false !== $found ) {
|
||||
$sources[] = $sitemap_url;
|
||||
$urls = array_merge( $urls, $found );
|
||||
}
|
||||
}
|
||||
|
||||
$urls = array_slice( array_unique( $urls ), 0, self::MAX_URLS );
|
||||
|
||||
if ( empty( $urls ) ) {
|
||||
update_option( self::LAST_ERROR_OPTION, __( 'No sitemap could be found (checked wp-sitemap.xml, sitemap_index.xml, sitemap.xml, and robots.txt).', 'argus-wordpress-defence' ), false );
|
||||
}
|
||||
|
||||
self::store_discovered( $urls, $sources );
|
||||
update_option( self::LAST_RUN_OPTION, current_time( 'mysql', true ), false );
|
||||
|
||||
return array( 'url_count' => count( $urls ), 'sources' => $sources );
|
||||
}
|
||||
|
||||
protected static function sitemaps_from_robots() {
|
||||
$response = wp_remote_get( home_url( '/robots.txt' ), array( 'timeout' => 10 ) );
|
||||
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
|
||||
return array();
|
||||
}
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$out = array();
|
||||
foreach ( preg_split( '/\r\n|\r|\n/', $body ) as $line ) {
|
||||
if ( 0 === stripos( trim( $line ), 'Sitemap:' ) ) {
|
||||
$out[] = trim( substr( trim( $line ), strlen( 'Sitemap:' ) ) );
|
||||
}
|
||||
}
|
||||
return array_slice( $out, 0, 10 );
|
||||
}
|
||||
|
||||
protected static function fetch_sitemap( $url, $depth = 0 ) {
|
||||
$response = wp_remote_get( $url, array( 'timeout' => 15 ) );
|
||||
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
|
||||
return false;
|
||||
}
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
if ( '' === trim( $body ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$needs_legacy_guard = PHP_VERSION_ID < 80000;
|
||||
$previous = $needs_legacy_guard ? libxml_disable_entity_loader( true ) : null; // phpcs:ignore
|
||||
libxml_use_internal_errors( true );
|
||||
$xml = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NONET );
|
||||
libxml_clear_errors();
|
||||
if ( $needs_legacy_guard ) {
|
||||
libxml_disable_entity_loader( $previous ); // phpcs:ignore
|
||||
}
|
||||
|
||||
if ( false === $xml ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$urls = array();
|
||||
|
||||
if ( isset( $xml->sitemap ) && $depth < 1 ) {
|
||||
$i = 0;
|
||||
foreach ( $xml->sitemap as $child ) {
|
||||
if ( ++$i > self::MAX_SITEMAP_FILES ) {
|
||||
break;
|
||||
}
|
||||
$loc = (string) ( $child->loc ?? '' );
|
||||
if ( $loc ) {
|
||||
$child_urls = self::fetch_sitemap( $loc, $depth + 1 );
|
||||
if ( is_array( $child_urls ) ) {
|
||||
$urls = array_merge( $urls, $child_urls );
|
||||
}
|
||||
}
|
||||
if ( count( $urls ) >= self::MAX_URLS ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $urls;
|
||||
}
|
||||
|
||||
if ( isset( $xml->url ) ) {
|
||||
foreach ( $xml->url as $entry ) {
|
||||
$loc = (string) ( $entry->loc ?? '' );
|
||||
if ( $loc ) {
|
||||
$urls[] = $loc;
|
||||
}
|
||||
if ( count( $urls ) >= self::MAX_URLS ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $urls;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static function store_discovered( array $urls, array $sources ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'cache_discovered_urls' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
foreach ( $urls as $url ) {
|
||||
$url = esc_url_raw( $url );
|
||||
if ( ! $url ) {
|
||||
continue;
|
||||
}
|
||||
$classification = Argus_Cache_Eligibility::classify_url( $url );
|
||||
$source = '';
|
||||
foreach ( $sources as $s ) {
|
||||
if ( false !== strpos( $url, wp_parse_url( home_url(), PHP_URL_HOST ) ) ) {
|
||||
$source = $s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table} (url, source, eligibility, eligibility_reason, discovered_at, last_checked_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE eligibility = VALUES(eligibility), eligibility_reason = VALUES(eligibility_reason), last_checked_at = VALUES(last_checked_at)", // phpcs:ignore
|
||||
$url,
|
||||
$source ?: 'sitemap',
|
||||
$classification['status'],
|
||||
$classification['reason'],
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function summary() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'cache_discovered_urls' );
|
||||
|
||||
$rows = $wpdb->get_results( "SELECT eligibility, COUNT(*) AS cnt FROM {$table} GROUP BY eligibility" ); // phpcs:ignore
|
||||
$by_status = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$by_status[ $row->eligibility ] = (int) $row->cnt;
|
||||
}
|
||||
|
||||
return array(
|
||||
'total' => array_sum( $by_status ),
|
||||
'eligible' => $by_status[ Argus_Cache_Eligibility::SAFE_TO_CACHE ] ?? 0,
|
||||
'excluded' => ( $by_status[ Argus_Cache_Eligibility::FORCE_EXCLUDED ] ?? 0 ) + ( $by_status[ Argus_Cache_Eligibility::BYPASS ] ?? 0 ),
|
||||
'needs_review' => $by_status[ Argus_Cache_Eligibility::NEEDS_REVIEW ] ?? 0,
|
||||
'last_run' => get_option( self::LAST_RUN_OPTION, '' ),
|
||||
'last_error' => get_option( self::LAST_ERROR_OPTION, '' ),
|
||||
);
|
||||
}
|
||||
|
||||
public static function paginated_urls( $page = 1, $eligibility = null, $per_page = 20 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'cache_discovered_urls' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
$where = $eligibility ? $wpdb->prepare( 'WHERE eligibility = %s', $eligibility ) : ''; // phpcs:ignore
|
||||
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} {$where}" ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} {$where} ORDER BY id DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore
|
||||
);
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Cache_Eligibility {
|
||||
|
||||
const SAFE_TO_CACHE = 'safe_to_cache';
|
||||
const BYPASS = 'bypass';
|
||||
const NEEDS_REVIEW = 'needs_review';
|
||||
const FORCE_EXCLUDED = 'force_excluded';
|
||||
|
||||
const IGNORABLE_QUERY_PARAMS = array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'msclkid', 'mc_cid', 'mc_eid', 'ref' );
|
||||
|
||||
public static function classify_request( array $context ) {
|
||||
$method = strtoupper( $context['method'] ?? 'GET' );
|
||||
if ( ! in_array( $method, array( 'GET', 'HEAD' ), true ) ) {
|
||||
return self::result( self::FORCE_EXCLUDED, 'HTTP method ' . $method . ' is never cached (only GET/HEAD are safe).' );
|
||||
}
|
||||
|
||||
if ( ! empty( $context['has_authorization_header'] ) ) {
|
||||
return self::result( self::BYPASS, 'Request carries an Authorization header (API/Basic-auth client).' );
|
||||
}
|
||||
|
||||
if ( ! empty( $context['is_admin'] ) ) {
|
||||
return self::result( self::FORCE_EXCLUDED, 'wp-admin is never cached.' );
|
||||
}
|
||||
|
||||
if ( ! empty( $context['is_ajax'] ) || ! empty( $context['is_rest'] ) || ! empty( $context['is_cron'] ) ) {
|
||||
return self::result( self::FORCE_EXCLUDED, 'admin-ajax.php / wp-json / wp-cron.php are always excluded from the shared page cache.' );
|
||||
}
|
||||
|
||||
$path = untrailingslashit( (string) ( $context['path'] ?? '' ) );
|
||||
foreach ( self::protected_paths() as $protected ) {
|
||||
$protected = untrailingslashit( $protected );
|
||||
if ( $path === $protected || 0 === strpos( $path, $protected . '/' ) || false !== strpos( $protected, '*' ) && self::wildcard_match( $protected, $path ) ) {
|
||||
return self::result( self::FORCE_EXCLUDED, 'Matches protected WordPress endpoint: ' . $protected );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $context['is_logged_in'] ) ) {
|
||||
return self::result( self::BYPASS, 'Authenticated WordPress user.' );
|
||||
}
|
||||
|
||||
foreach ( (array) ( $context['cookies'] ?? array() ) as $cookie_name => $cookie_value ) {
|
||||
$hit = self::match_cookie_pattern( $cookie_name );
|
||||
if ( $hit ) {
|
||||
return self::result( self::BYPASS, 'Session/personalization cookie present: ' . $hit );
|
||||
}
|
||||
}
|
||||
|
||||
$query = (array) ( $context['query'] ?? array() );
|
||||
$meaningful_query = array_diff_key( $query, array_flip( self::IGNORABLE_QUERY_PARAMS ) );
|
||||
if ( ! empty( $meaningful_query ) ) {
|
||||
return self::result( self::NEEDS_REVIEW, 'Request has query parameters beyond known tracking params: ' . implode( ', ', array_keys( $meaningful_query ) ) );
|
||||
}
|
||||
|
||||
return self::result( self::SAFE_TO_CACHE, 'GET request, anonymous, no protected path, no session cookies, no meaningful query string.' );
|
||||
}
|
||||
|
||||
public static function classify_response( array $context ) {
|
||||
$status = (int) ( $context['status_code'] ?? 0 );
|
||||
if ( 200 !== $status ) {
|
||||
return self::result( self::FORCE_EXCLUDED, 'Response status ' . $status . ' is not 200 (redirects/errors are never cached).' );
|
||||
}
|
||||
|
||||
$content_type = (string) ( $context['content_type'] ?? '' );
|
||||
if ( '' !== $content_type && false === stripos( $content_type, 'text/html' ) ) {
|
||||
return self::result( self::BYPASS, 'Response content-type is not text/html: ' . $content_type );
|
||||
}
|
||||
|
||||
$headers = array_change_key_case( (array) ( $context['headers'] ?? array() ), CASE_LOWER );
|
||||
|
||||
$cache_control = strtolower( (string) ( $headers['cache-control'] ?? '' ) );
|
||||
if ( $cache_control ) {
|
||||
foreach ( array( 'no-store', 'private', 'no-cache' ) as $directive ) {
|
||||
if ( false !== strpos( $cache_control, $directive ) ) {
|
||||
return self::result( self::BYPASS, 'Origin response sent Cache-Control: ' . $directive );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $headers['pragma'] ) && false !== stripos( (string) $headers['pragma'], 'no-cache' ) ) {
|
||||
return self::result( self::BYPASS, 'Origin response sent Pragma: no-cache.' );
|
||||
}
|
||||
|
||||
if ( ! empty( $headers['set-cookie'] ) ) {
|
||||
return self::result( self::BYPASS, 'Origin response set a new cookie during this request -- likely personalized (session/cart/comment identity).' );
|
||||
}
|
||||
|
||||
foreach ( (array) ( $context['new_cookies_set'] ?? array() ) as $cookie_name ) {
|
||||
if ( self::match_cookie_pattern( $cookie_name ) ) {
|
||||
return self::result( self::BYPASS, 'Origin response set a session/personalization cookie: ' . $cookie_name );
|
||||
}
|
||||
}
|
||||
|
||||
return self::result( self::SAFE_TO_CACHE, 'Response is a plain 200 text/html page with no private/session signal.' );
|
||||
}
|
||||
|
||||
protected static function match_cookie_pattern( $cookie_name ) {
|
||||
foreach ( self::cookie_patterns() as $pattern ) {
|
||||
if ( self::wildcard_match( $pattern, $cookie_name ) ) {
|
||||
return $pattern;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static function wildcard_match( $pattern, $subject ) {
|
||||
$regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/i';
|
||||
return (bool) preg_match( $regex, $subject );
|
||||
}
|
||||
|
||||
public static function cookie_patterns() {
|
||||
$defaults = array(
|
||||
|
||||
'wordpress_logged_in_*',
|
||||
'wordpress_sec_*',
|
||||
'wordpress_*',
|
||||
'wp-settings-*',
|
||||
'wp-settings-time-*',
|
||||
'comment_author_*',
|
||||
'comment_author_email_*',
|
||||
'comment_author_url_*',
|
||||
|
||||
'woocommerce_items_in_cart',
|
||||
'woocommerce_cart_hash',
|
||||
'wp_woocommerce_session_*',
|
||||
);
|
||||
|
||||
return apply_filters( 'argus_wpd_cache_bypass_cookie_patterns', $defaults );
|
||||
}
|
||||
|
||||
public static function protected_paths() {
|
||||
$defaults = array(
|
||||
'/wp-admin',
|
||||
'/wp-login.php',
|
||||
'/wp-json',
|
||||
'/wp-cron.php',
|
||||
'/xmlrpc.php',
|
||||
);
|
||||
if ( class_exists( 'WooCommerce' ) ) {
|
||||
$defaults = array_merge( $defaults, self::woocommerce_paths() );
|
||||
}
|
||||
return apply_filters( 'argus_wpd_cache_protected_paths', $defaults );
|
||||
}
|
||||
|
||||
protected static function woocommerce_paths() {
|
||||
$paths = array();
|
||||
$page_options = array(
|
||||
'woocommerce_cart_page_id' => '/cart',
|
||||
'woocommerce_checkout_page_id' => '/checkout',
|
||||
'woocommerce_myaccount_page_id' => '/my-account',
|
||||
);
|
||||
foreach ( $page_options as $option => $fallback ) {
|
||||
$page_id = (int) get_option( $option );
|
||||
$path = $page_id ? wp_parse_url( get_permalink( $page_id ), PHP_URL_PATH ) : null;
|
||||
$paths[] = $path ? untrailingslashit( $path ) : $fallback;
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
|
||||
public static function classify_url( $url ) {
|
||||
$parts = wp_parse_url( $url );
|
||||
$query = array();
|
||||
if ( ! empty( $parts['query'] ) ) {
|
||||
parse_str( $parts['query'], $query );
|
||||
}
|
||||
return self::classify_request(
|
||||
array(
|
||||
'method' => 'GET',
|
||||
'path' => $parts['path'] ?? '/',
|
||||
'query' => $query,
|
||||
'is_admin' => false,
|
||||
'is_logged_in' => false,
|
||||
'cookies' => array(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected static function result( $status, $reason ) {
|
||||
return array( 'status' => $status, 'reason' => $reason );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Cache_Log {
|
||||
|
||||
const RAW_RETENTION_DAYS = 7;
|
||||
|
||||
public static function log_dir() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
return trailingslashit( $uploads['basedir'] ) . 'argus-wpd-data/cache-log';
|
||||
}
|
||||
|
||||
public static function record( $status, $url, $reason = '', $response_time_ms = null, $rule = '' ) {
|
||||
$dir = self::log_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
wp_mkdir_p( $dir );
|
||||
|
||||
$htaccess = trailingslashit( $dir ) . '.htaccess';
|
||||
if ( ! file_exists( $htaccess ) ) {
|
||||
@file_put_contents( $htaccess, "Require all denied\n" ); // phpcs:ignore
|
||||
}
|
||||
}
|
||||
|
||||
$line = wp_json_encode(
|
||||
array(
|
||||
't' => microtime( true ),
|
||||
's' => $status,
|
||||
'u' => mb_substr( (string) $url, 0, 500 ),
|
||||
'r' => mb_substr( (string) $reason, 0, 255 ),
|
||||
'ms' => null === $response_time_ms ? null : (int) $response_time_ms,
|
||||
'rule' => $rule,
|
||||
)
|
||||
);
|
||||
|
||||
$file = trailingslashit( $dir ) . gmdate( 'Y-m-d' ) . '.log';
|
||||
|
||||
@file_put_contents( $file, $line . "\n", FILE_APPEND | LOCK_EX ); // phpcs:ignore
|
||||
}
|
||||
|
||||
public static function recent( $limit = 200, $status_filter = null ) {
|
||||
$dir = self::log_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$files = glob( trailingslashit( $dir ) . '*.log' ) ?: array();
|
||||
rsort( $files );
|
||||
|
||||
$rows = array();
|
||||
foreach ( array_slice( $files, 0, self::RAW_RETENTION_DAYS ) as $file ) {
|
||||
$lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ) ?: array();
|
||||
for ( $i = count( $lines ) - 1; $i >= 0 && count( $rows ) < $limit * 4; $i-- ) {
|
||||
$row = json_decode( $lines[ $i ], true );
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( $status_filter && ( $row['s'] ?? '' ) !== $status_filter ) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = $row;
|
||||
if ( count( $rows ) >= $limit ) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function paginated( $page = 1, $per_page = 10, $status_filter = null ) {
|
||||
$page = max( 1, (int) $page );
|
||||
$all = self::recent( ( $page * $per_page ) + 1, $status_filter );
|
||||
|
||||
$has_next = count( $all ) > ( $page - 1 ) * $per_page + $per_page;
|
||||
$rows = array_slice( $all, ( $page - 1 ) * $per_page, $per_page );
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'page' => $page,
|
||||
'total_pages' => $page + ( $has_next ? 1 : 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
public static function rollup_and_prune() {
|
||||
global $wpdb;
|
||||
$dir = self::log_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = Argus_DB::table( 'cache_stats_daily' );
|
||||
$files = glob( trailingslashit( $dir ) . '*.log' ) ?: array();
|
||||
|
||||
foreach ( $files as $file ) {
|
||||
$basename = basename( $file, '.log' );
|
||||
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $basename ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ) ?: array();
|
||||
$by_status = array();
|
||||
foreach ( $lines as $line ) {
|
||||
$row = json_decode( $line, true );
|
||||
if ( ! is_array( $row ) || empty( $row['s'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$status = $row['s'];
|
||||
if ( ! isset( $by_status[ $status ] ) ) {
|
||||
$by_status[ $status ] = array( 'count' => 0, 'ms' => 0 );
|
||||
}
|
||||
++$by_status[ $status ]['count'];
|
||||
$by_status[ $status ]['ms'] += (int) ( $row['ms'] ?? 0 );
|
||||
}
|
||||
|
||||
foreach ( $by_status as $status => $agg ) {
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table} (stat_date, status, request_count, total_response_time_ms, total_bytes)
|
||||
VALUES (%s, %s, %d, %d, 0)
|
||||
ON DUPLICATE KEY UPDATE request_count = VALUES(request_count), total_response_time_ms = VALUES(total_response_time_ms)", // phpcs:ignore
|
||||
$basename,
|
||||
$status,
|
||||
$agg['count'],
|
||||
$agg['ms']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$cutoff = gmdate( 'Y-m-d', strtotime( '-' . self::RAW_RETENTION_DAYS . ' days' ) );
|
||||
foreach ( $files as $file ) {
|
||||
$basename = basename( $file, '.log' );
|
||||
if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $basename ) && $basename < $cutoff ) {
|
||||
@unlink( $file ); // phpcs:ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function summary( $days = 7 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'cache_stats_daily' );
|
||||
$since = gmdate( 'Y-m-d', strtotime( '-' . (int) $days . ' days' ) );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT status, SUM(request_count) AS cnt, SUM(total_response_time_ms) AS ms
|
||||
FROM {$table} WHERE stat_date >= %s GROUP BY status", // phpcs:ignore
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
$out = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$cnt = (int) $row->cnt;
|
||||
$out[ $row->status ] = array(
|
||||
'count' => $cnt,
|
||||
'avg_ms' => $cnt > 0 ? (int) round( $row->ms / $cnt ) : null,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function daily_series( $days = 14 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'cache_stats_daily' );
|
||||
$since = gmdate( 'Y-m-d', strtotime( '-' . (int) $days . ' days' ) );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT stat_date, status, request_count FROM {$table} WHERE stat_date >= %s", $since ) // phpcs:ignore
|
||||
);
|
||||
|
||||
$by_date = array();
|
||||
for ( $i = $days - 1; $i >= 0; $i-- ) {
|
||||
$d = gmdate( 'Y-m-d', strtotime( "-{$i} days" ) );
|
||||
$by_date[ $d ] = array( 'date' => $d, 'hit' => 0, 'miss' => 0, 'bypass' => 0 );
|
||||
}
|
||||
foreach ( $rows as $row ) {
|
||||
if ( ! isset( $by_date[ $row->stat_date ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$key = strtolower( $row->status );
|
||||
if ( isset( $by_date[ $row->stat_date ][ $key ] ) ) {
|
||||
$by_date[ $row->stat_date ][ $key ] += (int) $row->request_count;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values( $by_date );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Cache_Warmer {
|
||||
|
||||
const BACKOFF_OPTION = 'argus_wpd_cache_warmer_backoff';
|
||||
const SLOW_ORIGIN_MS = 3000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const REFILL_LIMIT = 200;
|
||||
|
||||
public static function process_queue() {
|
||||
$backoff = get_option( self::BACKOFF_OPTION, array( 'consecutive_slow' => 0, 'next_allowed_ts' => 0 ) );
|
||||
if ( time() < (int) ( $backoff['next_allowed_ts'] ?? 0 ) ) {
|
||||
return array( 'skipped' => true, 'reason' => 'Backing off -- origin was slow on a recent warming pass.' );
|
||||
}
|
||||
|
||||
if ( self::origin_looks_unhealthy() ) {
|
||||
self::apply_backoff( $backoff );
|
||||
return array( 'skipped' => true, 'reason' => 'Origin average response time is elevated; pausing warming this cycle.' );
|
||||
}
|
||||
|
||||
self::refill_queue();
|
||||
|
||||
$batch_size = max( 1, (int) Argus_Settings::get( 'cache_warm_batch_size', 20 ) );
|
||||
$batch = self::claim_batch( $batch_size );
|
||||
if ( empty( $batch ) ) {
|
||||
return array( 'processed' => 0 );
|
||||
}
|
||||
|
||||
$concurrency = max( 1, min( 10, (int) Argus_Settings::get( 'cache_warm_concurrency', 4 ) ) );
|
||||
$min_interval = max( 0, (int) Argus_Settings::get( 'cache_warm_min_interval_secs', 1 ) );
|
||||
|
||||
$results = array( 'ok' => 0, 'failed' => 0 );
|
||||
foreach ( array_chunk( $batch, $concurrency ) as $chunk ) {
|
||||
$chunk_results = self::fetch_concurrently( wp_list_pluck( $chunk, 'url' ) );
|
||||
foreach ( $chunk as $item ) {
|
||||
$ok = ! empty( $chunk_results[ $item->url ] );
|
||||
self::record_result( $item, $ok );
|
||||
$ok ? ++$results['ok'] : ++$results['failed'];
|
||||
}
|
||||
if ( $min_interval > 0 ) {
|
||||
sleep( $min_interval );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $results['failed'] > $results['ok'] ) {
|
||||
$backoff['consecutive_slow'] = (int) ( $backoff['consecutive_slow'] ?? 0 ) + 1;
|
||||
self::apply_backoff( $backoff );
|
||||
} else {
|
||||
update_option( self::BACKOFF_OPTION, array( 'consecutive_slow' => 0, 'next_allowed_ts' => 0 ), false );
|
||||
}
|
||||
|
||||
return array( 'processed' => count( $batch ) ) + $results;
|
||||
}
|
||||
|
||||
protected static function origin_looks_unhealthy() {
|
||||
$summary = Argus_Cache_Log::summary( 1 );
|
||||
$miss_ms = $summary[ Argus_Static_Cache::STATUS_MISS ]['avg_ms'] ?? null;
|
||||
return null !== $miss_ms && $miss_ms > self::SLOW_ORIGIN_MS;
|
||||
}
|
||||
|
||||
protected static function apply_backoff( array $backoff ) {
|
||||
$consecutive = max( 1, (int) ( $backoff['consecutive_slow'] ?? 1 ) );
|
||||
$delay = min( HOUR_IN_SECONDS, 60 * ( 2 ** $consecutive ) );
|
||||
update_option(
|
||||
self::BACKOFF_OPTION,
|
||||
array( 'consecutive_slow' => $consecutive, 'next_allowed_ts' => time() + $delay ),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
protected static function refill_queue() {
|
||||
global $wpdb;
|
||||
$discovered = Argus_DB::table( 'cache_discovered_urls' );
|
||||
$queue = Argus_DB::table( 'cache_warm_queue' );
|
||||
$home = home_url( '/' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$candidates = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT url, lastmod FROM {$discovered}
|
||||
WHERE eligibility = %s
|
||||
AND ( last_warmed_at IS NULL OR last_warmed_at < %s )
|
||||
AND NOT EXISTS ( SELECT 1 FROM {$queue} q WHERE q.url = {$discovered}.url AND q.status = 'queued' )
|
||||
ORDER BY (url = %s) DESC, lastmod DESC
|
||||
LIMIT %d", // phpcs:ignore
|
||||
Argus_Cache_Eligibility::SAFE_TO_CACHE,
|
||||
gmdate( 'Y-m-d H:i:s', strtotime( '-1 hour' ) ),
|
||||
$home,
|
||||
self::REFILL_LIMIT
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $candidates as $i => $row ) {
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$queue} (url, priority, status, queued_at) VALUES (%s, %d, 'queued', %s)", // phpcs:ignore
|
||||
$row->url,
|
||||
$row->url === $home ? 0 : ( 100 + $i ),
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function claim_batch( $limit ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'cache_warm_queue' );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = 'queued' ORDER BY priority ASC, id ASC LIMIT %d", $limit ) // phpcs:ignore
|
||||
);
|
||||
if ( empty( $rows ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$ids = wp_list_pluck( $rows, 'id' );
|
||||
$wpdb->query( "UPDATE {$table} SET status = 'processing' WHERE id IN (" . implode( ',', array_map( 'absint', $ids ) ) . ')' ); // phpcs:ignore
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
protected static function record_result( $item, $ok ) {
|
||||
global $wpdb;
|
||||
$queue = Argus_DB::table( 'cache_warm_queue' );
|
||||
$discovered = Argus_DB::table( 'cache_discovered_urls' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
if ( $ok ) {
|
||||
$wpdb->update( $queue, array( 'status' => 'done', 'last_attempt_at' => $now, 'last_result' => 'warmed' ), array( 'id' => $item->id ) );
|
||||
$wpdb->update( $discovered, array( 'last_warmed_at' => $now ), array( 'url' => $item->url ) );
|
||||
Argus_Cache_Log::record( Argus_Static_Cache::STATUS_WARMED, $item->url, 'Preloaded by cache warmer.' );
|
||||
return;
|
||||
}
|
||||
|
||||
$attempts = (int) $item->attempts + 1;
|
||||
if ( $attempts >= self::MAX_ATTEMPTS ) {
|
||||
$wpdb->update( $queue, array( 'status' => 'failed', 'attempts' => $attempts, 'last_attempt_at' => $now, 'last_result' => 'gave up after ' . $attempts . ' attempts' ), array( 'id' => $item->id ) );
|
||||
} else {
|
||||
$wpdb->update( $queue, array( 'status' => 'queued', 'attempts' => $attempts, 'last_attempt_at' => $now, 'last_result' => 'retrying' ), array( 'id' => $item->id ) );
|
||||
}
|
||||
}
|
||||
|
||||
protected static function fetch_concurrently( array $urls ) {
|
||||
if ( ! function_exists( 'curl_multi_init' ) ) {
|
||||
$out = array();
|
||||
foreach ( $urls as $url ) {
|
||||
$response = wp_remote_get( $url, array( 'timeout' => 20, 'user-agent' => self::user_agent() ) );
|
||||
$out[ $url ] = ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) < 500;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
$mh = curl_multi_init(); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
$handles = array();
|
||||
|
||||
foreach ( $urls as $url ) {
|
||||
$ch = curl_init( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
curl_setopt_array( // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
$ch,
|
||||
array(
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_USERAGENT => self::user_agent(),
|
||||
CURLOPT_NOBODY => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
)
|
||||
);
|
||||
curl_multi_add_handle( $mh, $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
$handles[ $url ] = $ch;
|
||||
}
|
||||
|
||||
$running = null;
|
||||
do {
|
||||
curl_multi_exec( $mh, $running ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
curl_multi_select( $mh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
} while ( $running > 0 );
|
||||
|
||||
$out = array();
|
||||
foreach ( $handles as $url => $ch ) {
|
||||
$code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
$out[ $url ] = $code > 0 && $code < 500;
|
||||
curl_multi_remove_handle( $mh, $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
curl_close( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
curl_multi_close( $mh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected static function user_agent() {
|
||||
return 'ARGUS-Defence-CacheWarmer/1.0 (+' . home_url( '/' ) . ')';
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'cache_warm_queue' );
|
||||
$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS cnt FROM {$table} GROUP BY status" ); // phpcs:ignore
|
||||
|
||||
$out = array( 'queued' => 0, 'processing' => 0, 'done' => 0, 'failed' => 0 );
|
||||
foreach ( $rows as $row ) {
|
||||
if ( isset( $out[ $row->status ] ) ) {
|
||||
$out[ $row->status ] = (int) $row->cnt;
|
||||
}
|
||||
}
|
||||
$backoff = get_option( self::BACKOFF_OPTION, array() );
|
||||
$out['paused_until'] = ( ! empty( $backoff['next_allowed_ts'] ) && $backoff['next_allowed_ts'] > time() ) ? $backoff['next_allowed_ts'] : null;
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Challenge {
|
||||
|
||||
const TRANSIENT_PREFIX = 'argus_wpd_pow_';
|
||||
const COOKIE_NAME = 'argus_wpd_ct';
|
||||
const DEFAULT_DIFFICULTY = 4;
|
||||
const TTL_SECONDS = 120;
|
||||
const PASS_TTL_SECONDS = 30 * MINUTE_IN_SECONDS;
|
||||
|
||||
public static function issue( $ip ) {
|
||||
$challenge_id = wp_generate_password( 20, false );
|
||||
$prefix = wp_generate_password( 16, false );
|
||||
$difficulty = (int) apply_filters( 'argus_wpd_pow_difficulty', self::DEFAULT_DIFFICULTY );
|
||||
|
||||
set_transient(
|
||||
self::TRANSIENT_PREFIX . $challenge_id,
|
||||
array(
|
||||
'ip' => $ip,
|
||||
'prefix' => $prefix,
|
||||
'difficulty' => $difficulty,
|
||||
'issued_at' => time(),
|
||||
),
|
||||
self::TTL_SECONDS
|
||||
);
|
||||
|
||||
return array(
|
||||
'challenge_id' => $challenge_id,
|
||||
'prefix' => $prefix,
|
||||
'difficulty' => $difficulty,
|
||||
);
|
||||
}
|
||||
|
||||
public static function verify( $challenge_id, $counter, $ip ) {
|
||||
$data = get_transient( self::TRANSIENT_PREFIX . $challenge_id );
|
||||
if ( ! $data || $data['ip'] !== $ip ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = hash( 'sha256', $data['prefix'] . $counter );
|
||||
$ok = 0 === strncmp( $hash, str_repeat( '0', $data['difficulty'] ), $data['difficulty'] );
|
||||
|
||||
if ( $ok ) {
|
||||
|
||||
delete_transient( self::TRANSIENT_PREFIX . $challenge_id );
|
||||
self::issue_pass_cookie( $ip );
|
||||
}
|
||||
|
||||
return $ok;
|
||||
}
|
||||
|
||||
protected static function issue_pass_cookie( $ip ) {
|
||||
$token = wp_generate_password( 32, false );
|
||||
set_transient( 'argus_wpd_pass_' . $token, $ip, self::PASS_TTL_SECONDS );
|
||||
setcookie( self::COOKIE_NAME, $token, time() + self::PASS_TTL_SECONDS, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true );
|
||||
}
|
||||
|
||||
public static function has_valid_pass( $ip ) {
|
||||
if ( empty( $_COOKIE[ self::COOKIE_NAME ] ) ) { // phpcs:ignore
|
||||
return false;
|
||||
}
|
||||
$token = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ); // phpcs:ignore
|
||||
$stored_ip = get_transient( 'argus_wpd_pass_' . $token );
|
||||
return $stored_ip && hash_equals( (string) $stored_ip, $ip );
|
||||
}
|
||||
|
||||
public static function render_and_exit( $ip ) {
|
||||
$challenge = self::issue( $ip );
|
||||
nocache_headers();
|
||||
status_header( 429 );
|
||||
header( 'Content-Type: text/html; charset=utf-8' );
|
||||
?>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Verifying your browser…</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;background:#0b0f19;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.box{text-align:center;max-width:420px;padding:32px}
|
||||
.spinner{width:32px;height:32px;border:3px solid rgba(148,163,184,.25);border-top-color:#00d4ff;border-radius:50%;margin:0 auto 20px;animation:spin 1s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
p{color:#94a3b8;font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="spinner"></div>
|
||||
<h3>Verifying your browser</h3>
|
||||
<p>This site is protected by ARGUS WordPress Defence. This should only take a moment.</p>
|
||||
</div>
|
||||
<script>
|
||||
(async function () {
|
||||
const prefix = <?php echo wp_json_encode( $challenge['prefix'] ); ?>;
|
||||
const difficulty = <?php echo (int) $challenge['difficulty']; ?>;
|
||||
const challengeId = <?php echo wp_json_encode( $challenge['challenge_id'] ); ?>;
|
||||
const target = '0'.repeat(difficulty);
|
||||
|
||||
async function sha256Hex(input) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
while (true) {
|
||||
const hash = await sha256Hex(prefix + counter);
|
||||
if (hash.startsWith(target)) break;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = window.location.href;
|
||||
for (const [name, value] of Object.entries({ argus_wpd_challenge_id: challengeId, argus_wpd_counter: counter })) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function maybe_handle_submission( $ip ) {
|
||||
if ( empty( $_POST['argus_wpd_challenge_id'] ) || ! isset( $_POST['argus_wpd_counter'] ) ) { // phpcs:ignore
|
||||
return false;
|
||||
}
|
||||
|
||||
$challenge_id = sanitize_text_field( wp_unslash( $_POST['argus_wpd_challenge_id'] ) ); // phpcs:ignore
|
||||
$counter = sanitize_text_field( wp_unslash( $_POST['argus_wpd_counter'] ) ); // phpcs:ignore
|
||||
|
||||
return self::verify( $challenge_id, $counter, $ip );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Charts {
|
||||
|
||||
const PALETTE = array( '#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#9085e9', '#e66767', '#17a2b8' );
|
||||
|
||||
public static function donut( array $data, $size = 168, $stroke = 24 ) {
|
||||
$total = array_sum( $data );
|
||||
if ( $total <= 0 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$r = ( $size - $stroke ) / 2;
|
||||
$c = $size / 2;
|
||||
$circumference = 2 * M_PI * $r;
|
||||
|
||||
$segments = '';
|
||||
$offset = 0;
|
||||
$i = 0;
|
||||
foreach ( $data as $label => $value ) {
|
||||
if ( $value <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
$len = ( $value / $total ) * $circumference;
|
||||
$color = self::PALETTE[ $i % count( self::PALETTE ) ];
|
||||
|
||||
$segments .= sprintf(
|
||||
'<circle cx="%1$s" cy="%1$s" r="%2$s" fill="none" stroke="%3$s" stroke-width="%4$s" stroke-dasharray="%5$s %6$s" stroke-dashoffset="%7$s" stroke-linecap="butt" transform="rotate(-90 %1$s %1$s)"><title>%8$s: %9$s</title></circle>',
|
||||
esc_attr( $c ),
|
||||
esc_attr( $r ),
|
||||
esc_attr( $color ),
|
||||
esc_attr( $stroke ),
|
||||
esc_attr( round( $len, 3 ) ),
|
||||
esc_attr( round( $circumference - $len, 3 ) ),
|
||||
esc_attr( round( -$offset, 3 ) ),
|
||||
esc_html( $label ),
|
||||
esc_html( number_format_i18n( $value ) )
|
||||
);
|
||||
$offset += $len;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<svg viewBox="0 0 %1$d %1$d" class="argus-wpd-donut-svg" role="img" aria-label="%2$s">'
|
||||
. '<circle cx="%3$s" cy="%3$s" r="%4$s" fill="none" stroke="var(--panel-2)" stroke-width="%5$s"></circle>'
|
||||
. '%6$s'
|
||||
. '<text x="%3$s" y="%3$s" text-anchor="middle" dominant-baseline="middle" class="argus-wpd-donut-total">%7$s</text>'
|
||||
. '</svg>',
|
||||
$size,
|
||||
esc_attr__( 'Distribution chart', 'argus-wordpress-defence' ),
|
||||
esc_attr( $c ),
|
||||
esc_attr( $r ),
|
||||
esc_attr( $stroke ),
|
||||
$segments,
|
||||
esc_html( number_format_i18n( $total ) )
|
||||
);
|
||||
}
|
||||
|
||||
public static function legend( array $data ) {
|
||||
$total = array_sum( $data );
|
||||
if ( $total <= 0 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="argus-wpd-donut-legend">';
|
||||
$i = 0;
|
||||
foreach ( $data as $label => $value ) {
|
||||
if ( $value <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
$color = self::PALETTE[ $i % count( self::PALETTE ) ];
|
||||
$pct = round( $value / $total * 100 );
|
||||
|
||||
$html .= sprintf(
|
||||
'<div class="argus-wpd-donut-legend-row"><span class="argus-wpd-donut-swatch" style="background:%1$s"></span><span class="argus-wpd-donut-legend-label">%2$s</span><span class="argus-wpd-donut-legend-value">%3$s <em>(%4$s%%)</em></span></div>',
|
||||
esc_attr( $color ),
|
||||
esc_html( $label ),
|
||||
esc_html( number_format_i18n( $value ) ),
|
||||
esc_html( $pct )
|
||||
);
|
||||
$i++;
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
public static function area_chart( array $series, $width = 640, $height = 160 ) {
|
||||
$count = count( $series );
|
||||
if ( $count < 2 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$values = wp_list_pluck( $series, 'value' );
|
||||
$max = max( 1, max( $values ) );
|
||||
$pad_x = 8;
|
||||
$pad_y = 10;
|
||||
$plot_w = $width - ( $pad_x * 2 );
|
||||
$plot_h = $height - ( $pad_y * 2 );
|
||||
$step = $plot_w / ( $count - 1 );
|
||||
|
||||
$points = array();
|
||||
foreach ( array_values( $values ) as $i => $value ) {
|
||||
$x = $pad_x + ( $i * $step );
|
||||
$y = $pad_y + $plot_h - ( ( $value / $max ) * $plot_h );
|
||||
$points[] = array( round( $x, 2 ), round( $y, 2 ) );
|
||||
}
|
||||
|
||||
$line_path = 'M ' . implode( ' L ', array_map( function ( $p ) {
|
||||
return $p[0] . ' ' . $p[1];
|
||||
}, $points ) );
|
||||
|
||||
$fill_path = $line_path
|
||||
. sprintf( ' L %s %s', round( $pad_x + $plot_w, 2 ), round( $pad_y + $plot_h, 2 ) )
|
||||
. sprintf( ' L %s %s Z', $pad_x, round( $pad_y + $plot_h, 2 ) );
|
||||
|
||||
$last = end( $points );
|
||||
|
||||
return sprintf(
|
||||
'<svg viewBox="0 0 %1$d %2$d" class="argus-wpd-area-svg" role="img" aria-label="%3$s" preserveAspectRatio="none">'
|
||||
. '<defs><linearGradient id="argusWpdAreaFill" x1="0" y1="0" x2="0" y2="1">'
|
||||
. '<stop offset="0%%" stop-color="var(--cyan)" stop-opacity="0.35"></stop>'
|
||||
. '<stop offset="100%%" stop-color="var(--cyan)" stop-opacity="0"></stop>'
|
||||
. '</linearGradient></defs>'
|
||||
. '<path d="%4$s" fill="url(#argusWpdAreaFill)" stroke="none"></path>'
|
||||
. '<path d="%5$s" fill="none" stroke="var(--cyan)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></path>'
|
||||
. '<circle cx="%6$s" cy="%7$s" r="3.5" fill="var(--cyan)"></circle>'
|
||||
. '</svg>',
|
||||
$width,
|
||||
$height,
|
||||
esc_attr__( 'Blocked requests over time', 'argus-wordpress-defence' ),
|
||||
esc_attr( $fill_path ),
|
||||
esc_attr( $line_path ),
|
||||
esc_attr( $last[0] ),
|
||||
esc_attr( $last[1] )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Console_Stats {
|
||||
|
||||
const NON_WAF_LABELS = array(
|
||||
'active-ban' => 'Already-banned IP retry',
|
||||
'brute-force-threshold' => 'Brute-force login threshold',
|
||||
'login-elevated' => 'Elevated login failure rate',
|
||||
'exception-ip' => 'Allowlisted IP',
|
||||
'xmlrpc_abuse' => 'XML-RPC abuse',
|
||||
'rest_abuse' => 'REST API user enumeration',
|
||||
'waf-no-hits' => 'No WAF signal',
|
||||
'waf-low-severity' => 'Low-severity WAF signal',
|
||||
'no-match' => 'No policy rule matched',
|
||||
);
|
||||
|
||||
const ACCOUNT_TRIGGERS = array( 'active-ban', 'brute-force-threshold', 'login-elevated', 'exception-ip' );
|
||||
const API_TRIGGERS = array( 'xmlrpc_abuse', 'rest_abuse' );
|
||||
|
||||
public static function protection_state() {
|
||||
if ( ! Argus_Settings::get( 'waf_enabled', true ) ) {
|
||||
return 'action_required';
|
||||
}
|
||||
|
||||
$open = Argus_Findings::open_counts();
|
||||
if ( ( $open['critical'] ?? 0 ) > 0 || ( $open['high'] ?? 0 ) > 0 ) {
|
||||
return 'action_required';
|
||||
}
|
||||
|
||||
if ( class_exists( 'Argus_MU_Installer' ) ) {
|
||||
$activated_at = get_option( 'argus_wpd_activated_at' );
|
||||
$just_activated = $activated_at && ( time() - strtotime( $activated_at . ' UTC' ) ) < 5 * MINUTE_IN_SECONDS;
|
||||
if ( $just_activated && 'never' === Argus_MU_Installer::execution_status() ) {
|
||||
return 'checking';
|
||||
}
|
||||
}
|
||||
|
||||
return 'protected';
|
||||
}
|
||||
|
||||
public static function blocked_today() {
|
||||
$since = gmdate( 'Y-m-d 00:00:00' );
|
||||
return self::count_actions_since( $since, 'block' );
|
||||
}
|
||||
|
||||
public static function blocked_since_days( $days ) {
|
||||
$since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $days * DAY_IN_SECONDS ) );
|
||||
return self::count_actions_since( $since, 'block' );
|
||||
}
|
||||
|
||||
protected static function count_actions_since( $since_mysql, $action = null ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
|
||||
if ( $action ) {
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE action = %s AND created_at >= %s", $action, $since_mysql ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s", $since_mysql ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
public static function malicious_ip_count() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'bans' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT ip) FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s)", // phpcs:ignore
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function security_events_count( $days = 30 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'events' );
|
||||
$since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $days * DAY_IN_SECONDS ) );
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND severity IN ('critical','high','medium')", // phpcs:ignore
|
||||
$since
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function blocked_series( $range ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
|
||||
switch ( $range ) {
|
||||
case '30d':
|
||||
$buckets = 30;
|
||||
$bucket_secs = DAY_IN_SECONDS;
|
||||
$fmt = 'M j';
|
||||
break;
|
||||
case '7d':
|
||||
$buckets = 7;
|
||||
$bucket_secs = DAY_IN_SECONDS;
|
||||
$fmt = 'D';
|
||||
break;
|
||||
case '24h':
|
||||
default:
|
||||
$buckets = 24;
|
||||
$bucket_secs = HOUR_IN_SECONDS;
|
||||
$fmt = 'ga';
|
||||
break;
|
||||
}
|
||||
|
||||
$now = (int) current_time( 'timestamp', true );
|
||||
$since_epoch = $now - ( $buckets * $bucket_secs );
|
||||
$since_mysql = gmdate( 'Y-m-d H:i:s', $since_epoch );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT created_at FROM {$table} WHERE action = %s AND created_at >= %s", // phpcs:ignore
|
||||
'block',
|
||||
$since_mysql
|
||||
)
|
||||
);
|
||||
|
||||
$series = array();
|
||||
for ( $i = $buckets - 1; $i >= 0; $i-- ) {
|
||||
$bucket_start = $now - ( $i * $bucket_secs );
|
||||
$key = (int) floor( $bucket_start / $bucket_secs );
|
||||
$series[ $key ] = array(
|
||||
'label' => gmdate( $fmt, $bucket_start ),
|
||||
'value' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$ts = strtotime( $row->created_at . ' UTC' );
|
||||
$key = (int) floor( $ts / $bucket_secs );
|
||||
if ( isset( $series[ $key ] ) ) {
|
||||
$series[ $key ]['value']++;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values( $series );
|
||||
}
|
||||
|
||||
public static function blocked_per_minute_series( $minutes = 30 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
|
||||
$now = (int) current_time( 'timestamp', true );
|
||||
$since_epoch = $now - ( $minutes * MINUTE_IN_SECONDS );
|
||||
$since_mysql = gmdate( 'Y-m-d H:i:s', $since_epoch );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT created_at FROM {$table} WHERE action = %s AND created_at >= %s", // phpcs:ignore
|
||||
'block',
|
||||
$since_mysql
|
||||
)
|
||||
);
|
||||
|
||||
$series = array();
|
||||
for ( $i = $minutes - 1; $i >= 0; $i-- ) {
|
||||
$bucket_start = $now - ( $i * MINUTE_IN_SECONDS );
|
||||
$key = (int) floor( $bucket_start / MINUTE_IN_SECONDS );
|
||||
$series[ $key ] = array(
|
||||
'label' => gmdate( 'H:i', $bucket_start ),
|
||||
'value' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$ts = strtotime( $row->created_at . ' UTC' );
|
||||
$key = (int) floor( $ts / MINUTE_IN_SECONDS );
|
||||
if ( isset( $series[ $key ] ) ) {
|
||||
$series[ $key ]['value']++;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values( $series );
|
||||
}
|
||||
|
||||
public static function live_feed_time_label( $created_at_utc_mysql ) {
|
||||
$ts = strtotime( $created_at_utc_mysql . ' UTC' );
|
||||
if ( wp_date( 'Y-m-d', $ts ) === wp_date( 'Y-m-d' ) ) {
|
||||
return wp_date( 'H:i:s', $ts );
|
||||
}
|
||||
return wp_date( 'M j, H:i', $ts );
|
||||
}
|
||||
|
||||
public static function live_feed_description( $flag, $ip, $type ) {
|
||||
return sprintf(
|
||||
|
||||
__( 'Blocked %3$s from %1$s %2$s', 'argus-wordpress-defence' ),
|
||||
$flag,
|
||||
$ip,
|
||||
$type
|
||||
);
|
||||
}
|
||||
|
||||
public static function latest_blocked_events( $limit = 10 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT ip, rule_name, created_at FROM {$table} WHERE action = 'block' ORDER BY created_at DESC LIMIT %d", // phpcs:ignore
|
||||
$limit
|
||||
)
|
||||
);
|
||||
|
||||
$out = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$type = self::rule_label( self::first_rule_id( $row->rule_name ) );
|
||||
$out[] = array(
|
||||
'ip' => $row->ip,
|
||||
'flag' => Argus_GeoIP::icon( $row->ip ),
|
||||
'origin' => Argus_GeoIP::label( $row->ip ),
|
||||
'type' => $type,
|
||||
'desc' => self::live_feed_description( Argus_GeoIP::icon( $row->ip ), $row->ip, $type ),
|
||||
'created_at' => $row->created_at,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
const ACTIVITY_PER_PAGE = 10;
|
||||
|
||||
public static function recent_activity_paginated( $page = 1, $per_page = self::ACTIVITY_PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE action != 'allow'" ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT ip, trigger_type, rule_name, action, created_at FROM {$table} WHERE action != 'allow' ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore
|
||||
$per_page,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->label = self::rule_label( self::first_rule_id( $row->rule_name ) );
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function first_rule_id( $rule_name ) {
|
||||
$parts = explode( ',', (string) $rule_name );
|
||||
return trim( $parts[0] );
|
||||
}
|
||||
|
||||
public static function firewall_rule_hits( $hours = 24 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
$since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $hours * HOUR_IN_SECONDS ) );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT rule_name, action, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s AND action != 'allow' AND rule_name IS NOT NULL AND rule_name != '' GROUP BY rule_name, action", // phpcs:ignore
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
$tally = array();
|
||||
foreach ( $rows as $row ) {
|
||||
foreach ( explode( ',', $row->rule_name ) as $rule_id ) {
|
||||
$rule_id = trim( $rule_id );
|
||||
if ( '' === $rule_id ) {
|
||||
continue;
|
||||
}
|
||||
$key = $rule_id . '|' . $row->action;
|
||||
$tally[ $key ] = ( $tally[ $key ] ?? 0 ) + (int) $row->cnt;
|
||||
}
|
||||
}
|
||||
|
||||
arsort( $tally );
|
||||
$tally = array_slice( $tally, 0, 10, true );
|
||||
|
||||
$out = array();
|
||||
foreach ( $tally as $key => $cnt ) {
|
||||
list( $rule_id, $action ) = explode( '|', $key, 2 );
|
||||
$out[] = array(
|
||||
'rule_id' => $rule_id,
|
||||
'label' => self::rule_label( $rule_id ),
|
||||
'category' => self::rule_category_label( $rule_id ),
|
||||
'action' => $action,
|
||||
'hits' => $cnt,
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function attack_category_totals( $hours = 24 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
$since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $hours * HOUR_IN_SECONDS ) );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT rule_name, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s AND action != 'allow' AND rule_name IS NOT NULL AND rule_name != '' GROUP BY rule_name", // phpcs:ignore
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
$totals = array();
|
||||
foreach ( $rows as $row ) {
|
||||
foreach ( explode( ',', $row->rule_name ) as $rule_id ) {
|
||||
$rule_id = trim( $rule_id );
|
||||
if ( '' === $rule_id ) {
|
||||
continue;
|
||||
}
|
||||
$label = self::rule_category_label( $rule_id );
|
||||
$totals[ $label ] = ( $totals[ $label ] ?? 0 ) + (int) $row->cnt;
|
||||
}
|
||||
}
|
||||
|
||||
arsort( $totals );
|
||||
return $totals;
|
||||
}
|
||||
|
||||
public static function security_action_totals( $hours = 24 ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
$since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $hours * HOUR_IN_SECONDS ) );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT action, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s AND action != 'allow' GROUP BY action", // phpcs:ignore
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
$labels = array(
|
||||
'block' => __( 'Blocked', 'argus-wordpress-defence' ),
|
||||
'challenge' => __( 'Challenged', 'argus-wordpress-defence' ),
|
||||
'rate_limit' => __( 'Rate Limited', 'argus-wordpress-defence' ),
|
||||
);
|
||||
|
||||
$totals = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$label = $labels[ $row->action ] ?? ucfirst( $row->action );
|
||||
$totals[ $label ] = ( $totals[ $label ] ?? 0 ) + (int) $row->cnt;
|
||||
}
|
||||
|
||||
arsort( $totals );
|
||||
return $totals;
|
||||
}
|
||||
|
||||
protected static function waf_rule_map() {
|
||||
static $map = null;
|
||||
if ( null === $map ) {
|
||||
$map = array();
|
||||
foreach ( Argus_WAF_Rules::corpus() as $rule ) {
|
||||
$map[ $rule['id'] ] = $rule;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
public static function rule_label( $rule_id ) {
|
||||
if ( isset( self::waf_rule_map()[ $rule_id ] ) || isset( self::NON_WAF_LABELS[ $rule_id ] ) ) {
|
||||
return self::NON_WAF_LABELS[ $rule_id ] ?? self::humanize_rule_id( $rule_id );
|
||||
}
|
||||
return self::humanize_rule_id( $rule_id );
|
||||
}
|
||||
|
||||
protected static function humanize_rule_id( $rule_id ) {
|
||||
return ucwords( str_replace( '-', ' ', $rule_id ) );
|
||||
}
|
||||
|
||||
public static function rule_category_label( $rule_id ) {
|
||||
$map = self::waf_rule_map();
|
||||
if ( isset( $map[ $rule_id ] ) ) {
|
||||
return self::waf_category_label( $map[ $rule_id ]['category'] );
|
||||
}
|
||||
if ( in_array( $rule_id, self::ACCOUNT_TRIGGERS, true ) ) {
|
||||
return __( 'Account', 'argus-wordpress-defence' );
|
||||
}
|
||||
if ( in_array( $rule_id, self::API_TRIGGERS, true ) ) {
|
||||
return __( 'API Abuse', 'argus-wordpress-defence' );
|
||||
}
|
||||
return __( 'Policy', 'argus-wordpress-defence' );
|
||||
}
|
||||
|
||||
public static function waf_category_label( $category ) {
|
||||
$labels = array(
|
||||
'sql_injection' => __( 'SQL Injection', 'argus-wordpress-defence' ),
|
||||
'xss' => __( 'Cross-Site Scripting', 'argus-wordpress-defence' ),
|
||||
'rce' => __( 'Remote Code Execution', 'argus-wordpress-defence' ),
|
||||
'file_access' => __( 'Path Traversal / LFI', 'argus-wordpress-defence' ),
|
||||
'protocol_anomaly' => __( 'Protocol Anomaly', 'argus-wordpress-defence' ),
|
||||
);
|
||||
return $labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Correlation {
|
||||
|
||||
const WATERMARK_OPTION = 'argus_wpd_correlation_watermark';
|
||||
const WINDOW_SECONDS = 15 * MINUTE_IN_SECONDS;
|
||||
|
||||
public static function run() {
|
||||
global $wpdb;
|
||||
$events_table = Argus_DB::table( 'events' );
|
||||
$since = get_option( self::WATERMARK_OPTION, gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$suspicious = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$events_table} WHERE event_type IN ('ban','policy_observed','login_failed') AND severity IN ('high','critical') AND ip IS NOT NULL AND created_at >= %s ORDER BY created_at ASC", // phpcs:ignore
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $suspicious ) ) {
|
||||
update_option( self::WATERMARK_OPTION, $now, false );
|
||||
return 0;
|
||||
}
|
||||
|
||||
$file_events = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$events_table} WHERE event_type IN ('integrity_new_file','integrity_changed_file') AND created_at >= %s ORDER BY created_at ASC", // phpcs:ignore
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
$by_ip = array();
|
||||
foreach ( $suspicious as $event ) {
|
||||
$by_ip[ $event->ip ][] = $event;
|
||||
}
|
||||
|
||||
$correlated = 0;
|
||||
|
||||
foreach ( $by_ip as $ip => $ip_events ) {
|
||||
$request_event = $ip_events[0];
|
||||
$request_time = strtotime( $request_event->created_at . ' UTC' );
|
||||
|
||||
$best_delta = null;
|
||||
$best_file = null;
|
||||
$best_payload = null;
|
||||
|
||||
foreach ( $file_events as $file_event ) {
|
||||
$payload = json_decode( $file_event->payload, true );
|
||||
$file_mtime = isset( $payload['file_mtime'] ) ? strtotime( $payload['file_mtime'] . ' UTC' ) : null;
|
||||
if ( null === $file_mtime ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$delta = $file_mtime - $request_time;
|
||||
if ( $delta < 0 || $delta > self::WINDOW_SECONDS ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( null === $best_delta || $delta < $best_delta ) {
|
||||
$best_delta = $delta;
|
||||
$best_file = $file_event;
|
||||
$best_payload = $payload;
|
||||
}
|
||||
}
|
||||
|
||||
if ( null !== $best_file ) {
|
||||
self::record_correlated_finding( $request_event, $best_file, $best_payload, $best_delta );
|
||||
$correlated++;
|
||||
}
|
||||
}
|
||||
|
||||
update_option( self::WATERMARK_OPTION, $now, false );
|
||||
return $correlated;
|
||||
}
|
||||
|
||||
protected static function record_correlated_finding( $request_event, $file_event, array $file_payload, $delta_seconds ) {
|
||||
Argus_Findings::record(
|
||||
'correlation',
|
||||
'critical',
|
||||
array(
|
||||
'what_happened' => sprintf(
|
||||
'A high-severity request from %s was followed %d seconds later by a %s: %s',
|
||||
$request_event->ip,
|
||||
$delta_seconds,
|
||||
'integrity_new_file' === $file_event->event_type ? 'new file' : 'changed file',
|
||||
$file_payload['file_path'] ?? 'unknown'
|
||||
),
|
||||
'why_it_matters' => 'A suspicious request closely followed by a filesystem change is a much stronger signal than either event alone -- this is the pattern a successful exploit-then-webshell-drop typically produces.',
|
||||
'what_argus_found' => sprintf(
|
||||
'Request event: %s (%s, severity %s) at %s. File event: %s at %s.',
|
||||
$request_event->event_type,
|
||||
$request_event->summary,
|
||||
$request_event->severity,
|
||||
$request_event->created_at,
|
||||
$file_event->summary,
|
||||
$file_event->created_at
|
||||
),
|
||||
'when_it_happened' => $file_event->created_at,
|
||||
'why_suspicious' => sprintf( 'The file change happened within %d seconds of the suspicious request -- close enough in time to be a plausible cause-and-effect, though ARGUS cannot prove the same actor performed both (a filesystem write carries no IP attribution).', self::WINDOW_SECONDS ),
|
||||
'what_could_be_affected' => 'If this represents a successful exploit, the new/changed file may itself be malicious code with the same access as your web application.',
|
||||
'what_should_you_do' => 'Review the file named above immediately, and treat the source IP as a confirmed active threat rather than a routine block.',
|
||||
),
|
||||
array(
|
||||
'request_event_id' => $request_event->id,
|
||||
'file_event_id' => $file_event->id,
|
||||
'ip' => $request_event->ip,
|
||||
'delta_seconds' => $delta_seconds,
|
||||
)
|
||||
);
|
||||
|
||||
Argus_Ban_Engine::ban(
|
||||
$request_event->ip,
|
||||
Argus_Ban_Engine::SOURCE_POLICY_ENGINE,
|
||||
'Correlated with a filesystem change ' . $delta_seconds . 's later',
|
||||
array( 'request_event_id' => $request_event->id, 'file_event_id' => $file_event->id ),
|
||||
Argus_Ban_Engine::LEVEL_EXTENDED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_DB {
|
||||
|
||||
const SCHEMA_VERSION = '8';
|
||||
const SCHEMA_OPTION = 'argus_wpd_schema_version';
|
||||
|
||||
protected static function drop_index_if_exists( $table, $index ) {
|
||||
global $wpdb;
|
||||
$exists = $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW INDEX FROM ' . $table . ' WHERE Key_name = %s', $index ) // phpcs:ignore
|
||||
);
|
||||
if ( $exists ) {
|
||||
$wpdb->query( 'ALTER TABLE ' . $table . ' DROP INDEX ' . $index ); // phpcs:ignore
|
||||
}
|
||||
}
|
||||
|
||||
public static function table( $name ) {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'argus_' . $name;
|
||||
}
|
||||
|
||||
public static function install() {
|
||||
global $wpdb;
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$events = self::table( 'events' );
|
||||
$findings = self::table( 'findings' );
|
||||
$bans = self::table( 'bans' );
|
||||
$policy_decisions = self::table( 'policy_decisions' );
|
||||
$integrity_baseline = self::table( 'integrity_baseline' );
|
||||
$login_attempts = self::table( 'login_attempts' );
|
||||
$vuln_cache = self::table( 'vuln_cache' );
|
||||
$quarantine = self::table( 'quarantine' );
|
||||
$cache_stats_daily = self::table( 'cache_stats_daily' );
|
||||
$cache_discovered_urls = self::table( 'cache_discovered_urls' );
|
||||
$cache_warm_queue = self::table( 'cache_warm_queue' );
|
||||
$anis_reputation = self::table( 'anis_reputation' );
|
||||
$vuln_status = self::table( 'vuln_status' );
|
||||
$scan_history = self::table( 'scan_history' );
|
||||
$backups = self::table( 'backups' );
|
||||
|
||||
$sql = "CREATE TABLE {$events} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
event_type VARCHAR(40) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
ip VARCHAR(45) NULL,
|
||||
summary VARCHAR(255) NOT NULL,
|
||||
payload LONGTEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY event_type (event_type),
|
||||
KEY created_at (created_at),
|
||||
KEY ip (ip)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$findings} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
category VARCHAR(40) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
detail LONGTEXT NULL,
|
||||
remediation_hint LONGTEXT NULL,
|
||||
evidence LONGTEXT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY category (category),
|
||||
KEY status (status),
|
||||
KEY severity (severity)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$bans} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ip VARCHAR(45) NOT NULL,
|
||||
source VARCHAR(40) NOT NULL,
|
||||
reason VARCHAR(255) NOT NULL,
|
||||
evidence LONGTEXT NULL,
|
||||
ban_level VARCHAR(20) NOT NULL DEFAULT 'temporary',
|
||||
created_at DATETIME NOT NULL,
|
||||
expires_at DATETIME NULL,
|
||||
recovery_eligible_at DATETIME NULL,
|
||||
lifted_at DATETIME NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY ip (ip),
|
||||
KEY expires_at (expires_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
self::drop_index_if_exists( $bans, 'ip_active' );
|
||||
|
||||
$sql = "CREATE TABLE {$policy_decisions} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ip VARCHAR(45) NOT NULL,
|
||||
trigger_type VARCHAR(40) NOT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
rule_name VARCHAR(100) NULL,
|
||||
reason VARCHAR(255) NOT NULL,
|
||||
observation_only TINYINT(1) NOT NULL DEFAULT 0,
|
||||
mode VARCHAR(20) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY ip (ip),
|
||||
KEY created_at (created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$integrity_baseline} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_hash CHAR(64) NOT NULL,
|
||||
file_size BIGINT UNSIGNED NOT NULL,
|
||||
file_mtime DATETIME NOT NULL,
|
||||
category VARCHAR(20) NOT NULL,
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_checked_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY file_path (file_path(191)),
|
||||
KEY category (category)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$login_attempts} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ip VARCHAR(45) NOT NULL,
|
||||
username VARCHAR(60) NOT NULL,
|
||||
success TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY ip_created (ip, created_at),
|
||||
KEY username_created (username, created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$vuln_cache} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
component_type VARCHAR(20) NOT NULL,
|
||||
slug VARCHAR(191) NOT NULL,
|
||||
vulnerable_below VARCHAR(40) NULL,
|
||||
fixed_in VARCHAR(40) NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
cve VARCHAR(20) NULL,
|
||||
description LONGTEXT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY component_slug (component_type, slug(120))
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$quarantine} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
original_path VARCHAR(500) NOT NULL,
|
||||
quarantine_filename VARCHAR(60) NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
file_size BIGINT UNSIGNED NOT NULL,
|
||||
file_hash CHAR(64) NOT NULL,
|
||||
original_mtime DATETIME NULL,
|
||||
original_perms VARCHAR(4) NULL,
|
||||
detection_engine VARCHAR(60) NOT NULL,
|
||||
detection_rule VARCHAR(100) NULL,
|
||||
detection_type VARCHAR(20) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
confidence_score INT NULL,
|
||||
matched_rules LONGTEXT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'quarantined',
|
||||
finding_id BIGINT UNSIGNED NULL,
|
||||
quarantined_at DATETIME NOT NULL,
|
||||
analysed_at DATETIME NULL,
|
||||
resolved_at DATETIME NULL,
|
||||
restore_attempts INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY quarantine_filename (quarantine_filename),
|
||||
KEY status (status),
|
||||
KEY finding_id (finding_id)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$cache_stats_daily} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
request_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_response_time_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY date_status (stat_date, status)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$cache_discovered_urls} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
source VARCHAR(40) NOT NULL,
|
||||
eligibility VARCHAR(20) NOT NULL DEFAULT 'needs_review',
|
||||
eligibility_reason VARCHAR(255) NULL,
|
||||
priority_hint DECIMAL(3,2) NULL,
|
||||
lastmod DATETIME NULL,
|
||||
last_checked_at DATETIME NULL,
|
||||
last_warmed_at DATETIME NULL,
|
||||
discovered_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY url (url(191)),
|
||||
KEY eligibility (eligibility)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$cache_warm_queue} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'queued',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
last_attempt_at DATETIME NULL,
|
||||
last_result VARCHAR(255) NULL,
|
||||
queued_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY status_priority (status, priority),
|
||||
KEY url (url(191))
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$anis_reputation} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ip VARCHAR(45) NOT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
confidence INT NOT NULL DEFAULT 0,
|
||||
reports INT NOT NULL DEFAULT 0,
|
||||
source VARCHAR(60) NULL,
|
||||
reason VARCHAR(255) NULL,
|
||||
decided_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
cached_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY ip (ip),
|
||||
KEY action (action)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$vuln_status} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
component_type VARCHAR(20) NOT NULL,
|
||||
slug VARCHAR(191) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
installed_version VARCHAR(40) NULL,
|
||||
checked_version VARCHAR(40) NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
severity VARCHAR(20) NULL,
|
||||
cve VARCHAR(20) NULL,
|
||||
fixed_in VARCHAR(40) NULL,
|
||||
description LONGTEXT NULL,
|
||||
last_checked_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY component (component_type, slug),
|
||||
KEY status (status)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$scan_history} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scan_type VARCHAR(30) NOT NULL,
|
||||
started_at DATETIME NOT NULL,
|
||||
finished_at DATETIME NOT NULL,
|
||||
files_scanned INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
findings_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
result VARCHAR(20) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY started_at (started_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
$sql = "CREATE TABLE {$backups} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
filename VARCHAR(191) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'complete',
|
||||
error_message TEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY filename (filename),
|
||||
KEY created_at (created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta( $sql );
|
||||
|
||||
update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION, false );
|
||||
}
|
||||
|
||||
public static function maybe_upgrade() {
|
||||
if ( get_option( self::SCHEMA_OPTION ) !== self::SCHEMA_VERSION ) {
|
||||
self::install();
|
||||
}
|
||||
}
|
||||
|
||||
public static function uninstall() {
|
||||
global $wpdb;
|
||||
foreach ( array( 'events', 'findings', 'bans', 'policy_decisions', 'integrity_baseline', 'login_attempts', 'vuln_cache', 'quarantine', 'cache_stats_daily', 'cache_discovered_urls', 'cache_warm_queue', 'anis_reputation', 'vuln_status', 'scan_history', 'backups' ) as $t ) {
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table( $t ) ); // phpcs:ignore
|
||||
}
|
||||
delete_option( self::SCHEMA_OPTION );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Deactivator {
|
||||
|
||||
public static function deactivate() {
|
||||
wp_clear_scheduled_hook( 'argus_wpd_hourly' );
|
||||
wp_clear_scheduled_hook( 'argus_wpd_daily' );
|
||||
wp_clear_scheduled_hook( 'argus_wpd_five_minutes' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Events {
|
||||
|
||||
const DEFAULT_MAX_ROWS = 50000;
|
||||
|
||||
public static function record( $type, $severity, $summary, array $payload = array(), $ip = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'events' ),
|
||||
array(
|
||||
'event_type' => $type,
|
||||
'severity' => $severity,
|
||||
'ip' => $ip,
|
||||
'summary' => $summary,
|
||||
'payload' => wp_json_encode( $payload ),
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
public static function recent( $limit = 50, $type = null ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'events' );
|
||||
|
||||
if ( $type ) {
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE event_type = %s ORDER BY created_at DESC LIMIT %d", $type, $limit ) // phpcs:ignore
|
||||
);
|
||||
} else {
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->payload = json_decode( $row->payload, true );
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function counts_since( $since_mysql ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'events' );
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT event_type, severity, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s GROUP BY event_type, severity", // phpcs:ignore
|
||||
$since_mysql
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
public static function paginated( $page = 1, array $filters = array(), $per_page = self::PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'events' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
$where = array( '1=1' );
|
||||
$args = array();
|
||||
|
||||
if ( ! empty( $filters['search'] ) ) {
|
||||
$where[] = 'summary LIKE %s';
|
||||
$args[] = '%' . $wpdb->esc_like( $filters['search'] ) . '%';
|
||||
}
|
||||
if ( ! empty( $filters['severity'] ) ) {
|
||||
$where[] = 'severity = %s';
|
||||
$args[] = $filters['severity'];
|
||||
}
|
||||
if ( ! empty( $filters['type'] ) ) {
|
||||
$where[] = 'event_type = %s';
|
||||
$args[] = $filters['type'];
|
||||
}
|
||||
if ( ! empty( $filters['since'] ) ) {
|
||||
$where[] = 'created_at >= %s';
|
||||
$args[] = $filters['since'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where );
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; // phpcs:ignore
|
||||
$total = (int) ( $args ? $wpdb->get_var( $wpdb->prepare( $count_sql, $args ) ) : $wpdb->get_var( $count_sql ) ); // phpcs:ignore
|
||||
|
||||
$list_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; // phpcs:ignore
|
||||
$list_args = array_merge( $args, array( $per_page, $offset ) );
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( $list_sql, $list_args ) ); // phpcs:ignore
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->payload = json_decode( $row->payload, true );
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
|
||||
public static function distinct_types() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'events' );
|
||||
return $wpdb->get_col( "SELECT DISTINCT event_type FROM {$table} ORDER BY event_type ASC" ); // phpcs:ignore
|
||||
}
|
||||
|
||||
const RETENTION_DAYS_BY_SEVERITY = array(
|
||||
'info' => 30,
|
||||
'low' => 90,
|
||||
'medium' => 365,
|
||||
'high' => 365,
|
||||
'critical' => 365,
|
||||
);
|
||||
|
||||
public static function prune() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'events' );
|
||||
|
||||
$retention = apply_filters( 'argus_wpd_event_retention_days', self::RETENTION_DAYS_BY_SEVERITY );
|
||||
$deleted = 0;
|
||||
foreach ( $retention as $severity => $days ) {
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( (int) $days * DAY_IN_SECONDS ) );
|
||||
$deleted += (int) $wpdb->query(
|
||||
$wpdb->prepare( "DELETE FROM {$table} WHERE severity = %s AND created_at < %s", $severity, $cutoff ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
$max_rows = (int) apply_filters( 'argus_wpd_max_event_rows', self::DEFAULT_MAX_ROWS );
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
||||
if ( $total <= $max_rows ) {
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
$overflow = $total - $max_rows;
|
||||
$deleted += (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$table} ORDER BY created_at ASC LIMIT %d", // phpcs:ignore
|
||||
$overflow
|
||||
)
|
||||
);
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Explain {
|
||||
|
||||
const TEMPLATE_FIELDS = array(
|
||||
'what_happened',
|
||||
'why_it_matters',
|
||||
'what_argus_found',
|
||||
'when_it_happened',
|
||||
'why_suspicious',
|
||||
'what_could_be_affected',
|
||||
'what_should_you_do',
|
||||
);
|
||||
|
||||
public static function require_fields( array $explain ) {
|
||||
foreach ( self::TEMPLATE_FIELDS as $field ) {
|
||||
if ( empty( $explain[ $field ] ) ) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf( 'Argus_Explain: finding is missing required field "%s" -- every finding must be fully explainable (ADR-0053 §19.3), no exceptions.', $field )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function labels() {
|
||||
return array(
|
||||
'what_happened' => __( 'What happened', 'argus-wordpress-defence' ),
|
||||
'why_it_matters' => __( 'Why it matters', 'argus-wordpress-defence' ),
|
||||
'what_argus_found' => __( 'What ARGUS found', 'argus-wordpress-defence' ),
|
||||
'when_it_happened' => __( 'When it happened', 'argus-wordpress-defence' ),
|
||||
'why_suspicious' => __( 'Why ARGUS considers this suspicious', 'argus-wordpress-defence' ),
|
||||
'what_could_be_affected' => __( 'What could be affected', 'argus-wordpress-defence' ),
|
||||
'what_should_you_do' => __( 'What should you do', 'argus-wordpress-defence' ),
|
||||
);
|
||||
}
|
||||
|
||||
public static function render( array $explain ) {
|
||||
$labels = self::labels();
|
||||
ob_start();
|
||||
?>
|
||||
<dl class="argus-explain">
|
||||
<?php foreach ( self::TEMPLATE_FIELDS as $field ) : ?>
|
||||
<dt><?php echo esc_html( $labels[ $field ] ); ?></dt>
|
||||
<dd><?php echo wp_kses_post( wpautop( $explain[ $field ] ?? '' ) ); ?></dd>
|
||||
<?php endforeach; ?>
|
||||
</dl>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Findings {
|
||||
|
||||
const STATUS_OPEN = 'open';
|
||||
const STATUS_ACKNOWLEDGED = 'acknowledged';
|
||||
const STATUS_RESOLVED = 'resolved';
|
||||
const STATUS_RISK_ACCEPTED = 'risk_accepted';
|
||||
|
||||
public static function record( $category, $severity, array $explain, array $evidence = array(), $initial_status = self::STATUS_OPEN ) {
|
||||
global $wpdb;
|
||||
|
||||
Argus_Explain::require_fields( $explain );
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
|
||||
$existing_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE category = %s AND title = %s AND status = %s LIMIT 1", // phpcs:ignore
|
||||
$category,
|
||||
$explain['what_happened'],
|
||||
self::STATUS_OPEN
|
||||
)
|
||||
);
|
||||
|
||||
if ( $existing_id ) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'last_seen_at' => $now,
|
||||
'evidence' => wp_json_encode( $evidence ),
|
||||
'detail' => wp_json_encode( $explain ),
|
||||
),
|
||||
array( 'id' => $existing_id ),
|
||||
array( '%s', '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
return (int) $existing_id;
|
||||
}
|
||||
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'category' => $category,
|
||||
'severity' => $severity,
|
||||
'title' => $explain['what_happened'],
|
||||
'detail' => wp_json_encode( $explain ),
|
||||
'remediation_hint' => $explain['what_should_you_do'],
|
||||
'evidence' => wp_json_encode( $evidence ),
|
||||
'status' => $initial_status,
|
||||
'first_seen_at' => $now,
|
||||
'last_seen_at' => $now,
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
public static function set_status( $id, $status ) {
|
||||
global $wpdb;
|
||||
return $wpdb->update(
|
||||
Argus_DB::table( 'findings' ),
|
||||
array( 'status' => $status ),
|
||||
array( 'id' => $id ),
|
||||
array( '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function category_counts() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
|
||||
$rows = $wpdb->get_results( "SELECT category, COUNT(*) AS cnt FROM {$table} GROUP BY category ORDER BY cnt DESC" ); // phpcs:ignore
|
||||
$out = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$out[ $row->category ] = (int) $row->cnt;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function count_open( $category ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE category = %s AND status = %s", $category, self::STATUS_OPEN ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
public static function open_counts() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT severity, COUNT(*) AS cnt FROM {$table} WHERE status = %s GROUP BY severity", self::STATUS_OPEN ) // phpcs:ignore
|
||||
);
|
||||
|
||||
$counts = array(
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'medium' => 0,
|
||||
'low' => 0,
|
||||
'info' => 0,
|
||||
);
|
||||
foreach ( $rows as $row ) {
|
||||
if ( isset( $counts[ $row->severity ] ) ) {
|
||||
$counts[ $row->severity ] = (int) $row->cnt;
|
||||
}
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
public static function recent( $limit = 25, $status = null ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
|
||||
if ( $status ) {
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY last_seen_at DESC LIMIT %d", $status, $limit ) // phpcs:ignore
|
||||
);
|
||||
} else {
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY last_seen_at DESC LIMIT %d", $limit ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->explain = json_decode( $row->detail, true );
|
||||
$row->evidence = json_decode( $row->evidence, true );
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
public static function paginated( $page = 1, $status = null, $per_page = self::PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
if ( $status ) {
|
||||
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", $status ) ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY last_seen_at DESC LIMIT %d OFFSET %d", $status, $per_page, $offset ) // phpcs:ignore
|
||||
);
|
||||
} else {
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY last_seen_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->explain = json_decode( $row->detail, true );
|
||||
$row->evidence = json_decode( $row->evidence, true );
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
|
||||
const NEEDS_ATTENTION_SEVERITIES = array( 'critical', 'high' );
|
||||
|
||||
public static function paginated_by_view( $page = 1, $view = 'active', $per_page = self::PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
list( $where, $params ) = self::view_where( $view );
|
||||
|
||||
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where}", $params ) ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE {$where} ORDER BY last_seen_at DESC LIMIT %d OFFSET %d", array_merge( $params, array( $per_page, $offset ) ) ) // phpcs:ignore
|
||||
);
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->explain = json_decode( $row->detail, true );
|
||||
$row->evidence = json_decode( $row->evidence, true );
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
|
||||
public static function view_counts() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
$out = array();
|
||||
foreach ( array( 'active', 'needs_attention', 'resolved', 'accepted' ) as $view ) {
|
||||
list( $where, $params ) = self::view_where( $view );
|
||||
$out[ $view ] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where}", $params ) ); // phpcs:ignore
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected static function view_where( $view ) {
|
||||
$severity_placeholders = implode( ',', array_fill( 0, count( self::NEEDS_ATTENTION_SEVERITIES ), '%s' ) );
|
||||
switch ( $view ) {
|
||||
case 'needs_attention':
|
||||
return array(
|
||||
"status IN (%s,%s) AND severity IN ({$severity_placeholders})",
|
||||
array_merge( array( self::STATUS_OPEN, self::STATUS_ACKNOWLEDGED ), self::NEEDS_ATTENTION_SEVERITIES ),
|
||||
);
|
||||
case 'resolved':
|
||||
return array( 'status = %s', array( self::STATUS_RESOLVED ) );
|
||||
case 'accepted':
|
||||
return array( 'status = %s', array( self::STATUS_RISK_ACCEPTED ) );
|
||||
case 'active':
|
||||
default:
|
||||
return array(
|
||||
"status IN (%s,%s) AND severity NOT IN ({$severity_placeholders})",
|
||||
array_merge( array( self::STATUS_OPEN, self::STATUS_ACKNOWLEDGED ), self::NEEDS_ATTENTION_SEVERITIES ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function delete( $id ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
|
||||
$row = self::get( $id );
|
||||
if ( ! $row ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Argus_Events::record(
|
||||
'finding_deleted',
|
||||
'info',
|
||||
sprintf( 'Finding deleted: %s', $row->title ),
|
||||
array( 'finding_id' => $id, 'category' => $row->category, 'severity' => $row->severity )
|
||||
);
|
||||
|
||||
return (bool) $wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) );
|
||||
}
|
||||
|
||||
public static function resolve_all_open() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
|
||||
$count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", self::STATUS_OPEN ) ); // phpcs:ignore
|
||||
if ( 0 === $count ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$wpdb->update( $table, array( 'status' => self::STATUS_RESOLVED ), array( 'status' => self::STATUS_OPEN ), array( '%s' ), array( '%s' ) );
|
||||
|
||||
Argus_Events::record( 'findings_bulk_resolved', 'info', sprintf( '%d open finding(s) bulk-resolved by an administrator', $count ), array( 'count' => $count ) );
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public static function get( $id ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'findings' );
|
||||
$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); // phpcs:ignore
|
||||
if ( $row ) {
|
||||
$row->explain = json_decode( $row->detail, true );
|
||||
$row->evidence = json_decode( $row->evidence, true );
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_GeoIP_RIR {
|
||||
|
||||
const SOURCES = array(
|
||||
'arin' => 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
|
||||
'ripencc' => 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
|
||||
'apnic' => 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
|
||||
'lacnic' => 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
|
||||
'afrinic' => 'https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest',
|
||||
);
|
||||
|
||||
const TIME_BUDGET_SECONDS = 40;
|
||||
|
||||
const CURSOR_OPTION = 'argus_wpd_geoip_cursor';
|
||||
const BUILD_PATH_OPTION = 'argus_wpd_geoip_build_path';
|
||||
const LAST_REFRESH_OPTION = 'argus_wpd_geoip_last_refresh';
|
||||
const RANGE_COUNT_OPTION = 'argus_wpd_geoip_range_count';
|
||||
const LAST_ERROR_OPTION = 'argus_wpd_geoip_last_error';
|
||||
const CORRUPT_OPTION = 'argus_wpd_geoip_corrupt_detected';
|
||||
|
||||
const HEADER_MAGIC = 'AWG1';
|
||||
const HEADER_LEN = 16;
|
||||
const FORMAT_VERSION = 1;
|
||||
|
||||
protected static $file_cache = null;
|
||||
|
||||
public static function init() {
|
||||
add_filter( 'argus_wpd_resolve_country', array( __CLASS__, 'resolve' ), 10, 2 );
|
||||
}
|
||||
|
||||
public static function resolve( $result, $ip ) {
|
||||
if ( null !== $result ) {
|
||||
return $result;
|
||||
}
|
||||
if ( ! Argus_Settings::get( 'geoip_rir_enabled', true ) ) {
|
||||
return null;
|
||||
}
|
||||
$code = self::lookup_ipv4( $ip );
|
||||
if ( ! $code ) {
|
||||
return null;
|
||||
}
|
||||
return array( 'label' => self::country_name( $code ), 'code' => $code );
|
||||
}
|
||||
|
||||
public static function lookup_ipv4( $ip ) {
|
||||
$n = self::ipv4_to_uint( $ip );
|
||||
if ( false === $n ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( null === self::$file_cache ) {
|
||||
self::$file_cache = self::read_and_verify();
|
||||
}
|
||||
if ( '' === self::$file_cache ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$count = (int) ( strlen( self::$file_cache ) / 10 );
|
||||
$lo = 0;
|
||||
$hi = $count - 1;
|
||||
|
||||
while ( $lo <= $hi ) {
|
||||
$mid = intdiv( $lo + $hi, 2 );
|
||||
$rec = unpack( 'Nstart/Nend/a2cc', substr( self::$file_cache, $mid * 10, 10 ) );
|
||||
if ( $n < $rec['start'] ) {
|
||||
$hi = $mid - 1;
|
||||
} elseif ( $n > $rec['end'] ) {
|
||||
$lo = $mid + 1;
|
||||
} else {
|
||||
return $rec['cc'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected static function read_and_verify() {
|
||||
$path = self::compiled_file();
|
||||
if ( ! file_exists( $path ) ) {
|
||||
return '';
|
||||
}
|
||||
$raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( false === $raw || '' === $raw ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( strlen( $raw ) < self::HEADER_LEN || self::HEADER_MAGIC !== substr( $raw, 0, 4 ) ) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
$header = unpack( 'a4magic/Cversion/Ncount/Ncrc', substr( $raw, 0, self::HEADER_LEN ) );
|
||||
$records = substr( $raw, self::HEADER_LEN );
|
||||
|
||||
if ( (int) $header['count'] !== (int) ( strlen( $records ) / 10 ) || (int) $header['crc'] !== crc32( $records ) ) {
|
||||
|
||||
if ( ! get_option( self::CORRUPT_OPTION ) ) {
|
||||
update_option( self::CORRUPT_OPTION, true, false );
|
||||
Argus_Events::record( 'geoip_corrupt', 'medium', 'GeoIP database file failed its integrity check and was ignored until the next scheduled refresh.', array() );
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( get_option( self::CORRUPT_OPTION ) ) {
|
||||
delete_option( self::CORRUPT_OPTION );
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
public static function run_daily_refresh() {
|
||||
$started = microtime( true );
|
||||
$sources = array_keys( self::SOURCES );
|
||||
$cursor = (int) get_option( self::CURSOR_OPTION, 0 );
|
||||
|
||||
$build_path = get_option( self::BUILD_PATH_OPTION, '' );
|
||||
if ( 0 === $cursor || '' === $build_path || ! file_exists( $build_path ) ) {
|
||||
|
||||
foreach ( glob( trailingslashit( self::data_dir() ) . 'geoip-build-*.raw' ) ?: array() as $stray ) {
|
||||
@unlink( $stray ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
}
|
||||
$build_path = trailingslashit( self::data_dir() ) . 'geoip-build-' . wp_generate_password( 8, false ) . '.raw';
|
||||
update_option( self::BUILD_PATH_OPTION, $build_path, false );
|
||||
$cursor = 0;
|
||||
}
|
||||
|
||||
$handle = fopen( $build_path, 'ab' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( ! $handle ) {
|
||||
self::record_outcome( false, __( 'Could not open a working file in uploads to build the GeoIP database.', 'argus-wordpress-defence' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$any_failure = false;
|
||||
while ( $cursor < count( $sources ) ) {
|
||||
if ( microtime( true ) - $started > self::TIME_BUDGET_SECONDS ) {
|
||||
break;
|
||||
}
|
||||
$key = $sources[ $cursor ];
|
||||
if ( ! self::fetch_and_append( $key, self::SOURCES[ $key ], $handle ) ) {
|
||||
$any_failure = true;
|
||||
}
|
||||
$cursor++;
|
||||
update_option( self::CURSOR_OPTION, $cursor, false );
|
||||
}
|
||||
|
||||
fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
if ( $cursor < count( $sources ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$swap_result = self::compile_and_swap( $build_path, $any_failure );
|
||||
@unlink( $build_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
|
||||
update_option( self::BUILD_PATH_OPTION, '', false );
|
||||
update_option( self::CURSOR_OPTION, 0, false );
|
||||
update_option( self::RANGE_COUNT_OPTION, $swap_result['count'], false );
|
||||
|
||||
if ( $swap_result['swapped'] ) {
|
||||
|
||||
update_option( self::LAST_REFRESH_OPTION, current_time( 'mysql', true ), false );
|
||||
}
|
||||
|
||||
if ( $any_failure && ! $swap_result['swapped'] ) {
|
||||
|
||||
self::record_outcome(
|
||||
false,
|
||||
sprintf(
|
||||
|
||||
__( 'One or more RIR sources could not be reached this cycle. Kept the existing %d-range database rather than replacing it with an incomplete one; will retry on the next scheduled refresh.', 'argus-wordpress-defence' ),
|
||||
$swap_result['count']
|
||||
)
|
||||
);
|
||||
} else {
|
||||
self::record_outcome(
|
||||
! $any_failure,
|
||||
$any_failure
|
||||
? sprintf(
|
||||
|
||||
__( 'GeoIP database refreshed with %d IPv4 ranges, but one or more RIR sources could not be reached this cycle -- those ranges are missing until a future refresh succeeds.', 'argus-wordpress-defence' ),
|
||||
$swap_result['count']
|
||||
)
|
||||
: sprintf(
|
||||
|
||||
__( 'GeoIP database refreshed: %d IPv4 ranges loaded from 5 RIR sources.', 'argus-wordpress-defence' ),
|
||||
$swap_result['count']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
self::$file_cache = null;
|
||||
}
|
||||
|
||||
protected static function fetch_and_append( $source_key, $url, $handle ) {
|
||||
|
||||
$tmp = tempnam( sys_get_temp_dir(), 'argus-geoip-' . $source_key . '-' );
|
||||
if ( ! $tmp ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$response = wp_remote_get( $url, array( 'timeout' => 30, 'stream' => true, 'filename' => $tmp ) );
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
return false;
|
||||
}
|
||||
|
||||
$fh = fopen( $tmp, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( ! $fh ) {
|
||||
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
return false;
|
||||
}
|
||||
|
||||
while ( false !== ( $line = fgets( $fh ) ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition
|
||||
$line = trim( $line );
|
||||
if ( '' === $line || '#' === $line[0] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode( '|', $line );
|
||||
if ( count( $parts ) < 7 ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'ipv4' !== $parts[2] ) {
|
||||
continue;
|
||||
}
|
||||
if ( ! in_array( $parts[6], array( 'allocated', 'assigned' ), true ) ) {
|
||||
continue;
|
||||
}
|
||||
$cc = strtoupper( $parts[1] );
|
||||
if ( 2 !== strlen( $cc ) || ! ctype_alpha( $cc ) ) {
|
||||
continue;
|
||||
}
|
||||
$start = self::ipv4_to_uint( $parts[3] );
|
||||
$count = (int) $parts[4];
|
||||
if ( false === $start || $count <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
$end = min( $start + $count - 1, 4294967295 );
|
||||
|
||||
fwrite( $handle, pack( 'NNa2', $start, $end, $cc ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
|
||||
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
return true;
|
||||
}
|
||||
|
||||
protected static function compile_and_swap( $build_path, $any_failure = false ) {
|
||||
$data = file_get_contents( $build_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( false === $data || '' === $data ) {
|
||||
return array( 'count' => self::count_from_file( self::compiled_file() ), 'swapped' => false );
|
||||
}
|
||||
|
||||
$existing_file = self::compiled_file();
|
||||
if ( $any_failure && file_exists( $existing_file ) ) {
|
||||
return array( 'count' => self::count_from_file( $existing_file ), 'swapped' => false );
|
||||
}
|
||||
|
||||
$records = str_split( $data, 10 );
|
||||
usort(
|
||||
$records,
|
||||
function ( $a, $b ) {
|
||||
return substr_compare( $a, $b, 0, 4 );
|
||||
}
|
||||
);
|
||||
|
||||
$record_bytes = implode( '', $records );
|
||||
|
||||
$header = pack( 'a4CNNa3', self::HEADER_MAGIC, self::FORMAT_VERSION, count( $records ), crc32( $record_bytes ), '' );
|
||||
|
||||
$tmp_final = trailingslashit( self::data_dir() ) . 'geoip-v4.bin.tmp';
|
||||
file_put_contents( $tmp_final, $header . $record_bytes ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
rename( $tmp_final, $existing_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
delete_option( self::CORRUPT_OPTION );
|
||||
|
||||
return array( 'count' => count( $records ), 'swapped' => true );
|
||||
}
|
||||
|
||||
protected static function count_from_file( $path ) {
|
||||
if ( ! file_exists( $path ) ) {
|
||||
return 0;
|
||||
}
|
||||
$head = file_get_contents( $path, false, null, 0, self::HEADER_LEN ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( is_string( $head ) && strlen( $head ) === self::HEADER_LEN && self::HEADER_MAGIC === substr( $head, 0, 4 ) ) {
|
||||
$header = unpack( 'a4magic/Cversion/Ncount', $head );
|
||||
return (int) $header['count'];
|
||||
}
|
||||
return (int) ( filesize( $path ) / 10 );
|
||||
}
|
||||
|
||||
protected static function record_outcome( $success, $message ) {
|
||||
update_option( self::LAST_ERROR_OPTION, $success ? '' : $message, false );
|
||||
Argus_Events::record( 'geoip_refresh', $success ? 'info' : 'medium', $message, array( 'success' => $success ) );
|
||||
}
|
||||
|
||||
protected static function data_dir() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
$dir = trailingslashit( $uploads['basedir'] ) . 'argus-wpd-data';
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
wp_mkdir_p( $dir );
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
protected static function compiled_file() {
|
||||
return trailingslashit( self::data_dir() ) . 'geoip-v4.bin';
|
||||
}
|
||||
|
||||
public static function ipv4_to_uint( $ip ) {
|
||||
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
|
||||
return false;
|
||||
}
|
||||
$parts = explode( '.', $ip );
|
||||
if ( 4 !== count( $parts ) ) {
|
||||
return false;
|
||||
}
|
||||
return ( (int) $parts[0] << 24 ) + ( (int) $parts[1] << 16 ) + ( (int) $parts[2] << 8 ) + (int) $parts[3];
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
return array(
|
||||
'last_refresh' => get_option( self::LAST_REFRESH_OPTION, '' ) ?: null,
|
||||
'range_count' => (int) get_option( self::RANGE_COUNT_OPTION, 0 ),
|
||||
'last_error' => get_option( self::LAST_ERROR_OPTION, '' ),
|
||||
'cursor' => (int) get_option( self::CURSOR_OPTION, 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
public static function country_name( $code ) {
|
||||
static $names = null;
|
||||
if ( null === $names ) {
|
||||
$names = array(
|
||||
'AL' => 'Albania', 'AD' => 'Andorra', 'AT' => 'Austria', 'BY' => 'Belarus', 'BE' => 'Belgium',
|
||||
'BA' => 'Bosnia and Herzegovina', 'BG' => 'Bulgaria', 'HR' => 'Croatia', 'CY' => 'Cyprus', 'CZ' => 'Czech Republic',
|
||||
'DK' => 'Denmark', 'EE' => 'Estonia', 'FI' => 'Finland', 'FR' => 'France', 'DE' => 'Germany',
|
||||
'GR' => 'Greece', 'HU' => 'Hungary', 'IS' => 'Iceland', 'IE' => 'Ireland', 'IT' => 'Italy',
|
||||
'XK' => 'Kosovo', 'LV' => 'Latvia', 'LI' => 'Liechtenstein', 'LT' => 'Lithuania', 'LU' => 'Luxembourg',
|
||||
'MT' => 'Malta', 'MD' => 'Moldova', 'MC' => 'Monaco', 'ME' => 'Montenegro', 'NL' => 'Netherlands',
|
||||
'MK' => 'North Macedonia', 'NO' => 'Norway', 'PL' => 'Poland', 'PT' => 'Portugal', 'RO' => 'Romania',
|
||||
'RU' => 'Russia', 'SM' => 'San Marino', 'RS' => 'Serbia', 'SK' => 'Slovakia', 'SI' => 'Slovenia',
|
||||
'ES' => 'Spain', 'SE' => 'Sweden', 'CH' => 'Switzerland', 'UA' => 'Ukraine', 'GB' => 'United Kingdom',
|
||||
'VA' => 'Vatican City', 'AF' => 'Afghanistan', 'AM' => 'Armenia', 'AZ' => 'Azerbaijan', 'BH' => 'Bahrain',
|
||||
'BD' => 'Bangladesh', 'BT' => 'Bhutan', 'BN' => 'Brunei', 'KH' => 'Cambodia', 'CN' => 'China',
|
||||
'GE' => 'Georgia', 'IN' => 'India', 'ID' => 'Indonesia', 'IR' => 'Iran', 'IQ' => 'Iraq',
|
||||
'IL' => 'Israel', 'JP' => 'Japan', 'JO' => 'Jordan', 'KZ' => 'Kazakhstan', 'KW' => 'Kuwait',
|
||||
'KG' => 'Kyrgyzstan', 'LA' => 'Laos', 'LB' => 'Lebanon', 'MY' => 'Malaysia', 'MV' => 'Maldives',
|
||||
'MN' => 'Mongolia', 'MM' => 'Myanmar', 'NP' => 'Nepal', 'KP' => 'North Korea', 'OM' => 'Oman',
|
||||
'PK' => 'Pakistan', 'PS' => 'Palestine', 'PH' => 'Philippines', 'QA' => 'Qatar', 'SA' => 'Saudi Arabia',
|
||||
'SG' => 'Singapore', 'KR' => 'South Korea', 'LK' => 'Sri Lanka', 'SY' => 'Syria', 'TW' => 'Taiwan',
|
||||
'TJ' => 'Tajikistan', 'TH' => 'Thailand', 'TL' => 'Timor-Leste', 'TR' => 'Turkey', 'TM' => 'Turkmenistan',
|
||||
'AE' => 'United Arab Emirates', 'UZ' => 'Uzbekistan', 'VN' => 'Vietnam', 'YE' => 'Yemen', 'AG' => 'Antigua and Barbuda',
|
||||
'AR' => 'Argentina', 'BS' => 'Bahamas', 'BB' => 'Barbados', 'BZ' => 'Belize', 'BO' => 'Bolivia',
|
||||
'BR' => 'Brazil', 'CA' => 'Canada', 'CL' => 'Chile', 'CO' => 'Colombia', 'CR' => 'Costa Rica',
|
||||
'CU' => 'Cuba', 'DM' => 'Dominica', 'DO' => 'Dominican Republic', 'EC' => 'Ecuador', 'SV' => 'El Salvador',
|
||||
'GD' => 'Grenada', 'GT' => 'Guatemala', 'GY' => 'Guyana', 'HT' => 'Haiti', 'HN' => 'Honduras',
|
||||
'JM' => 'Jamaica', 'MX' => 'Mexico', 'NI' => 'Nicaragua', 'PA' => 'Panama', 'PY' => 'Paraguay',
|
||||
'PE' => 'Peru', 'KN' => 'Saint Kitts and Nevis', 'LC' => 'Saint Lucia', 'VC' => 'Saint Vincent and the Grenadines', 'SR' => 'Suriname',
|
||||
'TT' => 'Trinidad and Tobago', 'US' => 'United States', 'UY' => 'Uruguay', 'VE' => 'Venezuela', 'DZ' => 'Algeria',
|
||||
'AO' => 'Angola', 'BJ' => 'Benin', 'BW' => 'Botswana', 'BF' => 'Burkina Faso', 'BI' => 'Burundi',
|
||||
'CM' => 'Cameroon', 'CV' => 'Cape Verde', 'CF' => 'Central African Republic', 'TD' => 'Chad', 'KM' => 'Comoros',
|
||||
'CG' => 'Republic of the Congo', 'CD' => 'Democratic Republic of the Congo', 'CI' => 'Ivory Coast', 'DJ' => 'Djibouti', 'EG' => 'Egypt',
|
||||
'GQ' => 'Equatorial Guinea', 'ER' => 'Eritrea', 'ET' => 'Ethiopia', 'GA' => 'Gabon', 'GM' => 'Gambia',
|
||||
'GH' => 'Ghana', 'GN' => 'Guinea', 'GW' => 'Guinea-Bissau', 'KE' => 'Kenya', 'LS' => 'Lesotho',
|
||||
'LR' => 'Liberia', 'LY' => 'Libya', 'MG' => 'Madagascar', 'MW' => 'Malawi', 'ML' => 'Mali',
|
||||
'MR' => 'Mauritania', 'MU' => 'Mauritius', 'MA' => 'Morocco', 'MZ' => 'Mozambique', 'NA' => 'Namibia',
|
||||
'NE' => 'Niger', 'NG' => 'Nigeria', 'RW' => 'Rwanda', 'ST' => 'Sao Tome and Principe', 'SN' => 'Senegal',
|
||||
'SL' => 'Sierra Leone', 'SO' => 'Somalia', 'ZA' => 'South Africa', 'SS' => 'South Sudan', 'SD' => 'Sudan',
|
||||
'SZ' => 'Eswatini', 'TZ' => 'Tanzania', 'TG' => 'Togo', 'TN' => 'Tunisia', 'UG' => 'Uganda',
|
||||
'ZM' => 'Zambia', 'ZW' => 'Zimbabwe', 'AU' => 'Australia', 'FJ' => 'Fiji', 'KI' => 'Kiribati',
|
||||
'MH' => 'Marshall Islands', 'FM' => 'Micronesia', 'NR' => 'Nauru', 'NZ' => 'New Zealand', 'PW' => 'Palau',
|
||||
'PG' => 'Papua New Guinea', 'WS' => 'Samoa', 'SB' => 'Solomon Islands', 'TO' => 'Tonga', 'TV' => 'Tuvalu',
|
||||
'VU' => 'Vanuatu',
|
||||
);
|
||||
}
|
||||
return $names[ $code ] ?? $code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_GeoIP {
|
||||
|
||||
const LOCAL = 'Local / Private Network';
|
||||
const UNKNOWN = 'Unknown';
|
||||
|
||||
public static function classify( $ip ) {
|
||||
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
|
||||
return array( 'label' => self::LOCAL, 'code' => null );
|
||||
}
|
||||
|
||||
$result = apply_filters( 'argus_wpd_resolve_country', null, $ip );
|
||||
|
||||
if ( is_array( $result ) && ! empty( $result['label'] ) ) {
|
||||
return array( 'label' => $result['label'], 'code' => $result['code'] ?? null );
|
||||
}
|
||||
if ( is_string( $result ) && '' !== $result ) {
|
||||
return array( 'label' => $result, 'code' => null );
|
||||
}
|
||||
return array( 'label' => self::UNKNOWN, 'code' => null );
|
||||
}
|
||||
|
||||
public static function label( $ip ) {
|
||||
return self::classify( $ip )['label'];
|
||||
}
|
||||
|
||||
public static function icon( $ip ) {
|
||||
$c = self::classify( $ip );
|
||||
if ( $c['code'] && preg_match( '/^[a-zA-Z]{2}$/', $c['code'] ) ) {
|
||||
$code = strtoupper( $c['code'] );
|
||||
return mb_chr( 0x1F1E6 + ( ord( $code[0] ) - 65 ) ) . mb_chr( 0x1F1E6 + ( ord( $code[1] ) - 65 ) );
|
||||
}
|
||||
if ( self::LOCAL === $c['label'] ) {
|
||||
return '🏠';
|
||||
}
|
||||
return '🌐';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Integrity {
|
||||
|
||||
const CATEGORY_CORE = 'core';
|
||||
const CATEGORY_PLUGIN = 'plugin';
|
||||
const CATEGORY_THEME = 'theme';
|
||||
const CATEGORY_CONFIG = 'config';
|
||||
|
||||
const WATERMARK_OPTION = 'argus_wpd_integrity_watermark';
|
||||
const TRUSTED_WINDOWS_OPTION = 'argus_wpd_trusted_update_windows';
|
||||
const TRUSTED_WINDOW_TTL = 600;
|
||||
const LAST_FULL_SCAN_OPTION = 'argus_wpd_integrity_last_full_scan';
|
||||
const LAST_FULL_SCAN_FILES_OPTION = 'argus_wpd_integrity_last_full_scan_files';
|
||||
|
||||
public static function on_upgrader_complete( $upgrader, $hook_extra ) {
|
||||
if ( empty( $hook_extra['action'] ) || 'update' !== $hook_extra['action'] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefixes = array();
|
||||
switch ( $hook_extra['type'] ?? '' ) {
|
||||
case 'core':
|
||||
$prefixes[] = 'wp-admin/';
|
||||
$prefixes[] = WPINC . '/';
|
||||
break;
|
||||
case 'plugin':
|
||||
foreach ( (array) ( $hook_extra['plugins'] ?? array( $hook_extra['plugin'] ?? '' ) ) as $plugin_file ) {
|
||||
if ( $plugin_file ) {
|
||||
$prefixes[] = 'wp-content/plugins/' . strtok( $plugin_file, '/' ) . '/';
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'theme':
|
||||
foreach ( (array) ( $hook_extra['themes'] ?? array( $hook_extra['theme'] ?? '' ) ) as $theme_slug ) {
|
||||
if ( $theme_slug ) {
|
||||
$prefixes[] = 'wp-content/themes/' . $theme_slug . '/';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ( empty( $prefixes ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::trust_prefixes( $prefixes );
|
||||
}
|
||||
|
||||
protected static function trust_prefixes( array $prefixes ) {
|
||||
$windows = get_option( self::TRUSTED_WINDOWS_OPTION, array() );
|
||||
if ( ! is_array( $windows ) ) {
|
||||
$windows = array();
|
||||
}
|
||||
|
||||
$until = time() + self::TRUSTED_WINDOW_TTL;
|
||||
foreach ( $prefixes as $prefix ) {
|
||||
$windows[ $prefix ] = $until;
|
||||
}
|
||||
|
||||
update_option( self::TRUSTED_WINDOWS_OPTION, $windows, false );
|
||||
}
|
||||
|
||||
protected static function is_trusted_change( $rel_path ) {
|
||||
$windows = get_option( self::TRUSTED_WINDOWS_OPTION, array() );
|
||||
if ( ! is_array( $windows ) || empty( $windows ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$trusted = false;
|
||||
$pruned = array();
|
||||
|
||||
foreach ( $windows as $prefix => $until ) {
|
||||
if ( $until < $now ) {
|
||||
continue;
|
||||
}
|
||||
$pruned[ $prefix ] = $until;
|
||||
if ( ! $trusted && 0 === strpos( $rel_path, $prefix ) ) {
|
||||
$trusted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $pruned ) !== count( $windows ) ) {
|
||||
update_option( self::TRUSTED_WINDOWS_OPTION, $pruned, false );
|
||||
}
|
||||
|
||||
return $trusted;
|
||||
}
|
||||
|
||||
public static function full_scan() {
|
||||
$file_count = self::scan( null );
|
||||
self::check_core_checksums();
|
||||
update_option( self::WATERMARK_OPTION, time(), false );
|
||||
update_option( self::LAST_FULL_SCAN_OPTION, time(), false );
|
||||
update_option( self::LAST_FULL_SCAN_FILES_OPTION, $file_count, false );
|
||||
}
|
||||
|
||||
public static function incremental_scan() {
|
||||
$since = (int) get_option( self::WATERMARK_OPTION, 0 );
|
||||
self::scan( $since );
|
||||
update_option( self::WATERMARK_OPTION, time(), false );
|
||||
}
|
||||
|
||||
public static function scan_status() {
|
||||
$next = wp_next_scheduled( 'argus_wpd_daily' );
|
||||
return array(
|
||||
'last_full_scan' => (int) get_option( self::LAST_FULL_SCAN_OPTION, 0 ),
|
||||
'files_scanned' => (int) get_option( self::LAST_FULL_SCAN_FILES_OPTION, 0 ),
|
||||
'next_scan' => $next ? (int) $next : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function scan( $mtime_since ) {
|
||||
global $wpdb;
|
||||
|
||||
$targets = array(
|
||||
self::CATEGORY_CORE => array( ABSPATH . 'wp-admin', ABSPATH . WPINC ),
|
||||
self::CATEGORY_PLUGIN => array( WP_PLUGIN_DIR ),
|
||||
self::CATEGORY_THEME => array( get_theme_root() ),
|
||||
);
|
||||
|
||||
$seen_paths = array();
|
||||
|
||||
foreach ( $targets as $category => $dirs ) {
|
||||
foreach ( $dirs as $dir ) {
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( self::walk_php_files( $dir ) as $path ) {
|
||||
$mtime = filemtime( $path );
|
||||
if ( null !== $mtime_since && $mtime < $mtime_since ) {
|
||||
|
||||
$seen_paths[] = $path;
|
||||
continue;
|
||||
}
|
||||
self::check_file( $path, $category );
|
||||
$seen_paths[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( file_exists( ABSPATH . 'wp-config.php' ) ) {
|
||||
self::check_file( ABSPATH . 'wp-config.php', self::CATEGORY_CONFIG );
|
||||
$seen_paths[] = ABSPATH . 'wp-config.php';
|
||||
}
|
||||
|
||||
if ( null === $mtime_since ) {
|
||||
self::check_deletions( $seen_paths );
|
||||
}
|
||||
|
||||
return count( $seen_paths );
|
||||
}
|
||||
|
||||
protected static function walk_php_files( $dir ) {
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( $file->isFile() && 'php' === strtolower( $file->getExtension() ) ) {
|
||||
yield $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static function check_file( $path, $category ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'integrity_baseline' );
|
||||
|
||||
$hash = hash_file( 'sha256', $path );
|
||||
$size = filesize( $path );
|
||||
$mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) );
|
||||
$now = current_time( 'mysql', true );
|
||||
$rel = self::relative_path( $path );
|
||||
|
||||
$existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE file_path = %s", $rel ) ); // phpcs:ignore
|
||||
|
||||
if ( ! $existing ) {
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'file_path' => $rel,
|
||||
'file_hash' => $hash,
|
||||
'file_size' => $size,
|
||||
'file_mtime' => $mtime,
|
||||
'category' => $category,
|
||||
'first_seen_at' => $now,
|
||||
'last_checked_at' => $now,
|
||||
),
|
||||
array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
if ( self::CATEGORY_CORE === $category ) {
|
||||
self::record_finding(
|
||||
'New file appeared inside WordPress core: ' . $rel,
|
||||
'high',
|
||||
$rel,
|
||||
'WordPress core (wp-admin/wp-includes) does not normally gain new files outside of an actual WordPress update.',
|
||||
'This can indicate a webshell or backdoor was planted directly into core.',
|
||||
'Compare this file against a fresh WordPress download of your exact version. If you did not just update WordPress, treat this as a likely compromise and investigate immediately.',
|
||||
$mtime
|
||||
);
|
||||
}
|
||||
|
||||
Argus_Events::record( 'integrity_new_file', 'low', 'New file: ' . $rel, array( 'file_path' => $rel, 'category' => $category, 'file_mtime' => $mtime ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $existing->file_hash !== $hash ) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array( 'file_hash' => $hash, 'file_size' => $size, 'file_mtime' => $mtime, 'last_checked_at' => $now ),
|
||||
array( 'id' => $existing->id ),
|
||||
array( '%s', '%d', '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
Argus_Events::record( 'integrity_changed_file', 'low', 'Changed file: ' . $rel, array( 'file_path' => $rel, 'category' => $category, 'file_mtime' => $mtime ) );
|
||||
|
||||
$trusted = self::is_trusted_change( $rel );
|
||||
|
||||
self::record_finding(
|
||||
sprintf( '%s file changed: %s', ucfirst( $category ), $rel ),
|
||||
self::CATEGORY_CORE === $category ? 'high' : 'medium',
|
||||
$rel,
|
||||
self::CATEGORY_CORE === $category
|
||||
? 'WordPress core files should only change during an official WordPress update.'
|
||||
: 'Unexpected changes to plugin/theme files can indicate a compromise, a manual edit that will be lost on the next update, or supply-chain tampering.',
|
||||
'The file\'s content hash no longer matches what ARGUS last recorded for it.',
|
||||
'If this file is part of an application (theme/plugin), changes here can affect every visitor and every other user of the site.',
|
||||
$trusted
|
||||
? 'No action needed -- this change was recorded during a WordPress-initiated update of this exact file (core/plugin/theme updater), so ARGUS resolved it automatically.'
|
||||
: 'If you made this change deliberately (a manual edit, a WordPress/plugin update), no action is needed. Otherwise, compare it against the original source and investigate.',
|
||||
null,
|
||||
$trusted
|
||||
);
|
||||
} else {
|
||||
$wpdb->update( $table, array( 'last_checked_at' => $now ), array( 'id' => $existing->id ), array( '%s' ), array( '%d' ) );
|
||||
}
|
||||
}
|
||||
|
||||
protected static function check_deletions( array $seen_paths ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'integrity_baseline' );
|
||||
$seen_rel = array_map( array( __CLASS__, 'relative_path' ), $seen_paths );
|
||||
|
||||
$known = $wpdb->get_results( "SELECT id, file_path, category FROM {$table}" ); // phpcs:ignore
|
||||
|
||||
foreach ( $known as $row ) {
|
||||
if ( in_array( $row->file_path, $seen_rel, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) );
|
||||
|
||||
if ( self::CATEGORY_CORE === $row->category ) {
|
||||
self::record_finding(
|
||||
'WordPress core file was deleted: ' . $row->file_path,
|
||||
'high',
|
||||
$row->file_path,
|
||||
'A missing core file can break site functionality or be a sign of tampering.',
|
||||
'This file was previously recorded and is no longer present.',
|
||||
'Restore this file from a fresh WordPress download of your exact version, or reinstall WordPress core files.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static function check_core_checksums() {
|
||||
global $wp_version;
|
||||
$response = wp_remote_get(
|
||||
sprintf( 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=en_US', rawurlencode( $wp_version ) ),
|
||||
array( 'timeout' => 8 )
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
$checksums = $body['checksums'][ $wp_version ] ?? null;
|
||||
if ( ! is_array( $checksums ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $checksums as $rel_path => $official_hash ) {
|
||||
|
||||
$abs = ABSPATH . $rel_path;
|
||||
if ( ! file_exists( $abs ) || 0 === strpos( $rel_path, 'wp-content/' ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( md5_file( $abs ) !== $official_hash ) {
|
||||
self::record_finding(
|
||||
'Core file does not match the official WordPress.org checksum: ' . $rel_path,
|
||||
'critical',
|
||||
$rel_path,
|
||||
'This file differs from the exact bytes WordPress.org publishes for your installed version.',
|
||||
sprintf( 'Cross-checked against api.wordpress.org\'s official checksum feed for WordPress %s.', $wp_version ),
|
||||
'This is a strong tampering signal, not just a local drift. Restore this file from a fresh, official WordPress download immediately.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static function record_finding( $what_happened, $severity, $path, $why_it_matters, $what_argus_found, $what_should_you_do, $mtime = null, $auto_resolved = false ) {
|
||||
Argus_Findings::record(
|
||||
'integrity',
|
||||
$severity,
|
||||
array(
|
||||
'what_happened' => $what_happened,
|
||||
'why_it_matters' => $why_it_matters,
|
||||
'what_argus_found' => $what_argus_found,
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'ARGUS maintains its own baseline of every core, plugin, and theme file and flags anything that changes outside of a WordPress-initiated update.',
|
||||
'what_could_be_affected' => 'Any visitor or user interacting with the affected file\'s functionality.',
|
||||
'what_should_you_do' => $what_should_you_do,
|
||||
),
|
||||
array_filter( array( 'file_path' => $path, 'file_mtime' => $mtime ) ),
|
||||
$auto_resolved ? Argus_Findings::STATUS_RESOLVED : Argus_Findings::STATUS_OPEN
|
||||
);
|
||||
}
|
||||
|
||||
protected static function relative_path( $abs_path ) {
|
||||
return str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $abs_path ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Login_Guard {
|
||||
|
||||
public static function init() {
|
||||
if ( ! Argus_Settings::get( 'login_protection_enabled', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( 'authenticate', array( __CLASS__, 'pre_auth_check' ), 20, 1 );
|
||||
add_action( 'wp_login_failed', array( __CLASS__, 'on_failure' ), 10, 2 );
|
||||
add_action( 'wp_login', array( __CLASS__, 'on_success' ), 10, 2 );
|
||||
}
|
||||
|
||||
public static function pre_auth_check( $user ) {
|
||||
if ( empty( $_POST['log'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only check, no state change.
|
||||
return $user;
|
||||
}
|
||||
|
||||
$ip = Argus_Request_Inputs::client_ip();
|
||||
|
||||
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
|
||||
Argus_Policy_Engine::deny_already_banned( $ip );
|
||||
return $user;
|
||||
}
|
||||
|
||||
$failures = self::recent_failures( $ip );
|
||||
|
||||
$decision = Argus_Policy_Engine::evaluate( $ip, 'login_failure', array( 'recent_failures' => $failures ) );
|
||||
|
||||
if ( in_array( $decision['action'], array( Argus_Policy_Engine::ACTION_BLOCK, Argus_Policy_Engine::ACTION_CHALLENGE ), true ) ) {
|
||||
self::record_finding( $ip, (string) $_POST['log'], $failures, $decision ); // phpcs:ignore WordPress.Security.NonceVerification
|
||||
}
|
||||
|
||||
Argus_Policy_Engine::enforce_decision( $ip, 'login_failure', $decision, array( 'recent_failures' => $failures ) );
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public static function on_failure( $username, $error = null ) {
|
||||
global $wpdb;
|
||||
$ip = Argus_Request_Inputs::client_ip();
|
||||
|
||||
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
|
||||
Argus_Policy_Engine::deny_already_banned( $ip );
|
||||
return;
|
||||
}
|
||||
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'login_attempts' ),
|
||||
array(
|
||||
'ip' => $ip,
|
||||
'username' => mb_substr( (string) $username, 0, 60 ),
|
||||
'success' => 0,
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( '%s', '%s', '%d', '%s' )
|
||||
);
|
||||
|
||||
$failures = self::recent_failures( $ip );
|
||||
Argus_Events::record( 'login_failed', $failures >= Argus_Settings::get( 'login_attempt_threshold', 5 ) ? 'high' : 'medium', sprintf( 'Failed login for "%s" from %s (%d recent failures)', $username, $ip, $failures ), array( 'username' => $username, 'failures' => $failures ), $ip );
|
||||
|
||||
self::maybe_check_anis_reputation( $ip, $failures );
|
||||
|
||||
$decision = Argus_Policy_Engine::evaluate( $ip, 'login_failure', array( 'recent_failures' => $failures ) );
|
||||
|
||||
if ( in_array( $decision['action'], array( Argus_Policy_Engine::ACTION_BLOCK, Argus_Policy_Engine::ACTION_CHALLENGE ), true ) ) {
|
||||
self::record_finding( $ip, $username, $failures, $decision );
|
||||
}
|
||||
|
||||
Argus_Policy_Engine::enforce_decision( $ip, 'login_failure', $decision, array( 'recent_failures' => $failures ) );
|
||||
}
|
||||
|
||||
protected static function record_finding( $ip, $username, $failures, array $decision ) {
|
||||
$blocked = Argus_Policy_Engine::ACTION_BLOCK === $decision['action'];
|
||||
|
||||
Argus_Findings::record(
|
||||
'account',
|
||||
$blocked ? 'high' : 'medium',
|
||||
array(
|
||||
|
||||
'what_happened' => sprintf( 'Brute-force login attempts from %s', $ip ),
|
||||
'why_it_matters' => 'Repeated failed logins from one source in a short window is the classic pattern of an automated credential-guessing attack against wp-login.php.',
|
||||
'what_argus_found' => sprintf( '%d failed login attempts from %s. Action taken: %s (%s).', $failures, $ip, $decision['action'], $decision['observation_only'] ? 'observed only, MONITOR mode' : 'enforced' ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'A real person very rarely fails to log in this many times in a row -- this volume is consistent with automated password guessing, not a forgetful admin.',
|
||||
'what_could_be_affected' => 'If a guessed password succeeds, the attacker gains full access under that account\'s role -- often an administrator.',
|
||||
'what_should_you_do' => $decision['observation_only']
|
||||
? 'ARGUS is in MONITOR mode and did not block this. Review recent login activity and switch to BLOCK mode once you are confident legitimate traffic is not being flagged.'
|
||||
: ( $blocked
|
||||
? 'No action needed -- ARGUS already banned this IP.'
|
||||
: 'ARGUS is challenging further attempts from this IP with a proof-of-work check before allowing another login try. If attempts continue, it will be banned outright.' ),
|
||||
),
|
||||
array( 'ip' => $ip, 'username' => $username, 'failures' => $failures, 'decision' => $decision )
|
||||
);
|
||||
}
|
||||
|
||||
protected static function maybe_check_anis_reputation( $ip, $failures ) {
|
||||
if ( ! class_exists( 'Argus_ANIS_Client' ) || ! Argus_ANIS_Client::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
$threshold = (int) Argus_Settings::get( 'login_attempt_threshold', 5 );
|
||||
if ( $failures < max( 1, $threshold - 2 ) || $failures >= $threshold ) {
|
||||
return;
|
||||
}
|
||||
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reputation = Argus_ANIS_Client::check_ip_live( $ip );
|
||||
if ( $reputation && 'ban' === $reputation['action'] && $reputation['score'] >= Argus_ANIS_Client::AUTO_BAN_CONFIDENCE_THRESHOLD ) {
|
||||
Argus_Ban_Engine::ban(
|
||||
$ip,
|
||||
Argus_Ban_Engine::SOURCE_ARGUS_CLOUD,
|
||||
sprintf( 'Flagged by ANIS community threat intelligence during an active login attempt (score %d)', $reputation['score'] ),
|
||||
array( 'anis_score' => $reputation['score'], 'login_failures' => $failures ),
|
||||
Argus_Ban_Engine::LEVEL_EXTENDED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function on_success( $username, $user ) {
|
||||
global $wpdb;
|
||||
$ip = Argus_Request_Inputs::client_ip();
|
||||
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'login_attempts' ),
|
||||
array(
|
||||
'ip' => $ip,
|
||||
'username' => mb_substr( (string) $username, 0, 60 ),
|
||||
'success' => 1,
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( '%s', '%s', '%d', '%s' )
|
||||
);
|
||||
|
||||
if ( $user instanceof WP_User && in_array( 'administrator', (array) $user->roles, true ) ) {
|
||||
$account_age_days = ( time() - strtotime( $user->user_registered . ' UTC' ) ) / DAY_IN_SECONDS;
|
||||
if ( $account_age_days < 1 ) {
|
||||
Argus_Findings::record(
|
||||
'account',
|
||||
'medium',
|
||||
array(
|
||||
'what_happened' => sprintf( 'Administrator "%s" logged in for the first time within a day of the account being created', $username ),
|
||||
'why_it_matters' => 'A brand-new administrator account is a common outcome of a successful privilege-escalation attack, not just a legitimate new team member.',
|
||||
'what_argus_found' => sprintf( 'Account created %s, first login %s, from %s.', $user->user_registered, current_time( 'mysql' ), $ip ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'New administrator accounts created shortly before their first login are worth a quick manual confirmation, especially if you did not create this account yourself.',
|
||||
'what_could_be_affected' => 'A rogue administrator account has full control of the site: content, users, plugins, and themes.',
|
||||
'what_should_you_do' => 'If you created this account intentionally, you can dismiss this finding. If not, remove the account immediately and review recent file/plugin changes.',
|
||||
),
|
||||
array( 'username' => $username, 'user_id' => $user->ID, 'ip' => $ip )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ATTEMPTS_RETENTION_SECS = 7 * DAY_IN_SECONDS;
|
||||
|
||||
public static function prune_attempts() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'login_attempts' );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - self::ATTEMPTS_RETENTION_SECS );
|
||||
|
||||
return $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); // phpcs:ignore
|
||||
}
|
||||
|
||||
public static function recent_failures( $ip ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'login_attempts' );
|
||||
$window = (int) Argus_Settings::get( 'login_attempt_window_secs', 600 );
|
||||
$since = gmdate( 'Y-m-d H:i:s', time() - $window );
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE ip = %s AND success = 0 AND created_at >= %s", // phpcs:ignore
|
||||
$ip,
|
||||
$since
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Malware_Scanner {
|
||||
|
||||
const MAX_FILE_BYTES = 2 * MB_IN_BYTES;
|
||||
|
||||
protected static function heuristics() {
|
||||
return array(
|
||||
array( 'id' => 'eval-dynamic-decode', 'weight' => 40,
|
||||
'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13|convert_uudecode)\s*\(/i',
|
||||
'label' => 'eval() wrapping a decode/decompress function -- the single most common webshell obfuscation pattern' ),
|
||||
array( 'id' => 'superglobal-into-exec', 'weight' => 40,
|
||||
'pattern' => '/\b(eval|assert|system|exec|shell_exec|passthru|proc_open)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i',
|
||||
'label' => 'A request superglobal ($_POST/$_GET/etc.) passed directly into a code-execution function' ),
|
||||
array( 'id' => 'assert-as-eval', 'weight' => 25,
|
||||
'pattern' => '/\bassert\s*\(\s*[\'"]/i',
|
||||
'label' => 'assert() called with a string argument -- a legacy PHP trick equivalent to eval()' ),
|
||||
array( 'id' => 'dangerous-function-present', 'weight' => 15,
|
||||
'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen)\s*\(/i',
|
||||
'label' => 'A shell-execution function is present in the file' ),
|
||||
array( 'id' => 'variable-variable-obfuscation', 'weight' => 15,
|
||||
'pattern' => '/\$\{\s*[\'"]\w+[\'"]\s*\}|\$\$\w+/',
|
||||
'label' => 'Variable-variable syntax -- a common obfuscation technique to hide function/variable names from simple text search' ),
|
||||
array( 'id' => 'large-base64-blob', 'weight' => 20,
|
||||
'pattern' => '/[A-Za-z0-9+\/]{800,}={0,2}/',
|
||||
'label' => 'A very large base64-like blob is embedded in the file' ),
|
||||
array( 'id' => 'create-function', 'weight' => 15,
|
||||
'pattern' => '/\bcreate_function\s*\(/i',
|
||||
'label' => 'create_function() -- deprecated since PHP 7.2, rarely used except to obscure dynamically-generated code' ),
|
||||
array( 'id' => 'file-write-from-request', 'weight' => 20,
|
||||
'pattern' => '/\b(file_put_contents|fwrite)\s*\([^,]+,\s*\$_(POST|GET|REQUEST)/i',
|
||||
'label' => 'Writes request data directly to a file -- consistent with a dropper/uploader shell' ),
|
||||
);
|
||||
}
|
||||
|
||||
const THRESHOLD_HIGH = 55;
|
||||
const THRESHOLD_MEDIUM = 30;
|
||||
|
||||
const QUARANTINE_SUFFIX = '.argus-quarantined';
|
||||
const HTACCESS_MARKER = 'ARGUS WordPress Defence -- deny PHP execution (ADR-0053 §9.1)';
|
||||
const RESTORED_TRUST_OPTION = 'argus_wpd_malware_restored_trust';
|
||||
|
||||
const SCAN_WATERMARK_OPTION = 'argus_wpd_malware_watermark';
|
||||
|
||||
const LOCKDOWN_STATUS_OPTION = 'argus_wpd_uploads_lockdown_status';
|
||||
|
||||
public static function mark_restored_trusted( $rel_path, $hash ) {
|
||||
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
||||
if ( ! is_array( $trust ) ) {
|
||||
$trust = array();
|
||||
}
|
||||
$trust[ $rel_path ] = $hash;
|
||||
update_option( self::RESTORED_TRUST_OPTION, $trust, false );
|
||||
}
|
||||
|
||||
protected static function is_restored_trusted( $rel_path, $hash ) {
|
||||
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
||||
return is_array( $trust ) && isset( $trust[ $rel_path ] ) && hash_equals( $trust[ $rel_path ], $hash );
|
||||
}
|
||||
|
||||
public static function incremental_scan() {
|
||||
self::ensure_uploads_lockdown();
|
||||
self::scan_uploads_for_php();
|
||||
self::scan_recently_changed();
|
||||
self::rescan_restored_trust();
|
||||
}
|
||||
|
||||
public static function ensure_uploads_lockdown() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
$base = $uploads['basedir'] ?? null;
|
||||
if ( ! $base || ! is_dir( $base ) || ! is_writable( $base ) ) {
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false );
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = trailingslashit( $base ) . '.htaccess';
|
||||
|
||||
$rule = "# " . self::HTACCESS_MARKER . "\n"
|
||||
. "<IfModule mod_php.c>\n\tphp_flag engine off\n</IfModule>\n"
|
||||
. "<IfModule mod_php7.c>\n\tphp_flag engine off\n</IfModule>\n"
|
||||
. "<FilesMatch \"\\.ph(p[3457]?|t|tml)\">\n\tRequire all denied\n</FilesMatch>\n";
|
||||
|
||||
$existing = file_exists( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
$healthy = is_string( $existing )
|
||||
&& false !== strpos( $existing, self::HTACCESS_MARKER )
|
||||
&& false !== strpos( $existing, '\\.ph(p[3457]?|t|tml)"' );
|
||||
|
||||
if ( ! $healthy ) {
|
||||
|
||||
$preserved = is_string( $existing ) ? $existing : '';
|
||||
$preserved = (string) preg_replace(
|
||||
'/# ' . preg_quote( self::HTACCESS_MARKER, '/' ) . '.*?<\/FilesMatch>\n?/s',
|
||||
'',
|
||||
$preserved
|
||||
);
|
||||
|
||||
$written = file_put_contents( $path, $rule . $preserved ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( false === $written ) {
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false );
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function lockdown_status() {
|
||||
return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' );
|
||||
}
|
||||
|
||||
protected static function scan_uploads_for_php() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
$base = $uploads['basedir'] ?? null;
|
||||
if ( ! $base || ! is_dir( $base ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data_dir = wp_normalize_path( trailingslashit( $base ) . 'argus-wpd-data' );
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
|
||||
continue;
|
||||
}
|
||||
$path = $file->getPathname();
|
||||
|
||||
if ( 0 === strpos( wp_normalize_path( $path ), $data_dir ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) );
|
||||
|
||||
if ( self::is_restored_trusted( $rel, hash_file( 'sha256', $path ) ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = filesize( $path );
|
||||
$mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) );
|
||||
|
||||
$result = Argus_Quarantine::quarantine_file(
|
||||
$path,
|
||||
$rel,
|
||||
array(
|
||||
'engine' => 'Argus_Malware_Scanner',
|
||||
'rule' => 'php-in-uploads',
|
||||
'type' => 'signature',
|
||||
'severity' => 'critical',
|
||||
)
|
||||
);
|
||||
$neutralized = false !== $result;
|
||||
|
||||
$finding_id = Argus_Findings::record(
|
||||
'malware',
|
||||
'critical',
|
||||
array(
|
||||
'what_happened' => 'A PHP file was found inside the uploads directory: ' . $rel,
|
||||
'why_it_matters' => 'WordPress does not execute PHP inside wp-content/uploads by design -- a legitimate install should never have PHP files there.',
|
||||
'what_argus_found' => sprintf( 'File: %s, size %s bytes, last modified %s.', $rel, $size, $mtime ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'This is one of the most common webshell placement techniques -- uploads directories are usually writable by the web application, unlike wp-admin/wp-includes.',
|
||||
'what_could_be_affected' => 'A PHP file here can be executed directly by visiting its URL, potentially giving an attacker full code execution on your server.',
|
||||
'what_should_you_do' => $neutralized
|
||||
? 'This file has been quarantined -- moved to a protected, non-web-accessible store. Review it on the Quarantine page and decide whether to restore or permanently delete it.'
|
||||
: 'ARGUS detected this file but could not quarantine it (the uploads directory is not writable by PHP on this host, or it is a symlink ARGUS refuses to move automatically). Remove it manually and investigate how it got there.',
|
||||
),
|
||||
array(
|
||||
'quarantine_id' => $neutralized ? $result['id'] : null,
|
||||
'file_path' => $rel,
|
||||
'file_mtime' => $mtime,
|
||||
'neutralized' => $neutralized,
|
||||
),
|
||||
Argus_Findings::STATUS_OPEN
|
||||
);
|
||||
|
||||
if ( $neutralized ) {
|
||||
global $wpdb;
|
||||
$wpdb->update(
|
||||
Argus_DB::table( 'quarantine' ),
|
||||
array( 'finding_id' => $finding_id ),
|
||||
array( 'id' => $result['id'] ),
|
||||
array( '%d' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static function scan_recently_changed() {
|
||||
$since = (int) get_option( self::SCAN_WATERMARK_OPTION, time() - HOUR_IN_SECONDS );
|
||||
|
||||
foreach ( array( WP_PLUGIN_DIR, get_theme_root(), ABSPATH . 'wp-admin', ABSPATH . WPINC ) as $dir ) {
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
continue;
|
||||
}
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( $file->getMTime() < $since ) {
|
||||
continue;
|
||||
}
|
||||
self::scan_file( $file->getPathname() );
|
||||
}
|
||||
}
|
||||
|
||||
update_option( self::SCAN_WATERMARK_OPTION, time(), false );
|
||||
}
|
||||
|
||||
protected static function rescan_restored_trust() {
|
||||
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
||||
if ( ! is_array( $trust ) || empty( $trust ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $trust as $rel_path => $known_hash ) {
|
||||
$abs = ABSPATH . ltrim( $rel_path, '/' );
|
||||
if ( ! is_readable( $abs ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( hash_file( 'sha256', $abs ) === $known_hash ) {
|
||||
continue;
|
||||
}
|
||||
self::scan_file( $abs );
|
||||
}
|
||||
}
|
||||
|
||||
public static function score_content( $content ) {
|
||||
$score = 0;
|
||||
$matched = array();
|
||||
|
||||
foreach ( self::heuristics() as $rule ) {
|
||||
if ( preg_match( $rule['pattern'], $content ) ) {
|
||||
$score += $rule['weight'];
|
||||
$matched[] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
return array( $score, $matched );
|
||||
}
|
||||
|
||||
public static function scan_file( $path ) {
|
||||
if ( ! is_readable( $path ) || filesize( $path ) > self::MAX_FILE_BYTES ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( false === $content ) {
|
||||
return;
|
||||
}
|
||||
|
||||
list( $score, $matched ) = self::score_content( $content );
|
||||
|
||||
if ( $score < self::THRESHOLD_MEDIUM ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$severity = $score >= self::THRESHOLD_HIGH ? 'critical' : 'medium';
|
||||
$rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) );
|
||||
$labels = wp_list_pluck( $matched, 'label' );
|
||||
|
||||
Argus_Findings::record(
|
||||
'malware',
|
||||
$severity,
|
||||
array(
|
||||
'what_happened' => sprintf( 'Suspicious PHP file detected: %s (heuristic score %d)', $rel, $score ),
|
||||
'why_it_matters' => 'This file contains patterns strongly associated with webshells or backdoors -- code designed to give an attacker persistent, hidden access to your server.',
|
||||
'what_argus_found' => implode( '; ', $labels ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'These are static-analysis heuristics, not a claim that this file is definitely malicious -- some are also occasionally present in legitimate advanced plugins. ARGUS shows you exactly what matched so you can judge it yourself.',
|
||||
'what_could_be_affected' => 'If this file is genuinely malicious, an attacker could have code execution on your server through it.',
|
||||
'what_should_you_do' => 'Review the file manually. If you don\'t recognize it or can\'t explain why it exists, remove it and investigate how it got there (check recent file changes, plugin/theme sources, and user activity).',
|
||||
),
|
||||
array( 'file_path' => $rel, 'score' => $score, 'matched_rules' => wp_list_pluck( $matched, 'id' ), 'file_mtime' => gmdate( 'Y-m-d H:i:s', filemtime( $path ) ) )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_MU_Installer {
|
||||
|
||||
public static function mu_plugins_dir() {
|
||||
return WPMU_PLUGIN_DIR;
|
||||
}
|
||||
|
||||
public static function target_path() {
|
||||
return trailingslashit( self::mu_plugins_dir() ) . 'argus-mu-core.php';
|
||||
}
|
||||
|
||||
public static function source_path() {
|
||||
return ARGUS_WPD_DIR . 'mu-loader/argus-mu-core.php';
|
||||
}
|
||||
|
||||
public static function install() {
|
||||
$dir = self::mu_plugins_dir();
|
||||
|
||||
if ( ! file_exists( $dir ) ) {
|
||||
if ( ! wp_mkdir_p( $dir ) ) {
|
||||
update_option( 'argus_wpd_mu_install_status', 'failed_mkdir', false );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! is_writable( $dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
update_option( 'argus_wpd_mu_install_status', 'not_writable', false );
|
||||
return false;
|
||||
}
|
||||
|
||||
$copied = copy( self::source_path(), self::target_path() );
|
||||
update_option( 'argus_wpd_mu_install_status', $copied ? 'installed' : 'failed_copy', false );
|
||||
|
||||
return $copied;
|
||||
}
|
||||
|
||||
const SELF_HEAL_LOG_COOLDOWN_OPTION = 'argus_wpd_mu_self_heal_last_logged';
|
||||
const SELF_HEAL_LOG_COOLDOWN_SECS = HOUR_IN_SECONDS;
|
||||
|
||||
public static function ensure_current() {
|
||||
$target = self::target_path();
|
||||
|
||||
if ( ! file_exists( $target ) ) {
|
||||
self::maybe_log_self_heal( 'high', 'MU enforcement core was missing -- reinstalled', array( 'path' => $target ) );
|
||||
self::install();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( md5_file( $target ) !== md5_file( self::source_path() ) ) {
|
||||
self::maybe_log_self_heal( 'medium', 'MU enforcement core was out of date -- refreshed', array( 'path' => $target ) );
|
||||
self::install();
|
||||
}
|
||||
}
|
||||
|
||||
protected static function maybe_log_self_heal( $severity, $message, array $context ) {
|
||||
$last = (int) get_option( self::SELF_HEAL_LOG_COOLDOWN_OPTION, 0 );
|
||||
if ( time() - $last < self::SELF_HEAL_LOG_COOLDOWN_SECS ) {
|
||||
return;
|
||||
}
|
||||
update_option( self::SELF_HEAL_LOG_COOLDOWN_OPTION, time(), false );
|
||||
Argus_Events::record( 'self_heal', $severity, $message, $context );
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
return get_option( 'argus_wpd_mu_install_status', 'unknown' );
|
||||
}
|
||||
|
||||
const HEARTBEAT_OPTION = 'argus_wpd_mu_core_heartbeat';
|
||||
const HEARTBEAT_INTERVAL_SECS = 300;
|
||||
const HEARTBEAT_STALE_AFTER_SECS = 900;
|
||||
|
||||
public static function execution_status() {
|
||||
$last = (int) get_option( self::HEARTBEAT_OPTION, 0 );
|
||||
if ( 0 === $last ) {
|
||||
|
||||
return 'never';
|
||||
}
|
||||
return ( time() - $last ) <= self::HEARTBEAT_STALE_AFTER_SECS ? 'healthy' : 'stale';
|
||||
}
|
||||
|
||||
public static function uninstall() {
|
||||
$target = self::target_path();
|
||||
if ( file_exists( $target ) ) {
|
||||
wp_delete_file( $target );
|
||||
}
|
||||
delete_option( 'argus_wpd_mu_install_status' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Plugin {
|
||||
|
||||
protected static $instance = null;
|
||||
|
||||
public static function instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function boot() {
|
||||
Argus_DB::maybe_upgrade();
|
||||
Argus_MU_Installer::ensure_current();
|
||||
|
||||
add_filter( 'cron_schedules', array( __CLASS__, 'register_cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval
|
||||
|
||||
Argus_Login_Guard::init();
|
||||
Argus_API_Guard::init();
|
||||
Argus_GeoIP_RIR::init();
|
||||
Argus_Static_Cache::init();
|
||||
Argus_Auto_Update::init();
|
||||
Argus_Upload_Guard::init();
|
||||
Argus_ANIS_Client::init();
|
||||
Argus_Vuln_Intel::init();
|
||||
|
||||
add_action( 'init', array( 'Argus_WAF', 'inspect_and_enforce' ), 0 );
|
||||
|
||||
add_action( 'argus_wpd_hourly', array( __CLASS__, 'run_hourly' ) );
|
||||
add_action( 'argus_wpd_daily', array( __CLASS__, 'run_daily' ) );
|
||||
add_action( 'argus_wpd_five_minutes', array( 'Argus_ANIS_Client', 'maybe_retry_sync' ) );
|
||||
add_action( 'argus_wpd_five_minutes', array( 'Argus_Vuln_Intel', 'maybe_retry' ) );
|
||||
|
||||
add_action( 'argus_wpd_five_minutes', array( 'Argus_Update_Client', 'maybe_check' ) );
|
||||
|
||||
if ( class_exists( 'Argus_Integrity' ) ) {
|
||||
add_action( 'upgrader_process_complete', array( 'Argus_Integrity', 'on_upgrader_complete' ), 10, 2 );
|
||||
}
|
||||
|
||||
if ( is_admin() ) {
|
||||
require_once ARGUS_WPD_DIR . 'admin/class-argus-admin.php';
|
||||
Argus_Admin::init();
|
||||
}
|
||||
}
|
||||
|
||||
public static function run_hourly() {
|
||||
Argus_Ban_Engine::sweep_expired();
|
||||
Argus_Events::prune();
|
||||
Argus_Policy_Engine::prune();
|
||||
Argus_Login_Guard::prune_attempts();
|
||||
|
||||
$scan_started = time();
|
||||
$integrity_ran = class_exists( 'Argus_Integrity' ) && Argus_Settings::get( 'integrity_scan_enabled', true );
|
||||
$malware_ran = class_exists( 'Argus_Malware_Scanner' ) && Argus_Settings::get( 'malware_scan_enabled', true );
|
||||
if ( $integrity_ran ) {
|
||||
Argus_Integrity::incremental_scan();
|
||||
}
|
||||
if ( $malware_ran ) {
|
||||
Argus_Malware_Scanner::incremental_scan();
|
||||
}
|
||||
if ( ( $integrity_ran || $malware_ran ) && class_exists( 'Argus_Scan_History' ) ) {
|
||||
Argus_Scan_History::record( Argus_Scan_History::TYPE_SCHEDULED_INCREMENTAL, $scan_started );
|
||||
}
|
||||
if ( class_exists( 'Argus_Correlation' ) ) {
|
||||
Argus_Correlation::run();
|
||||
}
|
||||
if ( class_exists( 'Argus_REST_Inventory' ) ) {
|
||||
Argus_REST_Inventory::snapshot();
|
||||
}
|
||||
if ( class_exists( 'Argus_AJAX_Inventory' ) ) {
|
||||
Argus_AJAX_Inventory::snapshot();
|
||||
}
|
||||
if ( class_exists( 'Argus_Cache_Log' ) ) {
|
||||
Argus_Cache_Log::rollup_and_prune();
|
||||
}
|
||||
if ( class_exists( 'Argus_Cache_Warmer' ) && Argus_Settings::get( 'cache_warming_enabled', false ) ) {
|
||||
Argus_Cache_Warmer::process_queue();
|
||||
}
|
||||
if ( class_exists( 'Argus_ANIS_Client' ) && Argus_ANIS_Client::is_connected() ) {
|
||||
|
||||
Argus_ANIS_Client::scheduled_sync();
|
||||
}
|
||||
if ( class_exists( 'Argus_Vuln_Intel' ) && Argus_Settings::get( 'vuln_intel_enabled', true ) ) {
|
||||
|
||||
Argus_Vuln_Intel::scheduled_check();
|
||||
}
|
||||
if ( class_exists( 'Argus_Backup' ) ) {
|
||||
|
||||
Argus_Backup::maybe_run_scheduled();
|
||||
}
|
||||
}
|
||||
|
||||
public static function register_cron_schedules( $schedules ) {
|
||||
$schedules['argus_wpd_five_minutes'] = array(
|
||||
'interval' => 5 * MINUTE_IN_SECONDS,
|
||||
'display' => __( 'Every 5 Minutes (ARGUS Defence)', 'argus-wordpress-defence' ),
|
||||
);
|
||||
return $schedules;
|
||||
}
|
||||
|
||||
public static function run_daily() {
|
||||
if ( class_exists( 'Argus_Integrity' ) ) {
|
||||
$scan_started = time();
|
||||
Argus_Integrity::full_scan();
|
||||
if ( class_exists( 'Argus_Scan_History' ) ) {
|
||||
Argus_Scan_History::record( Argus_Scan_History::TYPE_SCHEDULED_FULL, $scan_started, Argus_Integrity::scan_status()['files_scanned'] );
|
||||
}
|
||||
}
|
||||
if ( class_exists( 'Argus_GeoIP_RIR' ) && Argus_Settings::get( 'geoip_rir_enabled', true ) ) {
|
||||
Argus_GeoIP_RIR::run_daily_refresh();
|
||||
}
|
||||
if ( class_exists( 'Argus_Cache_Discovery' ) && Argus_Settings::get( 'cache_discovery_enabled', true ) && Argus_Settings::get( 'static_cache_enabled', false ) ) {
|
||||
Argus_Cache_Discovery::run_discovery();
|
||||
}
|
||||
if ( class_exists( 'Argus_ANIS_Client' ) && Argus_ANIS_Client::is_enabled() ) {
|
||||
|
||||
Argus_ANIS_Client::register();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Policy_Engine {
|
||||
|
||||
const ACTION_ALLOW = 'allow';
|
||||
const ACTION_BLOCK = 'block';
|
||||
const ACTION_CHALLENGE = 'challenge';
|
||||
const ACTION_RATE_LIMIT = 'rate_limit';
|
||||
|
||||
public static function evaluate( $ip, $trigger, array $context = array() ) {
|
||||
$mode = Argus_Settings::mode();
|
||||
|
||||
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
|
||||
return self::decision( self::ACTION_BLOCK, 'active-ban', 'IP is currently banned', $mode, $trigger );
|
||||
}
|
||||
|
||||
if ( Argus_Settings::has_exception( 'ip', $ip ) ) {
|
||||
return self::decision( self::ACTION_ALLOW, 'exception-ip', 'IP is on the explicit exception list', $mode, $trigger );
|
||||
}
|
||||
|
||||
switch ( $trigger ) {
|
||||
case 'waf_match':
|
||||
return self::evaluate_waf( $ip, $context, $mode );
|
||||
case 'login_failure':
|
||||
return self::evaluate_login( $ip, $context, $mode );
|
||||
case 'xmlrpc_abuse':
|
||||
case 'rest_abuse':
|
||||
return self::decision( self::ACTION_BLOCK, $trigger, 'Abuse pattern on ' . $trigger, $mode, $trigger );
|
||||
default:
|
||||
return self::decision( self::ACTION_ALLOW, 'no-match', 'No policy rule matched', $mode, $trigger );
|
||||
}
|
||||
}
|
||||
|
||||
protected static function evaluate_waf( $ip, array $context, $mode ) {
|
||||
$hits = $context['hits'] ?? array();
|
||||
if ( empty( $hits ) ) {
|
||||
return self::decision( self::ACTION_ALLOW, 'waf-no-hits', 'No WAF rule matched', $mode, 'waf_match' );
|
||||
}
|
||||
|
||||
$severity = Argus_WAF_Rules::highest_severity( $hits );
|
||||
$rule_ids = wp_list_pluck( $hits, 'id' );
|
||||
|
||||
if ( 'critical' === $severity || 'high' === $severity ) {
|
||||
return self::decision( self::ACTION_BLOCK, implode( ',', $rule_ids ), 'WAF matched: ' . implode( ', ', $rule_ids ), $mode, 'waf_match' );
|
||||
}
|
||||
if ( Argus_Settings::is_strict() && 'medium' === $severity ) {
|
||||
return self::decision( self::ACTION_CHALLENGE, implode( ',', $rule_ids ), 'WAF matched (strict mode): ' . implode( ', ', $rule_ids ), $mode, 'waf_match' );
|
||||
}
|
||||
|
||||
return self::decision( self::ACTION_ALLOW, 'waf-low-severity', 'Only low-severity WAF signal', $mode, 'waf_match' );
|
||||
}
|
||||
|
||||
protected static function evaluate_login( $ip, array $context, $mode ) {
|
||||
$attempts = (int) ( $context['recent_failures'] ?? 0 );
|
||||
$threshold = (int) Argus_Settings::get( 'login_attempt_threshold', 5 );
|
||||
|
||||
if ( $attempts >= $threshold ) {
|
||||
return self::decision( self::ACTION_BLOCK, 'brute-force-threshold', sprintf( '%d failed login attempts in the tracked window', $attempts ), $mode, 'login_failure' );
|
||||
}
|
||||
if ( $attempts >= max( 1, (int) ceil( $threshold / 2 ) ) ) {
|
||||
return self::decision( self::ACTION_CHALLENGE, 'login-elevated', sprintf( '%d failed login attempts -- challenging before further tries', $attempts ), $mode, 'login_failure' );
|
||||
}
|
||||
|
||||
return self::decision( self::ACTION_ALLOW, 'login-under-threshold', 'Failure count below challenge threshold', $mode, 'login_failure' );
|
||||
}
|
||||
|
||||
protected static function decision( $action, $rule_name, $reason, $mode, $trigger ) {
|
||||
return array(
|
||||
'action' => $action,
|
||||
'rule_name' => $rule_name,
|
||||
'reason' => $reason,
|
||||
|
||||
'observation_only' => Argus_Settings::MODE_MONITOR === $mode,
|
||||
'mode' => $mode,
|
||||
'trigger' => $trigger,
|
||||
);
|
||||
}
|
||||
|
||||
public static function evaluate_and_enforce( $ip, $trigger, array $context = array() ) {
|
||||
$decision = self::evaluate( $ip, $trigger, $context );
|
||||
self::enforce_decision( $ip, $trigger, $decision, $context );
|
||||
return $decision;
|
||||
}
|
||||
|
||||
public static function enforce_decision( $ip, $trigger, array $decision, array $context = array() ) {
|
||||
global $wpdb;
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'policy_decisions' ),
|
||||
array(
|
||||
'ip' => $ip,
|
||||
'trigger_type' => $trigger,
|
||||
'action' => $decision['action'],
|
||||
'rule_name' => $decision['rule_name'],
|
||||
'reason' => $decision['reason'],
|
||||
'observation_only' => $decision['observation_only'] ? 1 : 0,
|
||||
'mode' => $decision['mode'],
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' )
|
||||
);
|
||||
|
||||
if ( self::ACTION_ALLOW === $decision['action'] || $decision['observation_only'] ) {
|
||||
if ( self::ACTION_ALLOW !== $decision['action'] ) {
|
||||
Argus_Events::record( 'policy_observed', 'low', 'Would have ' . $decision['action'] . ': ' . $decision['reason'], $decision, $ip );
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self::enforce( $decision, $ip, $context );
|
||||
}
|
||||
|
||||
public static function deny_already_banned( $ip ) {
|
||||
self::deny_and_exit( $ip, 'IP is currently banned' );
|
||||
}
|
||||
|
||||
protected static function enforce( array $decision, $ip, array $context ) {
|
||||
switch ( $decision['action'] ) {
|
||||
case self::ACTION_BLOCK:
|
||||
Argus_Ban_Engine::ban(
|
||||
$ip,
|
||||
'waf_match' === $decision['trigger'] ? Argus_Ban_Engine::SOURCE_LOCAL_WAF : Argus_Ban_Engine::SOURCE_POLICY_ENGINE,
|
||||
$decision['reason'],
|
||||
array( 'rule_name' => $decision['rule_name'], 'context' => $context )
|
||||
);
|
||||
self::deny_and_exit( $ip, $decision['reason'] );
|
||||
break;
|
||||
|
||||
case self::ACTION_CHALLENGE:
|
||||
if ( ! Argus_Challenge::has_valid_pass( $ip ) ) {
|
||||
Argus_Events::record( 'challenge_issued', 'medium', 'Challenge issued: ' . $decision['reason'], $decision, $ip );
|
||||
Argus_Challenge::render_and_exit( $ip );
|
||||
}
|
||||
break;
|
||||
|
||||
case self::ACTION_RATE_LIMIT:
|
||||
self::deny_and_exit( $ip, $decision['reason'], 429 );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected static function deny_and_exit( $ip, $reason, $status = 403 ) {
|
||||
nocache_headers();
|
||||
status_header( $status );
|
||||
header( 'Content-Type: text/plain; charset=utf-8' );
|
||||
echo 'Forbidden.';
|
||||
exit;
|
||||
}
|
||||
|
||||
const RETENTION_DAYS = 90;
|
||||
|
||||
public static function prune() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'policy_decisions' );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::RETENTION_DAYS * DAY_IN_SECONDS ) );
|
||||
|
||||
return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); // phpcs:ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Quarantine {
|
||||
|
||||
const STATUS_QUARANTINED = 'quarantined';
|
||||
const STATUS_RESTORED = 'restored';
|
||||
const STATUS_DELETED = 'deleted';
|
||||
|
||||
const LOCKDOWN_STATUS_OPTION = 'argus_wpd_quarantine_lockdown_status';
|
||||
const HTACCESS_MARKER = 'ARGUS WordPress Defence -- quarantine store, deny all direct access';
|
||||
|
||||
public static function quarantine_dir() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
return trailingslashit( $uploads['basedir'] ?? '' ) . 'argus-wpd-data/quarantine';
|
||||
}
|
||||
|
||||
public static function ensure_quarantine_lockdown() {
|
||||
$dir = self::quarantine_dir();
|
||||
|
||||
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_mkdir', false );
|
||||
return false;
|
||||
}
|
||||
if ( ! is_writable( $dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false );
|
||||
return false;
|
||||
}
|
||||
|
||||
$htaccess = trailingslashit( $dir ) . '.htaccess';
|
||||
$rule = "# " . self::HTACCESS_MARKER . "\nRequire all denied\n";
|
||||
$existing = file_exists( $htaccess ) ? file_get_contents( $htaccess ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
if ( false === $existing || false === strpos( $existing, 'Require all denied' ) ) {
|
||||
$written = file_put_contents( $htaccess, $rule ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( false === $written ) {
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$index = trailingslashit( $dir ) . 'index.php';
|
||||
if ( ! file_exists( $index ) ) {
|
||||
file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
|
||||
update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false );
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function lockdown_status() {
|
||||
return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' );
|
||||
}
|
||||
|
||||
public static function quarantine_file( $abs_path, $rel_path, array $detection ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( is_link( $abs_path ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! self::ensure_quarantine_lockdown() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! is_readable( $abs_path ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = hash_file( 'sha256', $abs_path );
|
||||
$size = filesize( $abs_path );
|
||||
$mtime = gmdate( 'Y-m-d H:i:s', filemtime( $abs_path ) );
|
||||
$perms = substr( sprintf( '%o', fileperms( $abs_path ) ), -4 );
|
||||
|
||||
$dir = self::quarantine_dir();
|
||||
do {
|
||||
$filename = 'q-' . wp_generate_password( 24, false, false ) . '.bin';
|
||||
$target = trailingslashit( $dir ) . $filename;
|
||||
} while ( file_exists( $target ) );
|
||||
|
||||
$moved = @rename( $abs_path, $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
if ( ! $moved ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'quarantine' ),
|
||||
array(
|
||||
'original_path' => $rel_path,
|
||||
'quarantine_filename' => $filename,
|
||||
'original_filename' => basename( $rel_path ),
|
||||
'file_size' => $size,
|
||||
'file_hash' => $hash,
|
||||
'original_mtime' => $mtime,
|
||||
'original_perms' => $perms,
|
||||
'detection_engine' => $detection['engine'],
|
||||
'detection_rule' => $detection['rule'] ?? null,
|
||||
'detection_type' => $detection['type'],
|
||||
'severity' => $detection['severity'],
|
||||
'status' => self::STATUS_QUARANTINED,
|
||||
'quarantined_at' => $now,
|
||||
),
|
||||
array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
$id = (int) $wpdb->insert_id;
|
||||
|
||||
Argus_Events::record(
|
||||
'quarantine_added',
|
||||
'high',
|
||||
sprintf( 'Quarantined %s (%s)', $rel_path, $detection['engine'] ),
|
||||
array( 'quarantine_id' => $id, 'original_path' => $rel_path, 'detection_type' => $detection['type'] )
|
||||
);
|
||||
|
||||
return array( 'id' => $id, 'hash' => $hash, 'quarantine_filename' => $filename );
|
||||
}
|
||||
|
||||
protected static function store_path( $row ) {
|
||||
return trailingslashit( self::quarantine_dir() ) . $row->quarantine_filename;
|
||||
}
|
||||
|
||||
public static function restore( $id ) {
|
||||
$row = self::get( $id );
|
||||
if ( ! $row || self::STATUS_QUARANTINED !== $row->status ) {
|
||||
return array( 'success' => false, 'message' => __( 'That item is not currently quarantined.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$store_path = self::store_path( $row );
|
||||
if ( ! file_exists( $store_path ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'The quarantined file is missing from the quarantine store.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$content = file_get_contents( $store_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
list( $score, $matched ) = Argus_Malware_Scanner::score_content( (string) $content );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
if ( $score >= Argus_Malware_Scanner::THRESHOLD_MEDIUM ) {
|
||||
$wpdb->update(
|
||||
Argus_DB::table( 'quarantine' ),
|
||||
array( 'restore_attempts' => (int) $row->restore_attempts + 1 ),
|
||||
array( 'id' => $row->id ),
|
||||
array( '%d' ),
|
||||
array( '%d' )
|
||||
);
|
||||
return array(
|
||||
'success' => false,
|
||||
|
||||
'message' => __( 'Restore cancelled -- the restored file still triggers the security engine.', 'argus-wordpress-defence' ),
|
||||
);
|
||||
}
|
||||
|
||||
$abs_original = ABSPATH . ltrim( $row->original_path, '/' );
|
||||
if ( file_exists( $abs_original ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'A file already exists at the original location.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$moved = @rename( $store_path, $abs_original ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
if ( ! $moved ) {
|
||||
return array( 'success' => false, 'message' => __( 'Could not move the file back to its original location.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
if ( $row->original_perms ) {
|
||||
@chmod( $abs_original, octdec( $row->original_perms ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.PHP.NoSilencedErrors
|
||||
}
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
$wpdb->update(
|
||||
Argus_DB::table( 'quarantine' ),
|
||||
array( 'status' => self::STATUS_RESTORED, 'resolved_at' => $now, 'restore_attempts' => (int) $row->restore_attempts + 1 ),
|
||||
array( 'id' => $row->id ),
|
||||
array( '%s', '%s', '%d' ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
Argus_Malware_Scanner::mark_restored_trusted( $row->original_path, hash_file( 'sha256', $abs_original ) );
|
||||
|
||||
if ( $row->finding_id ) {
|
||||
Argus_Findings::set_status( $row->finding_id, Argus_Findings::STATUS_RESOLVED );
|
||||
}
|
||||
|
||||
Argus_Events::record(
|
||||
'quarantine_restored',
|
||||
'info',
|
||||
sprintf( 'Restored %s from quarantine', $row->original_path ),
|
||||
array( 'quarantine_id' => $row->id, 'original_path' => $row->original_path, 'score' => $score )
|
||||
);
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
|
||||
'message' => __( 'Restored -- ARGUS\'s static-analysis engine found no suspicious patterns in the current content. This is not a guarantee the file is safe, only that it did not match ARGUS\'s known indicators.', 'argus-wordpress-defence' ),
|
||||
);
|
||||
}
|
||||
|
||||
public static function delete( $id ) {
|
||||
global $wpdb;
|
||||
|
||||
$row = self::get( $id );
|
||||
if ( ! $row || self::STATUS_QUARANTINED !== $row->status ) {
|
||||
return array( 'success' => false, 'message' => __( 'That item is not currently quarantined.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$store_path = self::store_path( $row );
|
||||
if ( file_exists( $store_path ) && ! @unlink( $store_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
return array( 'success' => false, 'message' => __( 'Could not delete the quarantined file.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
$wpdb->update(
|
||||
Argus_DB::table( 'quarantine' ),
|
||||
array( 'status' => self::STATUS_DELETED, 'resolved_at' => $now ),
|
||||
array( 'id' => $row->id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $row->finding_id ) {
|
||||
Argus_Findings::set_status( $row->finding_id, Argus_Findings::STATUS_RESOLVED );
|
||||
}
|
||||
|
||||
Argus_Events::record(
|
||||
'quarantine_deleted',
|
||||
'info',
|
||||
sprintf( 'Permanently deleted quarantined file: %s', $row->original_path ),
|
||||
array( 'quarantine_id' => $row->id, 'original_path' => $row->original_path )
|
||||
);
|
||||
|
||||
return array( 'success' => true, 'message' => __( 'Permanently deleted.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
public static function analyse( $id ) {
|
||||
global $wpdb;
|
||||
|
||||
$row = self::get( $id );
|
||||
if ( ! $row ) {
|
||||
return array( 'success' => false, 'message' => __( 'Quarantine item not found.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$store_path = self::store_path( $row );
|
||||
if ( ! file_exists( $store_path ) ) {
|
||||
return array( 'success' => false, 'message' => __( 'The quarantined file is missing from the quarantine store.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$content = file_get_contents( $store_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
list( $score, $matched ) = Argus_Malware_Scanner::score_content( (string) $content );
|
||||
|
||||
$wpdb->update(
|
||||
Argus_DB::table( 'quarantine' ),
|
||||
array(
|
||||
'confidence_score' => $score,
|
||||
'matched_rules' => wp_json_encode( wp_list_pluck( $matched, 'id' ) ),
|
||||
'analysed_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( 'id' => $row->id ),
|
||||
array( '%d', '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
return array( 'success' => true, 'score' => $score, 'matched' => $matched );
|
||||
}
|
||||
|
||||
public static function get( $id ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'quarantine' );
|
||||
return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); // phpcs:ignore
|
||||
}
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
public static function paginated( $page = 1, $status = null, $per_page = self::PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'quarantine' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
if ( $status ) {
|
||||
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", $status ) ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY quarantined_at DESC LIMIT %d OFFSET %d", $status, $per_page, $offset ) // phpcs:ignore
|
||||
);
|
||||
} else {
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY quarantined_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$row->matched_rules = $row->matched_rules ? json_decode( $row->matched_rules, true ) : array();
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
|
||||
public static function count_open() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'quarantine' );
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", self::STATUS_QUARANTINED ) // phpcs:ignore
|
||||
);
|
||||
}
|
||||
|
||||
public static function counts_by_status() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'quarantine' );
|
||||
$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS cnt FROM {$table} GROUP BY status" ); // phpcs:ignore
|
||||
|
||||
$out = array( self::STATUS_QUARANTINED => 0, self::STATUS_RESTORED => 0, self::STATUS_DELETED => 0 );
|
||||
foreach ( $rows as $row ) {
|
||||
if ( isset( $out[ $row->status ] ) ) {
|
||||
$out[ $row->status ] = (int) $row->cnt;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function legacy_count() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
$dir = $uploads['basedir'] ?? '';
|
||||
if ( ! $dir || ! is_dir( $dir ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$suffix = Argus_Malware_Scanner::QUARANTINE_SUFFIX;
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( $file->isFile() && $suffix === substr( $file->getFilename(), -strlen( $suffix ) ) ) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
if ( ! class_exists( 'Argus_Request_Inputs' ) ) {
|
||||
|
||||
class Argus_Request_Inputs {
|
||||
|
||||
public static function collect() {
|
||||
$inputs = array();
|
||||
|
||||
foreach ( (array) $_GET as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification
|
||||
self::flatten( 'GET:' . $key, $value, $inputs );
|
||||
}
|
||||
foreach ( (array) $_POST as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification
|
||||
self::flatten( 'POST:' . $key, $value, $inputs );
|
||||
}
|
||||
|
||||
$raw_body = file_get_contents( 'php://input' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( is_string( $raw_body ) && '' !== $raw_body ) {
|
||||
|
||||
$inputs['BODY'] = mb_substr( $raw_body, 0, 8192 );
|
||||
}
|
||||
|
||||
foreach ( array( 'HTTP_USER_AGENT', 'HTTP_REFERER', 'HTTP_X_FORWARDED_FOR', 'REQUEST_URI' ) as $server_key ) {
|
||||
if ( ! empty( $_SERVER[ $server_key ] ) ) {
|
||||
$inputs[ 'HEADER:' . $server_key ] = stripslashes( (string) $_SERVER[ $server_key ] ); // phpcs:ignore
|
||||
}
|
||||
}
|
||||
|
||||
return $inputs;
|
||||
}
|
||||
|
||||
protected static function flatten( $prefix, $value, array &$out, $depth = 0 ) {
|
||||
if ( $depth > 3 ) {
|
||||
return;
|
||||
}
|
||||
if ( is_array( $value ) ) {
|
||||
foreach ( $value as $k => $v ) {
|
||||
self::flatten( $prefix . '.' . $k, $v, $out, $depth + 1 );
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ( is_string( $value ) ) {
|
||||
$out[ $prefix ] = stripslashes( $value );
|
||||
}
|
||||
}
|
||||
|
||||
public static function client_ip() {
|
||||
|
||||
return isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : ''; // phpcs:ignore
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_REST_Inventory {
|
||||
|
||||
public static function snapshot() {
|
||||
if ( ! function_exists( 'rest_get_server' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = rest_get_server();
|
||||
$routes = $server->get_routes();
|
||||
|
||||
$flagged = array();
|
||||
|
||||
foreach ( $routes as $route => $handlers ) {
|
||||
|
||||
if ( 0 === strpos( $route, '/wp/v2' ) || 0 === strpos( $route, '/oembed' ) || 0 === strpos( $route, '/batch' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ( $handlers as $handler ) {
|
||||
$permission = $handler['permission_callback'] ?? null;
|
||||
$methods = is_array( $handler['methods'] ?? null ) ? array_keys( array_filter( $handler['methods'] ) ) : (array) ( $handler['methods'] ?? array() );
|
||||
|
||||
$is_wide_open = ( null === $permission ) || '__return_true' === $permission;
|
||||
$accepts_write = (bool) array_intersect( array( 'POST', 'PUT', 'PATCH', 'DELETE' ), $methods );
|
||||
|
||||
if ( $is_wide_open && $accepts_write ) {
|
||||
$flagged[] = array(
|
||||
'route' => $route,
|
||||
'methods' => $methods,
|
||||
'plugin' => self::owning_plugin( $handler['callback'] ?? null ),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update_option( 'argus_wpd_rest_inventory', array( 'checked_at' => current_time( 'mysql', true ), 'flagged' => $flagged, 'total_routes' => count( $routes ) ), false );
|
||||
|
||||
foreach ( $flagged as $route_info ) {
|
||||
Argus_Findings::record(
|
||||
'rest_inventory',
|
||||
'low',
|
||||
array(
|
||||
'what_happened' => sprintf( 'Unauthenticated, state-changing REST route found: %s (%s)', $route_info['route'], implode( '/', $route_info['methods'] ) ),
|
||||
'why_it_matters' => 'A route that accepts POST/PUT/PATCH/DELETE with no permission check is reachable by anyone, including automated scanners -- this is often intentional (e.g. a public contact-form endpoint) but worth a quick review.',
|
||||
'what_argus_found' => sprintf( 'Route registered by: %s.', $route_info['plugin'] ?: 'unknown' ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'This is an inventory finding, not a detected attack -- ARGUS does not know what this endpoint actually does, only that nothing stops an unauthenticated caller from reaching it.',
|
||||
'what_could_be_affected' => 'Depends entirely on what the endpoint does -- review its plugin\'s documentation or source.',
|
||||
'what_should_you_do' => 'If this endpoint is meant to be public, no action is needed. If not, check whether the owning plugin has a setting to require authentication, or contact its developer.',
|
||||
),
|
||||
array( 'route' => $route_info['route'], 'methods' => $route_info['methods'], 'plugin' => $route_info['plugin'] )
|
||||
);
|
||||
}
|
||||
|
||||
return $flagged;
|
||||
}
|
||||
|
||||
public static function owning_plugin( $callback ) {
|
||||
try {
|
||||
if ( is_array( $callback ) && is_object( $callback[0] ?? null ) ) {
|
||||
$ref = new ReflectionClass( $callback[0] );
|
||||
} elseif ( is_string( $callback ) && function_exists( $callback ) ) {
|
||||
$ref = new ReflectionFunction( $callback );
|
||||
} elseif ( $callback instanceof Closure ) {
|
||||
$ref = new ReflectionFunction( $callback );
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
$file = $ref->getFileName();
|
||||
if ( ! $file ) {
|
||||
return null;
|
||||
}
|
||||
$rel = str_replace( wp_normalize_path( WP_PLUGIN_DIR ) . '/', '', wp_normalize_path( $file ) );
|
||||
return explode( '/', $rel )[0] ?? null;
|
||||
} catch ( ReflectionException $e ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Scan_History {
|
||||
|
||||
const TYPE_QUICK = 'quick';
|
||||
const TYPE_FULL = 'full';
|
||||
const TYPE_SCHEDULED_INCREMENTAL = 'scheduled_incremental';
|
||||
const TYPE_SCHEDULED_FULL = 'scheduled_full';
|
||||
|
||||
const PER_PAGE = 5;
|
||||
|
||||
public static function record( $type, $started_at, $files_scanned = 0 ) {
|
||||
global $wpdb;
|
||||
|
||||
$findings_count = Argus_Findings::count_open( 'malware' ) + Argus_Findings::count_open( 'integrity' );
|
||||
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'scan_history' ),
|
||||
array(
|
||||
'scan_type' => $type,
|
||||
'started_at' => gmdate( 'Y-m-d H:i:s', $started_at ),
|
||||
'finished_at' => current_time( 'mysql', true ),
|
||||
'files_scanned' => (int) $files_scanned,
|
||||
'findings_count' => $findings_count,
|
||||
'result' => $findings_count > 0 ? 'issues' : 'clean',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function paginated( $page = 1, $per_page = self::PER_PAGE ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'scan_history' );
|
||||
$page = max( 1, (int) $page );
|
||||
$offset = ( $page - 1 ) * $per_page;
|
||||
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY started_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore
|
||||
);
|
||||
|
||||
return array(
|
||||
'rows' => $rows,
|
||||
'total' => $total,
|
||||
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
||||
'page' => $page,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Settings {
|
||||
|
||||
const OPTION = 'argus_wpd_settings';
|
||||
|
||||
const MODE_MONITOR = 'monitor';
|
||||
const MODE_BLOCK = 'block';
|
||||
const MODE_STRICT = 'strict';
|
||||
|
||||
protected static $defaults = array(
|
||||
|
||||
'mode' => self::MODE_MONITOR,
|
||||
'waf_enabled' => true,
|
||||
'login_protection_enabled' => true,
|
||||
'xmlrpc_block_pingback' => true,
|
||||
'rest_api_protection_enabled'=> true,
|
||||
'integrity_scan_enabled' => true,
|
||||
'malware_scan_enabled' => true,
|
||||
'upload_scan_enabled' => true,
|
||||
'vuln_intel_enabled' => true,
|
||||
'geoip_rir_enabled' => true,
|
||||
'static_cache_enabled' => false,
|
||||
'static_cache_ttl_secs' => 3600,
|
||||
'cache_stale_grace_secs' => 600,
|
||||
'cache_custom_exclude_paths' => array(),
|
||||
'cache_gzip_enabled' => true,
|
||||
'cache_gzip_level' => 6,
|
||||
'cache_brotli_enabled' => true,
|
||||
'cache_brotli_level' => 5,
|
||||
'cache_min_compress_bytes' => 1024,
|
||||
'cache_discovery_enabled' => true,
|
||||
'cache_warming_enabled' => false,
|
||||
'cache_warm_concurrency' => 4,
|
||||
'cache_warm_batch_size' => 20,
|
||||
'cache_warm_min_interval_secs' => 1,
|
||||
'challenge_enabled' => true,
|
||||
'login_attempt_threshold' => 5,
|
||||
'login_attempt_window_secs' => 600,
|
||||
'login_lockout_secs' => 900,
|
||||
'exceptions' => array(),
|
||||
'cloud_connected' => false,
|
||||
|
||||
'anis_enabled' => true,
|
||||
'anis_base_url' => 'https://anis.weboria.eu',
|
||||
'anis_license_key' => '',
|
||||
|
||||
'backup_schedule_mode' => 'automatic',
|
||||
'backup_interval_hours' => 24,
|
||||
'backup_retention_count' => 5,
|
||||
);
|
||||
|
||||
public static function all() {
|
||||
$stored = get_option( self::OPTION, array() );
|
||||
return wp_parse_args( $stored, self::$defaults );
|
||||
}
|
||||
|
||||
public static function get( $key, $fallback = null ) {
|
||||
$all = self::all();
|
||||
return array_key_exists( $key, $all ) ? $all[ $key ] : $fallback;
|
||||
}
|
||||
|
||||
public static function update( array $partial ) {
|
||||
$all = array_merge( self::all(), $partial );
|
||||
|
||||
update_option( self::OPTION, $all, true );
|
||||
return $all;
|
||||
}
|
||||
|
||||
public static function mode() {
|
||||
return self::get( 'mode', self::MODE_MONITOR );
|
||||
}
|
||||
|
||||
public static function is_monitor_only() {
|
||||
return self::mode() === self::MODE_MONITOR;
|
||||
}
|
||||
|
||||
public static function is_strict() {
|
||||
return self::mode() === self::MODE_STRICT;
|
||||
}
|
||||
|
||||
public static function has_exception( $type, $value ) {
|
||||
foreach ( self::get( 'exceptions', array() ) as $exception ) {
|
||||
if ( ( $exception['type'] ?? '' ) === $type && ( $exception['value'] ?? '' ) === $value ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function add_exception( $type, $value, $note = '' ) {
|
||||
if ( self::has_exception( $type, $value ) ) {
|
||||
return self::all();
|
||||
}
|
||||
$exceptions = self::get( 'exceptions', array() );
|
||||
$exceptions[] = array(
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
'note' => $note,
|
||||
'added_at' => current_time( 'mysql', true ),
|
||||
);
|
||||
return self::update( array( 'exceptions' => $exceptions ) );
|
||||
}
|
||||
|
||||
public static function remove_exception( $type, $value ) {
|
||||
$exceptions = array_values(
|
||||
array_filter(
|
||||
self::get( 'exceptions', array() ),
|
||||
function ( $e ) use ( $type, $value ) {
|
||||
return ! ( ( $e['type'] ?? '' ) === $type && ( $e['value'] ?? '' ) === $value );
|
||||
}
|
||||
)
|
||||
);
|
||||
return self::update( array( 'exceptions' => $exceptions ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Static_Cache {
|
||||
|
||||
const STATUS_HIT = 'HIT';
|
||||
const STATUS_MISS = 'MISS';
|
||||
const STATUS_BYPASS = 'BYPASS';
|
||||
const STATUS_EXPIRED = 'EXPIRED';
|
||||
const STATUS_STALE = 'STALE';
|
||||
const STATUS_REVALIDATED = 'REVALIDATED';
|
||||
const STATUS_WARMED = 'WARMED';
|
||||
const STATUS_PURGED = 'PURGED';
|
||||
const STATUS_EXCLUDED = 'EXCLUDED';
|
||||
|
||||
public static function init() {
|
||||
if ( class_exists( 'Argus_Settings' ) ) {
|
||||
$custom_excludes = (array) Argus_Settings::get( 'cache_custom_exclude_paths', array() );
|
||||
if ( ! empty( $custom_excludes ) ) {
|
||||
add_filter(
|
||||
'argus_wpd_cache_protected_paths',
|
||||
function ( $paths ) use ( $custom_excludes ) {
|
||||
return array_merge( $paths, $custom_excludes );
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! Argus_Settings::get( 'static_cache_enabled', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action( 'send_headers', array( __CLASS__, 'maybe_serve_cached' ), 0 );
|
||||
|
||||
foreach ( array( 'switch_theme', 'customize_save_after', 'activated_plugin', 'deactivated_plugin' ) as $hook ) {
|
||||
add_action( $hook, array( __CLASS__, 'purge_all' ) );
|
||||
}
|
||||
add_action( 'save_post', array( __CLASS__, 'purge_related_to_post' ) );
|
||||
add_action( 'deleted_post', array( __CLASS__, 'purge_related_to_post' ) );
|
||||
add_action( 'comment_post', array( __CLASS__, 'purge_related_to_comment' ) );
|
||||
add_action( 'wp_set_comment_status', array( __CLASS__, 'purge_related_to_comment' ) );
|
||||
}
|
||||
|
||||
public static function maybe_serve_cached() {
|
||||
$start = microtime( true );
|
||||
$url = self::current_url();
|
||||
|
||||
$classification = Argus_Cache_Eligibility::classify_request( self::request_context() );
|
||||
|
||||
if ( self::SAFE_TO_CACHE_STATUS() !== $classification['status'] ) {
|
||||
$log_status = Argus_Cache_Eligibility::FORCE_EXCLUDED === $classification['status'] ? self::STATUS_EXCLUDED : self::STATUS_BYPASS;
|
||||
Argus_Cache_Log::record( $log_status, $url, $classification['reason'] );
|
||||
return;
|
||||
}
|
||||
|
||||
$key = self::cache_key( $url );
|
||||
$paths = self::cache_paths( $key );
|
||||
$ttl = (int) Argus_Settings::get( 'static_cache_ttl_secs', 3600 );
|
||||
$grace = (int) Argus_Settings::get( 'cache_stale_grace_secs', 600 );
|
||||
|
||||
if ( file_exists( $paths['html'] ) ) {
|
||||
$age = time() - filemtime( $paths['html'] );
|
||||
|
||||
if ( $age < $ttl ) {
|
||||
self::serve_hit( $paths, $url, self::STATUS_HIT, $start );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $age < ( $ttl + $grace ) ) {
|
||||
$lock = self::acquire_lock( $key );
|
||||
if ( ! $lock ) {
|
||||
self::serve_hit( $paths, $url, self::STATUS_STALE, $start );
|
||||
return;
|
||||
}
|
||||
|
||||
self::start_capture( $url, $key, $paths, self::STATUS_REVALIDATED, $start, $lock );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$lock = self::acquire_lock( $key );
|
||||
if ( ! $lock ) {
|
||||
|
||||
Argus_Cache_Log::record( self::STATUS_MISS, $url, 'Cache miss (another request is already regenerating this URL).', null );
|
||||
return;
|
||||
}
|
||||
|
||||
self::start_capture( $url, $key, $paths, self::STATUS_MISS, $start, $lock );
|
||||
}
|
||||
|
||||
protected static function SAFE_TO_CACHE_STATUS() {
|
||||
return Argus_Cache_Eligibility::SAFE_TO_CACHE;
|
||||
}
|
||||
|
||||
protected static function start_capture( $url, $key, array $paths, $status, $start, $lock ) {
|
||||
header( 'X-Argus-Cache: ' . $status );
|
||||
ob_start(
|
||||
function ( $html ) use ( $url, $key, $paths, $status, $start, $lock ) {
|
||||
return self::capture_and_store( $html, $url, $key, $paths, $status, $start, $lock );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static function capture_and_store( $html, $url, $key, array $paths, $status, $start, $lock ) {
|
||||
$response_ms = (int) round( ( microtime( true ) - $start ) * 1000 );
|
||||
|
||||
$response_context = array(
|
||||
'status_code' => http_response_code(),
|
||||
'headers' => self::response_headers(),
|
||||
'new_cookies_set' => self::response_cookie_names(),
|
||||
'content_type' => self::response_content_type(),
|
||||
);
|
||||
|
||||
$classification = Argus_Cache_Eligibility::classify_response( $response_context );
|
||||
|
||||
if ( Argus_Cache_Eligibility::SAFE_TO_CACHE !== $classification['status'] || '' === $html ) {
|
||||
$log_status = '' === $html ? self::STATUS_BYPASS : ( Argus_Cache_Eligibility::FORCE_EXCLUDED === $classification['status'] ? self::STATUS_EXCLUDED : self::STATUS_BYPASS );
|
||||
Argus_Cache_Log::record( $log_status, $url, '' === $html ? 'Empty response body.' : $classification['reason'], $response_ms );
|
||||
self::release_lock( $lock );
|
||||
return $html;
|
||||
}
|
||||
|
||||
self::write_cache_entry( $paths, $html, $url );
|
||||
Argus_Cache_Log::record( $status, $url, 'Stored.', $response_ms );
|
||||
self::release_lock( $lock );
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
protected static function write_cache_entry( array $paths, $html, $url ) {
|
||||
wp_mkdir_p( dirname( $paths['html'] ) );
|
||||
file_put_contents( $paths['html'], $html ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
$min_bytes = (int) Argus_Settings::get( 'cache_min_compress_bytes', 1024 );
|
||||
if ( strlen( $html ) >= $min_bytes ) {
|
||||
if ( Argus_Settings::get( 'cache_gzip_enabled', true ) && function_exists( 'gzencode' ) ) {
|
||||
$level = (int) Argus_Settings::get( 'cache_gzip_level', 6 );
|
||||
file_put_contents( $paths['gz'], gzencode( $html, max( 1, min( 9, $level ) ) ) ); // phpcs:ignore
|
||||
} else {
|
||||
@unlink( $paths['gz'] ); // phpcs:ignore
|
||||
}
|
||||
if ( Argus_Settings::get( 'cache_brotli_enabled', true ) && function_exists( 'brotli_compress' ) ) {
|
||||
$level = (int) Argus_Settings::get( 'cache_brotli_level', 5 );
|
||||
file_put_contents( $paths['br'], brotli_compress( $html, max( 0, min( 11, $level ) ) ) ); // phpcs:ignore
|
||||
} else {
|
||||
@unlink( $paths['br'] ); // phpcs:ignore
|
||||
}
|
||||
} else {
|
||||
@unlink( $paths['gz'] ); // phpcs:ignore
|
||||
@unlink( $paths['br'] ); // phpcs:ignore
|
||||
}
|
||||
|
||||
file_put_contents( // phpcs:ignore
|
||||
$paths['meta'],
|
||||
wp_json_encode(
|
||||
array(
|
||||
'url' => $url,
|
||||
'bytes' => strlen( $html ),
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected static function serve_hit( array $paths, $url, $status, $start ) {
|
||||
$encoding = self::negotiate_encoding();
|
||||
$source = $paths['html'];
|
||||
$header_encoding = '';
|
||||
|
||||
if ( 'br' === $encoding && file_exists( $paths['br'] ) ) {
|
||||
$source = $paths['br'];
|
||||
$header_encoding = 'br';
|
||||
} elseif ( 'gzip' === $encoding && file_exists( $paths['gz'] ) ) {
|
||||
$source = $paths['gz'];
|
||||
$header_encoding = 'gzip';
|
||||
}
|
||||
|
||||
header( 'X-Argus-Cache: ' . $status );
|
||||
header( 'X-Argus-Cache-Age: ' . ( time() - filemtime( $paths['html'] ) ) );
|
||||
header( 'Vary: Accept-Encoding' );
|
||||
if ( $header_encoding ) {
|
||||
header( 'Content-Encoding: ' . $header_encoding );
|
||||
}
|
||||
header( 'Content-Type: text/html; charset=UTF-8' );
|
||||
readfile( $source ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
Argus_Cache_Log::record( $status, $url, 'Served from cache.', (int) round( ( microtime( true ) - $start ) * 1000 ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
protected static function negotiate_encoding() {
|
||||
$accept = strtolower( (string) ( $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
if ( false !== strpos( $accept, 'br' ) ) {
|
||||
return 'br';
|
||||
}
|
||||
if ( false !== strpos( $accept, 'gzip' ) ) {
|
||||
return 'gzip';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
protected static function request_context() {
|
||||
$cookies = array();
|
||||
foreach ( (array) $_COOKIE as $name => $value ) { // phpcs:ignore WordPress.Security.NonceVerification
|
||||
$cookies[ (string) $name ] = '';
|
||||
}
|
||||
|
||||
return array(
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
'path' => (string) wp_parse_url( self::current_url(), PHP_URL_PATH ),
|
||||
'query' => $_GET, // phpcs:ignore WordPress.Security.NonceVerification
|
||||
'is_admin' => is_admin(),
|
||||
'is_logged_in' => is_user_logged_in(),
|
||||
'cookies' => $cookies,
|
||||
'has_authorization_header' => ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) || ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ), // phpcs:ignore
|
||||
'is_ajax' => wp_doing_ajax(),
|
||||
'is_rest' => defined( 'REST_REQUEST' ) && REST_REQUEST,
|
||||
'is_cron' => defined( 'DOING_CRON' ) && DOING_CRON,
|
||||
) + self::conditional_tag_excludes();
|
||||
}
|
||||
|
||||
protected static function conditional_tag_excludes() {
|
||||
if ( is_feed() || is_trackback() || is_preview() || is_search() || is_404() ) {
|
||||
return array( 'is_admin' => true );
|
||||
}
|
||||
return array();
|
||||
}
|
||||
|
||||
protected static function response_headers() {
|
||||
$out = array();
|
||||
foreach ( headers_list() as $header ) {
|
||||
$parts = explode( ':', $header, 2 );
|
||||
if ( 2 !== count( $parts ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = strtolower( trim( $parts[0] ) );
|
||||
if ( isset( $out[ $name ] ) ) {
|
||||
$out[ $name ] .= ', ' . trim( $parts[1] );
|
||||
} else {
|
||||
$out[ $name ] = trim( $parts[1] );
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected static function response_cookie_names() {
|
||||
$names = array();
|
||||
foreach ( headers_list() as $header ) {
|
||||
if ( 0 === stripos( $header, 'Set-Cookie:' ) && preg_match( '/^Set-Cookie:\s*([^=]+)=/i', $header, $m ) ) {
|
||||
$names[] = trim( $m[1] );
|
||||
}
|
||||
}
|
||||
return $names;
|
||||
}
|
||||
|
||||
protected static function response_content_type() {
|
||||
foreach ( headers_list() as $header ) {
|
||||
if ( 0 === stripos( $header, 'Content-Type:' ) ) {
|
||||
return trim( substr( $header, strlen( 'Content-Type:' ) ) );
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
protected static function current_url() {
|
||||
$scheme = is_ssl() ? 'https' : 'http';
|
||||
$host = (string) ( $_SERVER['HTTP_HOST'] ?? 'default' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
$uri = (string) ( $_SERVER['REQUEST_URI'] ?? '/' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
return $scheme . '://' . $host . $uri;
|
||||
}
|
||||
|
||||
public static function cache_key( $url ) {
|
||||
$parts = wp_parse_url( $url );
|
||||
$path = $parts['path'] ?? '/';
|
||||
$query = array();
|
||||
if ( ! empty( $parts['query'] ) ) {
|
||||
parse_str( $parts['query'], $query );
|
||||
$query = array_diff_key( $query, array_flip( Argus_Cache_Eligibility::IGNORABLE_QUERY_PARAMS ) );
|
||||
ksort( $query );
|
||||
}
|
||||
$host = (string) ( $parts['host'] ?? 'default' );
|
||||
return md5( $host . '|' . $path . '|' . http_build_query( $query ) );
|
||||
}
|
||||
|
||||
protected static function cache_dir() {
|
||||
$uploads = wp_get_upload_dir();
|
||||
return trailingslashit( $uploads['basedir'] ) . 'argus-wpd-data/html-cache';
|
||||
}
|
||||
|
||||
public static function cache_paths( $key ) {
|
||||
$base = trailingslashit( self::cache_dir() ) . $key;
|
||||
return array(
|
||||
'html' => $base . '.html',
|
||||
'gz' => $base . '.html.gz',
|
||||
'br' => $base . '.html.br',
|
||||
'meta' => $base . '.meta.json',
|
||||
);
|
||||
}
|
||||
|
||||
protected static function lock_dir() {
|
||||
return trailingslashit( self::cache_dir() ) . 'locks';
|
||||
}
|
||||
|
||||
protected static function acquire_lock( $key ) {
|
||||
$dir = self::lock_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
wp_mkdir_p( $dir );
|
||||
}
|
||||
$path = trailingslashit( $dir ) . $key . '.lock';
|
||||
$fh = @fopen( $path, 'c' ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors
|
||||
if ( ! $fh ) {
|
||||
return false;
|
||||
}
|
||||
if ( ! flock( $fh, LOCK_EX | LOCK_NB ) ) {
|
||||
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
return false;
|
||||
}
|
||||
return $fh;
|
||||
}
|
||||
|
||||
protected static function release_lock( $fh ) {
|
||||
if ( $fh ) {
|
||||
flock( $fh, LOCK_UN );
|
||||
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
}
|
||||
|
||||
public static function purge_all() {
|
||||
$dir = self::cache_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return 0;
|
||||
}
|
||||
$count = 0;
|
||||
foreach ( array( '*.html', '*.html.gz', '*.html.br', '*.meta.json' ) as $glob ) {
|
||||
foreach ( glob( trailingslashit( $dir ) . $glob ) ?: array() as $file ) {
|
||||
@unlink( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
Argus_Cache_Log::record( self::STATUS_PURGED, '*', 'Purge everything.' );
|
||||
return $count;
|
||||
}
|
||||
|
||||
public static function purge_url( $url ) {
|
||||
$key = self::cache_key( $url );
|
||||
foreach ( self::cache_paths( $key ) as $path ) {
|
||||
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
}
|
||||
Argus_Cache_Log::record( self::STATUS_PURGED, $url, 'Purge single URL.' );
|
||||
}
|
||||
|
||||
public static function purge_pattern( $pattern ) {
|
||||
$dir = self::cache_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return 0;
|
||||
}
|
||||
$regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/i';
|
||||
$count = 0;
|
||||
foreach ( glob( trailingslashit( $dir ) . '*.meta.json' ) ?: array() as $meta_file ) {
|
||||
$meta = json_decode( (string) file_get_contents( $meta_file ), true ); // phpcs:ignore
|
||||
if ( ! is_array( $meta ) || empty( $meta['url'] ) || ! preg_match( $regex, $meta['url'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$key = basename( $meta_file, '.meta.json' );
|
||||
foreach ( self::cache_paths( $key ) as $path ) {
|
||||
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
}
|
||||
++$count;
|
||||
}
|
||||
Argus_Cache_Log::record( self::STATUS_PURGED, $pattern, 'Purge by pattern (' . $count . ' entries).' );
|
||||
return $count;
|
||||
}
|
||||
|
||||
public static function purge_related_to_post( $post_id ) {
|
||||
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
$permalink = get_permalink( $post_id );
|
||||
if ( $permalink ) {
|
||||
self::purge_url( $permalink );
|
||||
}
|
||||
self::purge_url( home_url( '/' ) );
|
||||
$page_for_posts = (int) get_option( 'page_for_posts' );
|
||||
if ( $page_for_posts ) {
|
||||
self::purge_url( get_permalink( $page_for_posts ) );
|
||||
}
|
||||
}
|
||||
|
||||
public static function purge_related_to_comment( $comment_id ) {
|
||||
$comment = get_comment( $comment_id );
|
||||
if ( $comment && $comment->comment_post_ID ) {
|
||||
self::purge_related_to_post( (int) $comment->comment_post_ID );
|
||||
}
|
||||
}
|
||||
|
||||
public static function stats() {
|
||||
$dir = self::cache_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return array( 'file_count' => 0, 'total_bytes' => 0 );
|
||||
}
|
||||
$files = glob( trailingslashit( $dir ) . '*.html' ) ?: array();
|
||||
$bytes = 0;
|
||||
foreach ( $files as $file ) {
|
||||
$bytes += filesize( $file );
|
||||
foreach ( array( '.gz', '.br' ) as $ext ) {
|
||||
$variant = $file . $ext;
|
||||
if ( file_exists( $variant ) ) {
|
||||
$bytes += filesize( $variant );
|
||||
}
|
||||
}
|
||||
}
|
||||
return array( 'file_count' => count( $files ), 'total_bytes' => $bytes );
|
||||
}
|
||||
|
||||
public static function compression_stats() {
|
||||
$dir = self::cache_dir();
|
||||
$out = array(
|
||||
'gzip' => array( 'files' => 0, 'avg_savings_pct' => 0.0, 'bytes_saved' => 0 ),
|
||||
'brotli' => array( 'files' => 0, 'avg_savings_pct' => 0.0, 'bytes_saved' => 0 ),
|
||||
'total_bytes_saved' => 0,
|
||||
);
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$files = glob( trailingslashit( $dir ) . '*.html' ) ?: array();
|
||||
$pct_sums = array( 'gzip' => 0.0, 'brotli' => 0.0 );
|
||||
|
||||
foreach ( $files as $file ) {
|
||||
$plain_size = filesize( $file );
|
||||
if ( ! $plain_size ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( array( 'gzip' => '.gz', 'brotli' => '.br' ) as $format => $ext ) {
|
||||
$variant = $file . $ext;
|
||||
if ( ! file_exists( $variant ) ) {
|
||||
continue;
|
||||
}
|
||||
$variant_size = filesize( $variant );
|
||||
if ( ! $variant_size || $variant_size >= $plain_size ) {
|
||||
continue;
|
||||
}
|
||||
$saved = $plain_size - $variant_size;
|
||||
++$out[ $format ]['files'];
|
||||
$out[ $format ]['bytes_saved'] += $saved;
|
||||
$out['total_bytes_saved'] += $saved;
|
||||
$pct_sums[ $format ] += ( $saved / $plain_size ) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( array( 'gzip', 'brotli' ) as $format ) {
|
||||
if ( $out[ $format ]['files'] > 0 ) {
|
||||
$out[ $format ]['avg_savings_pct'] = round( $pct_sums[ $format ] / $out[ $format ]['files'], 1 );
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function list_cached( $limit = 100 ) {
|
||||
$dir = self::cache_dir();
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return array();
|
||||
}
|
||||
$meta_files = glob( trailingslashit( $dir ) . '*.meta.json' ) ?: array();
|
||||
usort(
|
||||
$meta_files,
|
||||
function ( $a, $b ) {
|
||||
return filemtime( $b ) <=> filemtime( $a );
|
||||
}
|
||||
);
|
||||
|
||||
$out = array();
|
||||
foreach ( array_slice( $meta_files, 0, $limit ) as $meta_file ) {
|
||||
$meta = json_decode( (string) file_get_contents( $meta_file ), true ); // phpcs:ignore
|
||||
if ( ! is_array( $meta ) ) {
|
||||
continue;
|
||||
}
|
||||
$key = basename( $meta_file, '.meta.json' );
|
||||
$paths = self::cache_paths( $key );
|
||||
$out[] = array(
|
||||
'url' => $meta['url'] ?? '',
|
||||
'bytes' => (int) ( $meta['bytes'] ?? 0 ),
|
||||
'created_at' => $meta['created_at'] ?? '',
|
||||
'age_secs' => file_exists( $paths['html'] ) ? ( time() - filemtime( $paths['html'] ) ) : null,
|
||||
'has_gzip' => file_exists( $paths['gz'] ),
|
||||
'has_brotli' => file_exists( $paths['br'] ),
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Templates {
|
||||
|
||||
const ACTIVE_OPTION = 'argus_wpd_active_template';
|
||||
|
||||
public static function all() {
|
||||
return array(
|
||||
'personal' => array(
|
||||
'label' => __( 'Personal / Blog', 'argus-wordpress-defence' ),
|
||||
'description' => __( 'A personal site or small blog -- real protection without getting in the way of the one person who actually logs in.', 'argus-wordpress-defence' ),
|
||||
'settings' => array(
|
||||
'mode' => Argus_Settings::MODE_BLOCK,
|
||||
'waf_enabled' => true,
|
||||
'login_protection_enabled' => true,
|
||||
'xmlrpc_block_pingback' => true,
|
||||
'rest_api_protection_enabled' => true,
|
||||
'integrity_scan_enabled' => true,
|
||||
'malware_scan_enabled' => true,
|
||||
'vuln_intel_enabled' => true,
|
||||
'geoip_rir_enabled' => true,
|
||||
'challenge_enabled' => true,
|
||||
'login_attempt_threshold' => 5,
|
||||
'login_attempt_window_secs' => 600,
|
||||
'login_lockout_secs' => 900,
|
||||
'backup_interval_hours' => 168,
|
||||
'backup_retention_count' => 4,
|
||||
),
|
||||
),
|
||||
'business' => array(
|
||||
'label' => __( 'Business', 'argus-wordpress-defence' ),
|
||||
'description' => __( 'A company site with several editors/admins -- tighter login tolerance than a personal blog, everything else fully on.', 'argus-wordpress-defence' ),
|
||||
'settings' => array(
|
||||
'mode' => Argus_Settings::MODE_BLOCK,
|
||||
'waf_enabled' => true,
|
||||
'login_protection_enabled' => true,
|
||||
'xmlrpc_block_pingback' => true,
|
||||
'rest_api_protection_enabled' => true,
|
||||
'integrity_scan_enabled' => true,
|
||||
'malware_scan_enabled' => true,
|
||||
'vuln_intel_enabled' => true,
|
||||
'geoip_rir_enabled' => true,
|
||||
'challenge_enabled' => true,
|
||||
'login_attempt_threshold' => 4,
|
||||
'login_attempt_window_secs' => 600,
|
||||
'login_lockout_secs' => 1800,
|
||||
'backup_interval_hours' => 24,
|
||||
'backup_retention_count' => 7,
|
||||
),
|
||||
),
|
||||
'enterprise' => array(
|
||||
'label' => __( 'Enterprise', 'argus-wordpress-defence' ),
|
||||
'description' => __( 'A larger organization -- many admins/editors across more content and integrations, a bigger and more attractive attack surface than a single-team business site. STRICT mode, tight login tolerance.', 'argus-wordpress-defence' ),
|
||||
'settings' => array(
|
||||
'mode' => Argus_Settings::MODE_STRICT,
|
||||
'waf_enabled' => true,
|
||||
'login_protection_enabled' => true,
|
||||
'xmlrpc_block_pingback' => true,
|
||||
'rest_api_protection_enabled' => true,
|
||||
'integrity_scan_enabled' => true,
|
||||
'malware_scan_enabled' => true,
|
||||
'vuln_intel_enabled' => true,
|
||||
'geoip_rir_enabled' => true,
|
||||
'challenge_enabled' => true,
|
||||
'login_attempt_threshold' => 3,
|
||||
'login_attempt_window_secs' => 600,
|
||||
'login_lockout_secs' => 3600,
|
||||
'backup_interval_hours' => 24,
|
||||
'backup_retention_count' => 14,
|
||||
),
|
||||
),
|
||||
'ecommerce' => array(
|
||||
'label' => __( 'Online Store (WooCommerce, Easy Digital Downloads, or other)', 'argus-wordpress-defence' ),
|
||||
'description' => __( 'Real customer accounts, payment/order data, and a REST API third parties call -- the highest-value target of the four, so STRICT mode, the tightest login tolerance, and the longest lockout (payment fraud is a stronger incentive to keep retrying than most other attacks).', 'argus-wordpress-defence' ),
|
||||
'settings' => array(
|
||||
'mode' => Argus_Settings::MODE_STRICT,
|
||||
'waf_enabled' => true,
|
||||
'login_protection_enabled' => true,
|
||||
'xmlrpc_block_pingback' => true,
|
||||
'rest_api_protection_enabled' => true,
|
||||
'integrity_scan_enabled' => true,
|
||||
'malware_scan_enabled' => true,
|
||||
'vuln_intel_enabled' => true,
|
||||
'geoip_rir_enabled' => true,
|
||||
'challenge_enabled' => true,
|
||||
'login_attempt_threshold' => 3,
|
||||
'login_attempt_window_secs' => 600,
|
||||
'login_lockout_secs' => 7200,
|
||||
'backup_interval_hours' => 12,
|
||||
'backup_retention_count' => 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public static function recommended() {
|
||||
return self::is_ecommerce_site() ? 'ecommerce' : null;
|
||||
}
|
||||
|
||||
public static function is_ecommerce_site() {
|
||||
if ( class_exists( 'WooCommerce' ) ) {
|
||||
return true;
|
||||
}
|
||||
if ( function_exists( 'EDD' ) ) {
|
||||
return true;
|
||||
}
|
||||
return (bool) apply_filters( 'argus_wpd_is_ecommerce_site', false );
|
||||
}
|
||||
|
||||
public static function apply( $key ) {
|
||||
$templates = self::all();
|
||||
if ( ! isset( $templates[ $key ] ) ) {
|
||||
return false;
|
||||
}
|
||||
Argus_Settings::update( $templates[ $key ]['settings'] );
|
||||
update_option( self::ACTIVE_OPTION, $key, false );
|
||||
Argus_Events::record(
|
||||
'template_applied',
|
||||
'info',
|
||||
sprintf( 'Applied the "%s" protection template', $templates[ $key ]['label'] ),
|
||||
array( 'template' => $key )
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function active() {
|
||||
return get_option( self::ACTIVE_OPTION, '' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Update_Client {
|
||||
|
||||
const CHECK_INTERVAL_SECS = 6 * HOUR_IN_SECONDS;
|
||||
const LOCK_TRANSIENT = 'argus_wpd_update_check_lock';
|
||||
|
||||
const LAST_CHECK_OPTION = 'argus_wpd_update_last_check';
|
||||
const MANIFEST_ETAG_OPTION = 'argus_wpd_update_manifest_etag';
|
||||
const PENDING_MANIFEST_OPTION = 'argus_wpd_update_pending_manifest';
|
||||
const LAST_SUCCESS_OPTION = 'argus_wpd_update_last_success';
|
||||
const LAST_FAILURE_OPTION = 'argus_wpd_update_last_failure';
|
||||
|
||||
public static function manifest_url() {
|
||||
return defined( 'ARGUS_WPD_UPDATE_MANIFEST_URL' ) ? ARGUS_WPD_UPDATE_MANIFEST_URL : '';
|
||||
}
|
||||
|
||||
public static function public_key() {
|
||||
return defined( 'ARGUS_WPD_UPDATE_PUBLIC_KEY' ) ? ARGUS_WPD_UPDATE_PUBLIC_KEY : '';
|
||||
}
|
||||
|
||||
public static function is_configured() {
|
||||
return '' !== self::manifest_url() && '' !== self::public_key();
|
||||
}
|
||||
|
||||
public static function maybe_check() {
|
||||
if ( ! self::is_configured() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last_check = (int) get_option( self::LAST_CHECK_OPTION, 0 );
|
||||
if ( ( $last_check + self::CHECK_INTERVAL_SECS ) > time() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( false !== get_transient( self::LOCK_TRANSIENT ) ) {
|
||||
return;
|
||||
}
|
||||
set_transient( self::LOCK_TRANSIENT, 1, 4 * MINUTE_IN_SECONDS );
|
||||
|
||||
self::check_now();
|
||||
|
||||
delete_transient( self::LOCK_TRANSIENT );
|
||||
}
|
||||
|
||||
public static function check_now() {
|
||||
if ( ! self::is_configured() ) {
|
||||
return array( 'success' => false, 'message' => __( 'No update source is configured.', 'argus-wordpress-defence' ), 'update_available' => false );
|
||||
}
|
||||
|
||||
update_option( self::LAST_CHECK_OPTION, time(), false );
|
||||
|
||||
$args = array( 'timeout' => 15 );
|
||||
$etag = get_option( self::MANIFEST_ETAG_OPTION, '' );
|
||||
if ( $etag ) {
|
||||
$args['headers'] = array( 'If-None-Match' => $etag );
|
||||
}
|
||||
|
||||
$response = wp_remote_get( self::manifest_url(), $args );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return self::record_failure( __( 'Could not reach the update server.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 304 === $code ) {
|
||||
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => false );
|
||||
}
|
||||
if ( 200 !== $code ) {
|
||||
return self::record_failure( sprintf( 'Update server returned HTTP %d.', $code ) );
|
||||
}
|
||||
|
||||
$new_etag = wp_remote_retrieve_header( $response, 'etag' );
|
||||
if ( $new_etag ) {
|
||||
update_option( self::MANIFEST_ETAG_OPTION, $new_etag, false );
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$manifest = json_decode( $body, true );
|
||||
if ( ! is_array( $manifest ) ) {
|
||||
return self::record_failure( __( 'Update manifest was not valid JSON.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
return self::process_manifest( $manifest );
|
||||
}
|
||||
|
||||
public static function process_manifest( array $manifest ) {
|
||||
|
||||
if ( ! self::verify_signature( $manifest ) ) {
|
||||
return self::record_failure( __( 'Update manifest failed signature verification.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$version = $manifest['version'] ?? '';
|
||||
if ( '' === $version ) {
|
||||
return self::record_failure( __( 'Update manifest is missing a version.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
if ( version_compare( $version, ARGUS_WPD_VERSION, '<=' ) ) {
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => false );
|
||||
}
|
||||
|
||||
$last_installed = get_option( self::LAST_SUCCESS_OPTION, array() );
|
||||
if ( ! empty( $last_installed['version'] ) && version_compare( $version, $last_installed['version'], '<=' ) ) {
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => false );
|
||||
}
|
||||
|
||||
global $wp_version;
|
||||
if ( ! empty( $manifest['min_php'] ) && version_compare( PHP_VERSION, $manifest['min_php'], '<' ) ) {
|
||||
return self::record_failure( sprintf( 'Update %s requires PHP %s or newer.', $version, $manifest['min_php'] ) );
|
||||
}
|
||||
if ( ! empty( $manifest['min_wp'] ) && version_compare( $wp_version, $manifest['min_wp'], '<' ) ) {
|
||||
return self::record_failure( sprintf( 'Update %s requires WordPress %s or newer.', $version, $manifest['min_wp'] ) );
|
||||
}
|
||||
|
||||
update_option( self::PENDING_MANIFEST_OPTION, $manifest, false );
|
||||
|
||||
if ( ! empty( $manifest['critical'] ) ) {
|
||||
|
||||
return self::install_update( $manifest );
|
||||
}
|
||||
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => true );
|
||||
}
|
||||
|
||||
public static function verify_signature( array $manifest ) {
|
||||
if ( empty( $manifest['signature'] ) ) {
|
||||
return false;
|
||||
}
|
||||
$signature = base64_decode( $manifest['signature'], true );
|
||||
if ( false === $signature || SODIUM_CRYPTO_SIGN_BYTES !== strlen( $signature ) ) {
|
||||
return false;
|
||||
}
|
||||
$public_key_b64 = self::public_key();
|
||||
if ( '' === $public_key_b64 ) {
|
||||
return false;
|
||||
}
|
||||
$public_key = base64_decode( $public_key_b64, true );
|
||||
if ( false === $public_key ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$canonical = self::canonical_payload( $manifest );
|
||||
return sodium_crypto_sign_verify_detached( $signature, $canonical, $public_key );
|
||||
}
|
||||
|
||||
protected static function canonical_payload( array $manifest ) {
|
||||
$ordered = array();
|
||||
foreach ( array( 'version', 'released_at', 'package_url', 'sha256', 'min_php', 'min_wp', 'critical' ) as $key ) {
|
||||
$ordered[ $key ] = $manifest[ $key ] ?? null;
|
||||
}
|
||||
return wp_json_encode( $ordered, JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
|
||||
public static function install_update( array $manifest, $target_dir = null ) {
|
||||
$target_dir = $target_dir ?: ( WP_PLUGIN_DIR . '/argus-wordpress-defence' );
|
||||
$version = $manifest['version'];
|
||||
|
||||
$upgrade_dir = trailingslashit( wp_get_upload_dir()['basedir'] ) . 'argus-wpd-data/update-staging';
|
||||
if ( ! is_dir( $upgrade_dir ) && ! wp_mkdir_p( $upgrade_dir ) ) {
|
||||
return self::record_failure( __( 'Could not prepare the update staging directory.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$package_path = trailingslashit( $upgrade_dir ) . 'package-' . $version . '-' . bin2hex( random_bytes( 4 ) ) . '.zip';
|
||||
$response = wp_remote_get( $manifest['package_url'], array( 'timeout' => 120, 'stream' => true, 'filename' => $package_path ) );
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
@unlink( $package_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
||||
return self::record_failure( __( 'Could not download the update package.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
if ( ! hash_equals( $manifest['sha256'], hash_file( 'sha256', $package_path ) ) ) {
|
||||
wp_delete_file( $package_path );
|
||||
return self::record_failure( __( 'Downloaded package hash did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$extract_root = trailingslashit( $upgrade_dir ) . 'extract-' . $version . '-' . bin2hex( random_bytes( 4 ) );
|
||||
if ( ! class_exists( 'ZipArchive' ) ) {
|
||||
wp_delete_file( $package_path );
|
||||
return self::record_failure( __( 'The PHP zip extension is not available -- cannot install the update.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $package_path ) || true !== $zip->extractTo( $extract_root ) ) {
|
||||
wp_delete_file( $package_path );
|
||||
return self::record_failure( __( 'Could not extract the update package.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
$zip->close();
|
||||
wp_delete_file( $package_path );
|
||||
|
||||
$extracted_plugin_dir = trailingslashit( $extract_root ) . 'argus-wordpress-defence';
|
||||
$new_main_file = trailingslashit( $extracted_plugin_dir ) . 'argus-wordpress-defence.php';
|
||||
if ( ! file_exists( $new_main_file ) ) {
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Extracted package did not contain the expected plugin file.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$header_contents = file_get_contents( $new_main_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( ! preg_match( '/Version:\s*([^\r\n]+)/', $header_contents, $m ) || trim( $m[1] ) !== $version ) {
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Extracted package version did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$backup_dir = $target_dir . '-previous-' . time();
|
||||
$had_previous = is_dir( $target_dir );
|
||||
if ( $had_previous && ! rename( $target_dir, $backup_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Could not move the current plugin version aside for the update.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
if ( ! rename( $extracted_plugin_dir, $target_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
if ( $had_previous ) {
|
||||
rename( $backup_dir, $target_dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Could not activate the downloaded update -- rolled back to the previous version.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
self::rrmdir( $extract_root );
|
||||
if ( $had_previous ) {
|
||||
self::rrmdir( $backup_dir );
|
||||
}
|
||||
|
||||
delete_option( self::PENDING_MANIFEST_OPTION );
|
||||
update_option(
|
||||
self::LAST_SUCCESS_OPTION,
|
||||
array( 'version' => $version, 'installed_at' => current_time( 'mysql', true ) ),
|
||||
false
|
||||
);
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => ! empty( $manifest['critical'] )
|
||||
? sprintf( __( 'A critical ARGUS security update was installed (%s).', 'argus-wordpress-defence' ), $version )
|
||||
: sprintf( __( 'ARGUS Defence was updated to version %s.', 'argus-wordpress-defence' ), $version ),
|
||||
'update_available' => false,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function record_failure( $message ) {
|
||||
update_option(
|
||||
self::LAST_FAILURE_OPTION,
|
||||
array( 'message' => $message, 'at' => current_time( 'mysql', true ) ),
|
||||
false
|
||||
);
|
||||
Argus_Events::record( 'update_check_failed', 'medium', $message, array() );
|
||||
return array( 'success' => false, 'message' => $message, 'update_available' => false );
|
||||
}
|
||||
|
||||
protected static function rrmdir( $dir ) {
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return;
|
||||
}
|
||||
$items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST );
|
||||
foreach ( $items as $item ) {
|
||||
$item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
return array(
|
||||
'current_version' => ARGUS_WPD_VERSION,
|
||||
'configured' => self::is_configured(),
|
||||
'last_check' => get_option( self::LAST_CHECK_OPTION, 0 ),
|
||||
'last_success' => get_option( self::LAST_SUCCESS_OPTION, array() ),
|
||||
'last_failure' => get_option( self::LAST_FAILURE_OPTION, array() ),
|
||||
'pending' => get_option( self::PENDING_MANIFEST_OPTION, array() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Upload_Guard {
|
||||
|
||||
const EXECUTABLE_PATTERN = '/\.ph(p[3457]?|t|tml)/i';
|
||||
|
||||
const SCAN_CHUNK_BYTES = 65536;
|
||||
const MAX_FILE_SIZE_TO_SCAN = 20971520;
|
||||
|
||||
public static function init() {
|
||||
if ( ! Argus_Settings::get( 'upload_scan_enabled', true ) ) {
|
||||
return;
|
||||
}
|
||||
add_filter( 'wp_handle_upload_prefilter', array( __CLASS__, 'inspect' ) );
|
||||
}
|
||||
|
||||
public static function inspect( $file ) {
|
||||
if ( ! empty( $file['error'] ) ) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$name = (string) ( $file['name'] ?? '' );
|
||||
|
||||
if ( preg_match( self::EXECUTABLE_PATTERN, $name ) ) {
|
||||
$file['error'] = __( 'For your website\'s security, ARGUS Defence blocked this upload: executable file types are not allowed.', 'argus-wordpress-defence' );
|
||||
self::record_block( $name, 'executable-extension', 'critical' );
|
||||
return $file;
|
||||
}
|
||||
|
||||
$tmp = (string) ( $file['tmp_name'] ?? '' );
|
||||
if ( '' === $tmp || ! is_readable( $tmp ) ) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$size = filesize( $tmp );
|
||||
if ( false === $size || $size <= 0 || $size > self::MAX_FILE_SIZE_TO_SCAN ) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$content = self::read_head_and_tail( $tmp, $size );
|
||||
list( $score, $matched ) = Argus_Malware_Scanner::score_content( $content );
|
||||
|
||||
if ( $score >= Argus_Malware_Scanner::THRESHOLD_HIGH ) {
|
||||
$file['error'] = __( 'For your website\'s security, ARGUS Defence blocked this upload: its content matched known malicious patterns.', 'argus-wordpress-defence' );
|
||||
self::record_block( $name, 'content-heuristic', 'critical', $score, $matched );
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
protected static function read_head_and_tail( $path, $size ) {
|
||||
$fh = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( ! $fh ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$head = fread( $fh, min( self::SCAN_CHUNK_BYTES, $size ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
$tail = '';
|
||||
if ( $size > self::SCAN_CHUNK_BYTES * 2 ) {
|
||||
fseek( $fh, -self::SCAN_CHUNK_BYTES, SEEK_END );
|
||||
$tail = fread( $fh, self::SCAN_CHUNK_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
} elseif ( $size > self::SCAN_CHUNK_BYTES ) {
|
||||
$tail = fread( $fh, $size - self::SCAN_CHUNK_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
|
||||
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
return $head . $tail;
|
||||
}
|
||||
|
||||
protected static function record_block( $filename, $rule, $severity, $score = null, array $matched = array() ) {
|
||||
$user = wp_get_current_user();
|
||||
$who = $user && $user->exists() ? $user->user_login : 'unknown';
|
||||
|
||||
Argus_Findings::record(
|
||||
'malware',
|
||||
$severity,
|
||||
array(
|
||||
'what_happened' => sprintf( 'Blocked a malicious file upload before it reached your website: %s', $filename ),
|
||||
'why_it_matters' => 'A file matching this pattern could execute code on your server if it had been allowed through.',
|
||||
'what_argus_found' => 'executable-extension' === $rule
|
||||
? sprintf( 'The filename "%s" has a PHP-executable extension, which is never allowed as a media upload.', $filename )
|
||||
: sprintf( 'The file content scored %d on ARGUS\'s heuristic scanner (matched: %s), consistent with a webshell or obfuscated payload.', (int) $score, implode( ', ', wp_list_pluck( $matched, 'id' ) ) ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'Legitimate media uploads (images, documents, video) never contain executable code or PHP-executable extensions.',
|
||||
'what_could_be_affected' => 'If this upload had succeeded, it could have given an attacker code execution on your server.',
|
||||
'what_should_you_do' => sprintf( 'No action needed -- ARGUS already blocked this upload before it reached your website. Uploaded by: %s.', $who ),
|
||||
),
|
||||
array( 'filename' => $filename, 'rule' => $rule, 'score' => $score, 'matched_rules' => $matched, 'uploaded_by' => $who )
|
||||
);
|
||||
|
||||
Argus_Events::record(
|
||||
'upload_blocked',
|
||||
$severity,
|
||||
sprintf( 'Blocked upload "%s" (%s)', $filename, $rule ),
|
||||
array( 'filename' => $filename, 'rule' => $rule, 'uploaded_by' => $who )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Vuln_Intel {
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_CLEAN = 'clean';
|
||||
const STATUS_VULNERABLE = 'vulnerable';
|
||||
const STATUS_UNAVAILABLE = 'unavailable';
|
||||
|
||||
const ACTION_UPDATE = 'update';
|
||||
const ACTION_IGNORE = 'ignore';
|
||||
const ACTION_DEFER = 'defer';
|
||||
const ACTION_RISK_ACCEPTANCE = 'risk_acceptance';
|
||||
|
||||
const CHECK_INTERVAL_SECS = HOUR_IN_SECONDS;
|
||||
|
||||
const UNAVAILABLE_AFTER_SECS = 3 * HOUR_IN_SECONDS;
|
||||
|
||||
const RETRY_DELAYS_MINUTES = array( 5, 5, 15, 30 );
|
||||
|
||||
const LAST_SUCCESS_OPTION = 'argus_wpd_vuln_last_success';
|
||||
const RETRY_STAGE_OPTION = 'argus_wpd_vuln_retry_stage';
|
||||
const NEXT_RETRY_AT_OPTION = 'argus_wpd_vuln_next_retry_at';
|
||||
|
||||
public static function init() {
|
||||
if ( class_exists( 'Argus_Integrity' ) ) {
|
||||
|
||||
add_action( 'upgrader_process_complete', array( __CLASS__, 'on_upgrader_complete' ), 10, 2 );
|
||||
}
|
||||
}
|
||||
|
||||
public static function on_upgrader_complete( $upgrader, $data ) {
|
||||
self::resolve_inventory();
|
||||
}
|
||||
|
||||
public static function scheduled_check() {
|
||||
$success = self::check_core_currency();
|
||||
self::resolve_inventory();
|
||||
|
||||
if ( $success ) {
|
||||
self::clear_retry_ladder();
|
||||
} else {
|
||||
self::start_retry_ladder();
|
||||
}
|
||||
}
|
||||
|
||||
public static function maybe_retry() {
|
||||
$stage = get_option( self::RETRY_STAGE_OPTION, null );
|
||||
if ( null === $stage ) {
|
||||
return;
|
||||
}
|
||||
if ( time() < (int) get_option( self::NEXT_RETRY_AT_OPTION, 0 ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$success = self::check_core_currency();
|
||||
if ( $success ) {
|
||||
self::clear_retry_ladder();
|
||||
return;
|
||||
}
|
||||
|
||||
$next_stage = (int) $stage + 1;
|
||||
if ( ! isset( self::RETRY_DELAYS_MINUTES[ $next_stage ] ) ) {
|
||||
self::clear_retry_ladder();
|
||||
return;
|
||||
}
|
||||
update_option( self::RETRY_STAGE_OPTION, $next_stage, false );
|
||||
update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[ $next_stage ] * MINUTE_IN_SECONDS ), false );
|
||||
}
|
||||
|
||||
protected static function start_retry_ladder() {
|
||||
update_option( self::RETRY_STAGE_OPTION, 0, false );
|
||||
update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[0] * MINUTE_IN_SECONDS ), false );
|
||||
}
|
||||
|
||||
protected static function clear_retry_ladder() {
|
||||
delete_option( self::RETRY_STAGE_OPTION );
|
||||
delete_option( self::NEXT_RETRY_AT_OPTION );
|
||||
}
|
||||
|
||||
protected static function check_core_currency() {
|
||||
if ( ! function_exists( 'get_core_updates' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/update.php';
|
||||
}
|
||||
|
||||
$ping = wp_remote_get( 'https://api.wordpress.org/core/version-check/1.7/', array( 'timeout' => 15 ) );
|
||||
if ( is_wp_error( $ping ) || (int) wp_remote_retrieve_response_code( $ping ) >= 500 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
wp_version_check();
|
||||
$updates = get_core_updates();
|
||||
|
||||
global $wp_version;
|
||||
self::upsert_status( 'core', 'core', 'WordPress Core', $wp_version, self::STATUS_CLEAN );
|
||||
|
||||
update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false );
|
||||
|
||||
if ( empty( $updates ) || ! is_array( $updates ) || 'upgrade' !== ( $updates[0]->response ?? '' ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$update = $updates[0];
|
||||
Argus_Findings::record(
|
||||
'vulnerability',
|
||||
'medium',
|
||||
array(
|
||||
'what_happened' => sprintf( 'WordPress core is out of date: running %s, %s is available', $wp_version, $update->version ),
|
||||
'why_it_matters' => 'Older WordPress core releases can be missing security fixes, even when no specific vulnerability is separately confirmed for this exact version.',
|
||||
'what_argus_found' => sprintf( 'Checked against WordPress.org\'s own update API. Current: %s. Available: %s.', $wp_version, $update->version ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
|
||||
'why_suspicious' => 'This is a currency check, not a confirmed exploit -- ARGUS flags any available core update for visibility, not only ones known to fix a specific CVE.',
|
||||
'what_could_be_affected' => 'The entire site, since WordPress core underlies every plugin and theme.',
|
||||
'what_should_you_do' => 'Review the WordPress release notes for ' . $update->version . ' and update when convenient. If you have a reason to defer (compatibility testing, staging validation), that is a reasonable choice -- just don\'t defer indefinitely.',
|
||||
),
|
||||
array(
|
||||
'current_version' => $wp_version,
|
||||
'available_version' => $update->version,
|
||||
'action' => array(
|
||||
'type' => 'update_core',
|
||||
'label' => sprintf( __( 'Update to %s', 'argus-wordpress-defence' ), $update->version ),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function inventory() {
|
||||
global $wp_version;
|
||||
$items = array( array( 'type' => 'core', 'slug' => 'core', 'name' => 'WordPress Core', 'version' => $wp_version ) );
|
||||
|
||||
if ( ! function_exists( 'get_plugins' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
foreach ( get_plugins() as $plugin_file => $plugin_data ) {
|
||||
$items[] = array(
|
||||
'type' => 'plugin',
|
||||
'slug' => self::slug_from_plugin_file( $plugin_file ),
|
||||
'name' => $plugin_data['Name'] ?? $plugin_file,
|
||||
'version' => $plugin_data['Version'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( wp_get_themes() as $stylesheet => $theme ) {
|
||||
$items[] = array(
|
||||
'type' => 'theme',
|
||||
'slug' => $stylesheet,
|
||||
'name' => $theme->get( 'Name' ) ?: $stylesheet,
|
||||
'version' => $theme->get( 'Version' ) ?: '',
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function resolve_inventory() {
|
||||
global $wpdb;
|
||||
$vuln_cache_total = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'vuln_cache' ) ); // phpcs:ignore
|
||||
|
||||
foreach ( self::inventory() as $item ) {
|
||||
if ( 'core' === $item['type'] ) {
|
||||
continue;
|
||||
}
|
||||
self::resolve_component( $item['type'], $item['slug'], $item['name'], $item['version'], $vuln_cache_total );
|
||||
}
|
||||
|
||||
self::prune_removed_components();
|
||||
}
|
||||
|
||||
protected static function resolve_component( $type, $slug, $name, $version, $vuln_cache_total ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( 0 === $vuln_cache_total ) {
|
||||
|
||||
self::upsert_status( $type, $slug, $name, $version, self::STATUS_PENDING );
|
||||
return;
|
||||
}
|
||||
|
||||
$table = Argus_DB::table( 'vuln_cache' );
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE component_type = %s AND slug = %s", $type, $slug ) // phpcs:ignore
|
||||
);
|
||||
|
||||
$flagged = null;
|
||||
foreach ( $rows as $row ) {
|
||||
if ( '' === $version || ! $row->vulnerable_below ) {
|
||||
continue;
|
||||
}
|
||||
if ( version_compare( $version, $row->vulnerable_below, '<' ) ) {
|
||||
$flagged = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $flagged ) {
|
||||
self::upsert_status( $type, $slug, $name, $version, self::STATUS_VULNERABLE, $flagged );
|
||||
self::record_vulnerability_finding( $type, $slug, $name, $version, $flagged );
|
||||
return;
|
||||
}
|
||||
|
||||
self::upsert_status( $type, $slug, $name, $version, self::STATUS_CLEAN );
|
||||
}
|
||||
|
||||
protected static function upsert_status( $type, $slug, $name, $version, $status, $vuln_row = null ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'vuln_status' );
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
$data = array(
|
||||
'component_type' => $type,
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'installed_version' => $version,
|
||||
'checked_version' => $version,
|
||||
'status' => $status,
|
||||
'severity' => $vuln_row->severity ?? null,
|
||||
'cve' => $vuln_row->cve ?? null,
|
||||
'fixed_in' => $vuln_row->fixed_in ?? null,
|
||||
'description' => $vuln_row->description ?? null,
|
||||
'last_checked_at' => $now,
|
||||
'updated_at' => $now,
|
||||
);
|
||||
|
||||
$existing_id = $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT id FROM {$table} WHERE component_type = %s AND slug = %s", $type, $slug ) // phpcs:ignore
|
||||
);
|
||||
if ( $existing_id ) {
|
||||
$wpdb->update( $table, $data, array( 'id' => $existing_id ) );
|
||||
} else {
|
||||
$wpdb->insert( $table, $data );
|
||||
}
|
||||
}
|
||||
|
||||
protected static function prune_removed_components() {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'vuln_status' );
|
||||
|
||||
$current = array();
|
||||
foreach ( self::inventory() as $item ) {
|
||||
$current[] = $item['type'] . '|' . $item['slug'];
|
||||
}
|
||||
|
||||
$existing = $wpdb->get_results( "SELECT id, component_type, slug FROM {$table}" ); // phpcs:ignore
|
||||
foreach ( $existing as $row ) {
|
||||
if ( ! in_array( $row->component_type . '|' . $row->slug, $current, true ) ) {
|
||||
$wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static function record_vulnerability_finding( $type, $slug, $name, $version, $vuln_row ) {
|
||||
Argus_Findings::record(
|
||||
'vulnerability',
|
||||
$vuln_row->severity,
|
||||
array(
|
||||
'what_happened' => sprintf( '%s "%s" (v%s) has a known vulnerability%s', ucfirst( $type ), $name, $version, $vuln_row->cve ? ' (' . $vuln_row->cve . ')' : '' ),
|
||||
'why_it_matters' => $vuln_row->description ?: 'This version is affected by a publicly documented vulnerability.',
|
||||
'what_argus_found' => sprintf( 'Installed: %s. Vulnerable below: %s. Fixed in: %s.', $version, $vuln_row->vulnerable_below, $vuln_row->fixed_in ?: 'unknown' ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'This is a version-matching result against ARGUS\'s vulnerability intelligence, not a detected exploitation attempt.',
|
||||
'what_could_be_affected' => 'Depends on the specific vulnerability -- see the description above.',
|
||||
'what_should_you_do' => $vuln_row->fixed_in
|
||||
? sprintf( 'Update to version %s or later.', $vuln_row->fixed_in )
|
||||
: 'No fixed version is currently known -- consider deactivating this component until one is available, or accept the risk deliberately if it\'s not exposed.',
|
||||
),
|
||||
array(
|
||||
'component_type' => $type,
|
||||
'slug' => $slug,
|
||||
'installed_version' => $version,
|
||||
'cve' => $vuln_row->cve,
|
||||
'action' => $vuln_row->fixed_in ? array(
|
||||
'type' => 'plugin' === $type ? 'update_plugin' : 'update_theme',
|
||||
'label' => sprintf( __( 'Update to %s', 'argus-wordpress-defence' ), $vuln_row->fixed_in ),
|
||||
'plugin' => 'plugin' === $type ? self::plugin_file_for_slug( $slug ) : null,
|
||||
'theme' => 'theme' === $type ? $slug : null,
|
||||
) : null,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected static function plugin_file_for_slug( $slug ) {
|
||||
if ( ! function_exists( 'get_plugins' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
foreach ( array_keys( get_plugins() ) as $plugin_file ) {
|
||||
if ( self::slug_from_plugin_file( $plugin_file ) === $slug ) {
|
||||
return $plugin_file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function import_feed( array $records ) {
|
||||
global $wpdb;
|
||||
$table = Argus_DB::table( 'vuln_cache' );
|
||||
$now = current_time( 'mysql', true );
|
||||
$count = 0;
|
||||
|
||||
foreach ( $records as $r ) {
|
||||
if ( empty( $r['component_type'] ) || empty( $r['slug'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE component_type = %s AND slug = %s AND cve <=> %s", // phpcs:ignore
|
||||
$r['component_type'],
|
||||
$r['slug'],
|
||||
$r['cve'] ?? null
|
||||
)
|
||||
);
|
||||
|
||||
$data = array(
|
||||
'component_type' => $r['component_type'],
|
||||
'slug' => $r['slug'],
|
||||
'vulnerable_below' => $r['vulnerable_below'] ?? null,
|
||||
'fixed_in' => $r['fixed_in'] ?? null,
|
||||
'severity' => $r['severity'] ?? 'medium',
|
||||
'cve' => $r['cve'] ?? null,
|
||||
'description' => $r['description'] ?? '',
|
||||
'updated_at' => $now,
|
||||
);
|
||||
|
||||
if ( $existing_id ) {
|
||||
$wpdb->update( $table, $data, array( 'id' => $existing_id ) );
|
||||
} else {
|
||||
$wpdb->insert( $table, $data );
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ( $count > 0 ) {
|
||||
self::resolve_inventory();
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
protected static function slug_from_plugin_file( $plugin_file ) {
|
||||
$parts = explode( '/', $plugin_file );
|
||||
return $parts[0] ?? $plugin_file;
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
$last_success = get_option( self::LAST_SUCCESS_OPTION, '' );
|
||||
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results( 'SELECT * FROM ' . Argus_DB::table( 'vuln_status' ) . ' ORDER BY component_type = "core" DESC, name ASC' ); // phpcs:ignore
|
||||
|
||||
$components = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$status = $row->status;
|
||||
|
||||
if ( 'core' === $row->component_type && $last_success && strtotime( $last_success . ' UTC' ) < ( time() - self::UNAVAILABLE_AFTER_SECS ) ) {
|
||||
$status = self::STATUS_UNAVAILABLE;
|
||||
}
|
||||
$components[] = array(
|
||||
'type' => $row->component_type,
|
||||
'slug' => $row->slug,
|
||||
'name' => $row->name,
|
||||
'installed_version' => $row->installed_version,
|
||||
'status' => $status,
|
||||
'severity' => $row->severity,
|
||||
'cve' => $row->cve,
|
||||
'fixed_in' => $row->fixed_in,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
|
||||
'protection' => $last_success ? 'active' : 'starting',
|
||||
'last_check' => $last_success,
|
||||
'next_check' => $last_success ? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + self::CHECK_INTERVAL_SECS ) : '',
|
||||
'components' => $components,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
if ( ! class_exists( 'Argus_WAF_Rules' ) ) {
|
||||
|
||||
class Argus_WAF_Rules {
|
||||
|
||||
public static function corpus() {
|
||||
static $rules = null;
|
||||
if ( null !== $rules ) {
|
||||
return $rules;
|
||||
}
|
||||
|
||||
$rules = array(
|
||||
|
||||
array( 'id' => 'sqli-union-select', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
'pattern' => '/\bunion\b[^\w]{1,20}\bselect\b/i' ),
|
||||
array( 'id' => 'sqli-classic-tautology', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
|
||||
'pattern' => '/(\%27|\'|\%22|")\s*(or|and)\s*(\%27|\'|\%22|")?[\d\w]+(\%27|\'|\%22|")?\s*=\s*(\%27|\'|\%22|")?[\d\w]+(\%27|\'|\%22|")?/i' ),
|
||||
array( 'id' => 'sqli-information-schema', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
'pattern' => '/information_schema|sysobjects|sysdatabases/i' ),
|
||||
array( 'id' => 'sqli-time-based', 'category' => 'sql_injection', 'severity' => 'high',
|
||||
'pattern' => '/\b(sleep|benchmark|pg_sleep|waitfor\s+delay)\s*\(/i' ),
|
||||
array( 'id' => 'sqli-stacked-comment', 'category' => 'sql_injection', 'severity' => 'high',
|
||||
'pattern' => '/;\s*(drop|delete|update|insert)\s+(table|from|into)/i' ),
|
||||
|
||||
array( 'id' => 'xss-script-tag', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/<\s*script[\s>\/]/i' ),
|
||||
|
||||
array( 'id' => 'xss-event-handler', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/\bon(error|load|mouseover|click|focus)\s*=\s*["\']?[^"\'>]*[\(\{]/i' ),
|
||||
array( 'id' => 'xss-javascript-uri', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/javascript\s*:\s*[^\s]/i' ),
|
||||
array( 'id' => 'xss-svg-onload', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/<\s*svg[^>]*onload/i' ),
|
||||
|
||||
array( 'id' => 'cmdi-shell-metachars', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/;\s*(cat|ls|whoami|id|uname|wget|curl)\s/i' ),
|
||||
array( 'id' => 'cmdi-backtick-subshell', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/`[^`]{1,80}`|\$\([^\)]{1,80}\)/' ),
|
||||
array( 'id' => 'phpi-eval-base64', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|str_rot13)\s*\(/i' ),
|
||||
array( 'id' => 'phpi-dangerous-function', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen)\s*\(/i' ),
|
||||
array( 'id' => 'phpi-tag-in-input', 'category' => 'rce', 'severity' => 'high',
|
||||
'pattern' => '/<\?php|<\?=/i' ),
|
||||
|
||||
array( 'id' => 'lfi-dot-dot-slash', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/(\.\.\/|\.\.\\\\|%2e%2e%2f|%252e%252e%252f)/i' ),
|
||||
array( 'id' => 'lfi-sensitive-file', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/\/etc\/(passwd|shadow|hosts)\b|wp-config\.php/i' ),
|
||||
array( 'id' => 'lfi-php-wrapper', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/php:\/\/(filter|input|data)/i' ),
|
||||
array( 'id' => 'rfi-remote-scheme', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/^(https?|ftp):\/\/.+\.(php|txt)(\?|$)/i' ),
|
||||
|
||||
array( 'id' => 'proto-null-byte', 'category' => 'protocol_anomaly', 'severity' => 'medium',
|
||||
'pattern' => '/%00/' ),
|
||||
array( 'id' => 'proto-double-encoding', 'category' => 'protocol_anomaly', 'severity' => 'low',
|
||||
'pattern' => '/%25(2e|2f|5c)/i' ),
|
||||
);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function scan( array $inputs ) {
|
||||
$hits = array();
|
||||
|
||||
foreach ( $inputs as $source => $value ) {
|
||||
if ( ! is_string( $value ) || '' === $value ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = rawurldecode( $value );
|
||||
|
||||
foreach ( self::corpus() as $rule ) {
|
||||
$matched = preg_match( $rule['pattern'], $decoded, $m ) ? $m : ( preg_match( $rule['pattern'], $value, $m ) ? $m : null );
|
||||
if ( null === $matched ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'rfi-remote-scheme' === $rule['id'] && ! self::is_cross_origin( $matched[0] ) ) {
|
||||
|
||||
continue;
|
||||
}
|
||||
$hits[] = array_merge(
|
||||
$rule,
|
||||
array(
|
||||
'source' => $source,
|
||||
'matched_value' => mb_substr( $value, 0, 200 ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $hits;
|
||||
}
|
||||
|
||||
protected static function is_cross_origin( $url ) {
|
||||
$target_host = strtolower( (string) parse_url( $url, PHP_URL_HOST ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
$own_host = strtolower( (string) ( $_SERVER['HTTP_HOST'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
|
||||
if ( '' === $target_host || '' === $own_host ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$own_host = preg_replace( '/:\d+$/', '', $own_host );
|
||||
return $target_host !== $own_host;
|
||||
}
|
||||
|
||||
public static function highest_severity( array $hits ) {
|
||||
$order = array( 'critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1, 'info' => 0 );
|
||||
$best = 'info';
|
||||
foreach ( $hits as $hit ) {
|
||||
if ( ( $order[ $hit['severity'] ] ?? 0 ) > ( $order[ $best ] ?? 0 ) ) {
|
||||
$best = $hit['severity'];
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_WAF {
|
||||
|
||||
protected static $already_ran = false;
|
||||
|
||||
public static function inspect_and_enforce() {
|
||||
|
||||
if ( self::$already_ran ) {
|
||||
return;
|
||||
}
|
||||
self::$already_ran = true;
|
||||
|
||||
if ( ! Argus_Settings::get( 'waf_enabled', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = Argus_Request_Inputs::client_ip();
|
||||
|
||||
if ( Argus_Challenge::maybe_handle_submission( $ip ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
|
||||
Argus_Policy_Engine::deny_already_banned( $ip );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( class_exists( 'Argus_ANIS_Client' ) && Argus_ANIS_Client::maybe_enforce( $ip ) ) {
|
||||
Argus_Policy_Engine::deny_already_banned( $ip );
|
||||
return;
|
||||
}
|
||||
|
||||
$inputs = Argus_Request_Inputs::collect();
|
||||
$hits = Argus_WAF_Rules::scan( $inputs );
|
||||
|
||||
if ( empty( $hits ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$decision = Argus_Policy_Engine::evaluate( $ip, 'waf_match', array( 'hits' => $hits ) );
|
||||
|
||||
self::record_finding( $ip, $hits, $decision );
|
||||
|
||||
Argus_Policy_Engine::enforce_decision( $ip, 'waf_match', $decision, array( 'hits' => $hits ) );
|
||||
}
|
||||
|
||||
protected static function record_finding( $ip, array $hits, array $decision ) {
|
||||
$severity = Argus_WAF_Rules::highest_severity( $hits );
|
||||
$categories = array_unique( wp_list_pluck( $hits, 'category' ) );
|
||||
|
||||
$rule_ids = array_values( array_unique( wp_list_pluck( $hits, 'id' ) ) );
|
||||
$sources = array_unique( wp_list_pluck( $hits, 'source' ) );
|
||||
|
||||
Argus_Findings::record(
|
||||
'waf',
|
||||
$severity,
|
||||
array(
|
||||
'what_happened' => sprintf( 'A request from %s matched %d local WAF rule(s): %s', $ip, count( $rule_ids ), implode( ', ', $rule_ids ) ),
|
||||
'why_it_matters' => 'This request contained a pattern associated with ' . implode( ', ', $categories ) . ', a common technique used to compromise WordPress sites.',
|
||||
'what_argus_found' => sprintf( 'Matched in: %s. Action taken: %s (%s).', implode( ', ', $sources ), $decision['action'], $decision['observation_only'] ? 'observed only, MONITOR mode' : 'enforced' ),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'The matched pattern is not something a normal WordPress visitor, editor, or REST API client would ever legitimately send.',
|
||||
'what_could_be_affected' => 'If successful, this class of request could read or modify site data, execute code, or access files outside what the request should be able to reach.',
|
||||
'what_should_you_do' => $decision['observation_only']
|
||||
? 'ARGUS is in MONITOR mode and did not block this request. Review recent WAF findings and switch to BLOCK mode once you are confident legitimate traffic is not being flagged.'
|
||||
: 'No action needed -- ARGUS already blocked this request. If you believe this was a false positive, add an exception for this rule or IP in Settings.',
|
||||
),
|
||||
array( 'ip' => $ip, 'hits' => $hits, 'decision' => $decision )
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user