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.
230 lines
6.6 KiB
PHP
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;
|
|
}
|
|
}
|