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.
411 lines
12 KiB
PHP
411 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' ),
|
|
);
|
|
return $labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) );
|
|
}
|
|
}
|