Files
argus-wp-defence/includes/class-argus-console-stats.php
ARGUSandClaude Sonnet 5 35efd3e485 ARGUS WordPress Defence 7.23.0
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>
2026-08-09 19:08:02 +00:00

416 lines
12 KiB
PHP

<?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' ),
'xxe' => __( 'XML External Entity', 'argus-wordpress-defence' ),
'ssrf' => __( 'Server-Side Request Forgery', 'argus-wordpress-defence' ),
'session_fixation' => __( 'Session Fixation', 'argus-wordpress-defence' ),
'java_injection' => __( 'Java / JNDI Injection', 'argus-wordpress-defence' ),
'scanner_activity' => __( 'Scanner / Recon Activity', 'argus-wordpress-defence' ),
);
return $labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) );
}
}