Files
argus-wp-defence/includes/class-argus-ban-engine.php
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

230 lines
6.6 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Argus_Ban_Engine {
const SOURCE_LOCAL_WAF = 'local_waf';
const SOURCE_BRUTE_FORCE = 'brute_force';
const SOURCE_POLICY_ENGINE = 'policy_engine';
const SOURCE_MANUAL = 'manual';
const SOURCE_ARGUS_CLOUD = 'argus_cloud_reputation';
const LEVEL_TEMPORARY = 'temporary';
const LEVEL_EXTENDED = 'extended';
const LEVEL_PERMANENT = 'permanent';
const CACHE_GROUP = 'argus_wpd_bans';
const CACHE_TTL = 30;
protected static $request_cache = array();
public static function ban( $ip, $source, $reason, array $evidence = array(), $level = self::LEVEL_TEMPORARY ) {
global $wpdb;
$now = current_time( 'mysql', true );
$ttl = self::ttl_for_level( $level );
$existing = self::active_ban( $ip );
if ( $existing ) {
$new_level = self::escalate( $existing->ban_level, $level );
$wpdb->update(
Argus_DB::table( 'bans' ),
array(
'source' => $source,
'reason' => $reason,
'evidence' => wp_json_encode( $evidence ),
'ban_level' => $new_level,
'expires_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + self::ttl_for_level( $new_level ) ) : null,
'recovery_eligible_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + self::ttl_for_level( $new_level ) ) : null,
),
array( 'id' => $existing->id ),
array( '%s', '%s', '%s', '%s', '%s', '%s' ),
array( '%d' )
);
$id = (int) $existing->id;
} else {
$wpdb->insert(
Argus_DB::table( 'bans' ),
array(
'ip' => $ip,
'source' => $source,
'reason' => $reason,
'evidence' => wp_json_encode( $evidence ),
'ban_level' => $level,
'created_at' => $now,
'expires_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + $ttl ) : null,
'recovery_eligible_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + $ttl ) : null,
),
array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
);
$id = (int) $wpdb->insert_id;
}
self::invalidate_cache( $ip );
Argus_Events::record(
'ban',
'high',
sprintf( 'Banned %s (%s)', $ip, $reason ),
array( 'ip' => $ip, 'source' => $source, 'reason' => $reason, 'evidence' => $evidence, 'level' => $level ),
$ip
);
do_action( 'argus_wpd_ip_banned', $ip, $source, $reason, $level );
return $id;
}
public static function is_banned( $ip ) {
if ( array_key_exists( $ip, self::$request_cache ) ) {
return self::$request_cache[ $ip ];
}
$cache_key = self::cache_key( $ip );
$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
if ( false !== $cached ) {
self::$request_cache[ $ip ] = (bool) $cached;
return self::$request_cache[ $ip ];
}
$result = null !== self::active_ban( $ip );
self::$request_cache[ $ip ] = $result;
wp_cache_set( $cache_key, $result ? 'yes' : 'no', self::CACHE_GROUP, self::CACHE_TTL );
return $result;
}
protected static function cache_key( $ip ) {
return 'is_banned_' . md5( $ip );
}
protected static function invalidate_cache( $ip ) {
unset( self::$request_cache[ $ip ] );
wp_cache_delete( self::cache_key( $ip ), self::CACHE_GROUP );
}
public static function active_ban( $ip ) {
global $wpdb;
$table = Argus_DB::table( 'bans' );
$now = current_time( 'mysql', true );
return $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE ip = %s AND lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC LIMIT 1", // phpcs:ignore
$ip,
$now
)
);
}
public static function lift( $ban_id ) {
global $wpdb;
$table = Argus_DB::table( 'bans' );
$ip = $wpdb->get_var( $wpdb->prepare( "SELECT ip FROM {$table} WHERE id = %d", $ban_id ) ); // phpcs:ignore
$result = $wpdb->update(
$table,
array( 'lifted_at' => current_time( 'mysql', true ) ),
array( 'id' => $ban_id ),
array( '%s' ),
array( '%d' )
);
if ( $ip ) {
self::invalidate_cache( $ip );
}
return $result;
}
public static function sweep_expired() {
global $wpdb;
$table = Argus_DB::table( 'bans' );
$now = current_time( 'mysql', true );
$expired = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, ip FROM {$table} WHERE lifted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= %s", // phpcs:ignore
$now
)
);
foreach ( $expired as $row ) {
self::lift( $row->id );
Argus_Events::record( 'ban_expired', 'info', sprintf( 'Ban on %s expired and was lifted', $row->ip ), array( 'ip' => $row->ip ), $row->ip );
}
return count( $expired );
}
public static function recent( $limit = 50 ) {
global $wpdb;
$table = Argus_DB::table( 'bans' );
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) ); // phpcs:ignore
foreach ( $rows as &$row ) {
$row->evidence = json_decode( $row->evidence, true );
}
return $rows;
}
const PER_PAGE = 10;
public static function paginated( $page = 1, $per_page = self::PER_PAGE ) {
global $wpdb;
$table = Argus_DB::table( 'bans' );
$page = max( 1, (int) $page );
$offset = ( $page - 1 ) * $per_page;
$now = current_time( 'mysql', true );
$total = (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s)", $now ) // phpcs:ignore
);
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore
$now,
$per_page,
$offset
)
);
foreach ( $rows as &$row ) {
$row->evidence = json_decode( $row->evidence, true );
}
return array(
'rows' => $rows,
'total' => $total,
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
'page' => $page,
);
}
public static function manual_ban( $ip, $reason ) {
return self::ban( $ip, self::SOURCE_MANUAL, $reason ?: 'Manually banned by administrator', array(), self::LEVEL_EXTENDED );
}
protected static function ttl_for_level( $level ) {
switch ( $level ) {
case self::LEVEL_TEMPORARY:
return 15 * MINUTE_IN_SECONDS;
case self::LEVEL_EXTENDED:
return DAY_IN_SECONDS;
case self::LEVEL_PERMANENT:
return 0;
default:
return 15 * MINUTE_IN_SECONDS;
}
}
protected static function escalate( $current, $incoming ) {
$rank = array( self::LEVEL_TEMPORARY => 1, self::LEVEL_EXTENDED => 2, self::LEVEL_PERMANENT => 3 );
return ( $rank[ $incoming ] ?? 1 ) > ( $rank[ $current ] ?? 1 ) ? $incoming : $current;
}
}