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>
416 lines
12 KiB
PHP
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 ) );
|
|
}
|
|
}
|