'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'; } }