Files
argus-wp-defence/includes/class-argus-anis-client.php
T
root df0f2fccb8 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.
2026-08-09 13:40:16 +00:00

586 lines
20 KiB
PHP

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