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:
root
2026-08-09 13:40:16 +00:00
commit df0f2fccb8
73 changed files with 12302 additions and 0 deletions
+133
View File
@@ -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' ) );
}
}
+55
View File
@@ -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' );
}
}
}
+69
View File
@@ -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;
}
}
+585
View File
@@ -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';
}
}
+95
View File
@@ -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
)
);
}
}
+45
View File
@@ -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;">&#9679; ' . 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' ),
);
}
}
+31
View File
@@ -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;
}
}
}
}
+291
View File
@@ -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 );
}
}
+229
View File
@@ -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;
}
}
+199
View File
@@ -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 ) ),
);
}
}
+185
View File
@@ -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 );
}
}
+192
View File
@@ -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 );
}
}
+213
View File
@@ -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;
}
}
+146
View File
@@ -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&hellip;</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 );
}
}
+141
View File
@@ -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] )
);
}
}
+410
View File
@@ -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 ) );
}
}
+124
View File
@@ -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
);
}
}
+311
View File
@@ -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 );
}
}
+14
View File
@@ -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' );
}
}
+155
View File
@@ -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;
}
}
+54
View File
@@ -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();
}
}
+280
View File
@@ -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;
}
}
+381
View File
@@ -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;
}
}
+44
View File
@@ -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 '🌐';
}
}
+332
View File
@@ -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 ) );
}
}
+186
View File
@@ -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
)
);
}
}
+290
View File
@@ -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 ) ) )
);
}
}
+93
View File
@@ -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' );
}
}
+126
View File
@@ -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();
}
}
}
+162
View File
@@ -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
}
}
+348
View File
@@ -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;
}
}
+53
View File
@@ -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
}
}
}
+86
View File
@@ -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;
}
}
}
+52
View File
@@ -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,
);
}
}
+120
View File
@@ -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 ) );
}
}
+495
View File
@@ -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;
}
}
+133
View File
@@ -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, '' );
}
}
+272
View File
@@ -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() ),
);
}
}
+102
View File
@@ -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 )
);
}
}
+383
View File
@@ -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,
);
}
}
+122
View File
@@ -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;
}
}
}
+76
View File
@@ -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 )
);
}
}