Real changes since 1.0.0, all live-verified before this release: - Firewall: rule corpus expanded 19 -> 43 rules, real OWASP-CRS-equivalent coverage (XXE, SSRF, session fixation, Log4Shell/JNDI, scanner-tool detection, deeper SQL injection/XSS/PHP-injection). - Fixed a real bug: a quarantined file's severity badge and its content analysis score could disagree with no explanation (e.g. a benign file showing CRITICAL next to Score 0); both are now derived consistently and shown together. - ARGUS now always keeps itself updated, and can optionally do the same for every other installed plugin and theme (Settings, on by default) -- uses WordPress's own native update system, nothing custom. - Global Threat Intelligence is now opt-in, not automatic -- a single click on its own page, with an honest, specific description of exactly what's shared (an IP address, a reason code, a confidence score, a country). Previously connected automatically on activation. - New first-run Welcome screen after activation: confirms what's already protecting the site, and surfaces the few real optional choices in one place. - Dashboard: running version now visible in the header; new "IPs Tracked" and "ANIS Protections" metrics. - Full WordPress.org Plugin Directory readiness audit performed against this codebase. Two real compliance issues found and fixed (see above: Global Threat Intelligence's default, and the self-update mechanism, which is excluded from this build entirely -- WordPress.org prohibits a plugin from using any update channel other than its own, even an inert one). This release is still self-distributed, not a WordPress.org submission -- that remains a future step. Verified before publishing: this exact ZIP was installed, activated (14 admin pages loaded clean, zero PHP errors/warnings), and uninstalled (zero leftover database tables or options) in a fresh, disposable WordPress + MySQL environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
589 lines
20 KiB
PHP
589 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', false ),
|
|
'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 ( ! (bool) Argus_Settings::get( 'anis_enabled', false ) ) {
|
|
return 'disabled';
|
|
}
|
|
if ( ! self::is_enabled() ) {
|
|
return 'not_connected';
|
|
}
|
|
if ( self::circuit_is_open() && self::is_connected() ) {
|
|
return 'limited';
|
|
}
|
|
return self::is_connected() ? 'active' : 'not_connected';
|
|
}
|
|
}
|