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.
163 lines
5.8 KiB
PHP
163 lines
5.8 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Policy_Engine {
|
|
|
|
const ACTION_ALLOW = 'allow';
|
|
const ACTION_BLOCK = 'block';
|
|
const ACTION_CHALLENGE = 'challenge';
|
|
const ACTION_RATE_LIMIT = 'rate_limit';
|
|
|
|
public static function evaluate( $ip, $trigger, array $context = array() ) {
|
|
$mode = Argus_Settings::mode();
|
|
|
|
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
|
|
return self::decision( self::ACTION_BLOCK, 'active-ban', 'IP is currently banned', $mode, $trigger );
|
|
}
|
|
|
|
if ( Argus_Settings::has_exception( 'ip', $ip ) ) {
|
|
return self::decision( self::ACTION_ALLOW, 'exception-ip', 'IP is on the explicit exception list', $mode, $trigger );
|
|
}
|
|
|
|
switch ( $trigger ) {
|
|
case 'waf_match':
|
|
return self::evaluate_waf( $ip, $context, $mode );
|
|
case 'login_failure':
|
|
return self::evaluate_login( $ip, $context, $mode );
|
|
case 'xmlrpc_abuse':
|
|
case 'rest_abuse':
|
|
return self::decision( self::ACTION_BLOCK, $trigger, 'Abuse pattern on ' . $trigger, $mode, $trigger );
|
|
default:
|
|
return self::decision( self::ACTION_ALLOW, 'no-match', 'No policy rule matched', $mode, $trigger );
|
|
}
|
|
}
|
|
|
|
protected static function evaluate_waf( $ip, array $context, $mode ) {
|
|
$hits = $context['hits'] ?? array();
|
|
if ( empty( $hits ) ) {
|
|
return self::decision( self::ACTION_ALLOW, 'waf-no-hits', 'No WAF rule matched', $mode, 'waf_match' );
|
|
}
|
|
|
|
$severity = Argus_WAF_Rules::highest_severity( $hits );
|
|
$rule_ids = wp_list_pluck( $hits, 'id' );
|
|
|
|
if ( 'critical' === $severity || 'high' === $severity ) {
|
|
return self::decision( self::ACTION_BLOCK, implode( ',', $rule_ids ), 'WAF matched: ' . implode( ', ', $rule_ids ), $mode, 'waf_match' );
|
|
}
|
|
if ( Argus_Settings::is_strict() && 'medium' === $severity ) {
|
|
return self::decision( self::ACTION_CHALLENGE, implode( ',', $rule_ids ), 'WAF matched (strict mode): ' . implode( ', ', $rule_ids ), $mode, 'waf_match' );
|
|
}
|
|
|
|
return self::decision( self::ACTION_ALLOW, 'waf-low-severity', 'Only low-severity WAF signal', $mode, 'waf_match' );
|
|
}
|
|
|
|
protected static function evaluate_login( $ip, array $context, $mode ) {
|
|
$attempts = (int) ( $context['recent_failures'] ?? 0 );
|
|
$threshold = (int) Argus_Settings::get( 'login_attempt_threshold', 5 );
|
|
|
|
if ( $attempts >= $threshold ) {
|
|
return self::decision( self::ACTION_BLOCK, 'brute-force-threshold', sprintf( '%d failed login attempts in the tracked window', $attempts ), $mode, 'login_failure' );
|
|
}
|
|
if ( $attempts >= max( 1, (int) ceil( $threshold / 2 ) ) ) {
|
|
return self::decision( self::ACTION_CHALLENGE, 'login-elevated', sprintf( '%d failed login attempts -- challenging before further tries', $attempts ), $mode, 'login_failure' );
|
|
}
|
|
|
|
return self::decision( self::ACTION_ALLOW, 'login-under-threshold', 'Failure count below challenge threshold', $mode, 'login_failure' );
|
|
}
|
|
|
|
protected static function decision( $action, $rule_name, $reason, $mode, $trigger ) {
|
|
return array(
|
|
'action' => $action,
|
|
'rule_name' => $rule_name,
|
|
'reason' => $reason,
|
|
|
|
'observation_only' => Argus_Settings::MODE_MONITOR === $mode,
|
|
'mode' => $mode,
|
|
'trigger' => $trigger,
|
|
);
|
|
}
|
|
|
|
public static function evaluate_and_enforce( $ip, $trigger, array $context = array() ) {
|
|
$decision = self::evaluate( $ip, $trigger, $context );
|
|
self::enforce_decision( $ip, $trigger, $decision, $context );
|
|
return $decision;
|
|
}
|
|
|
|
public static function enforce_decision( $ip, $trigger, array $decision, array $context = array() ) {
|
|
global $wpdb;
|
|
$wpdb->insert(
|
|
Argus_DB::table( 'policy_decisions' ),
|
|
array(
|
|
'ip' => $ip,
|
|
'trigger_type' => $trigger,
|
|
'action' => $decision['action'],
|
|
'rule_name' => $decision['rule_name'],
|
|
'reason' => $decision['reason'],
|
|
'observation_only' => $decision['observation_only'] ? 1 : 0,
|
|
'mode' => $decision['mode'],
|
|
'created_at' => current_time( 'mysql', true ),
|
|
),
|
|
array( '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' )
|
|
);
|
|
|
|
if ( self::ACTION_ALLOW === $decision['action'] || $decision['observation_only'] ) {
|
|
if ( self::ACTION_ALLOW !== $decision['action'] ) {
|
|
Argus_Events::record( 'policy_observed', 'low', 'Would have ' . $decision['action'] . ': ' . $decision['reason'], $decision, $ip );
|
|
}
|
|
return;
|
|
}
|
|
|
|
self::enforce( $decision, $ip, $context );
|
|
}
|
|
|
|
public static function deny_already_banned( $ip ) {
|
|
self::deny_and_exit( $ip, 'IP is currently banned' );
|
|
}
|
|
|
|
protected static function enforce( array $decision, $ip, array $context ) {
|
|
switch ( $decision['action'] ) {
|
|
case self::ACTION_BLOCK:
|
|
Argus_Ban_Engine::ban(
|
|
$ip,
|
|
'waf_match' === $decision['trigger'] ? Argus_Ban_Engine::SOURCE_LOCAL_WAF : Argus_Ban_Engine::SOURCE_POLICY_ENGINE,
|
|
$decision['reason'],
|
|
array( 'rule_name' => $decision['rule_name'], 'context' => $context )
|
|
);
|
|
self::deny_and_exit( $ip, $decision['reason'] );
|
|
break;
|
|
|
|
case self::ACTION_CHALLENGE:
|
|
if ( ! Argus_Challenge::has_valid_pass( $ip ) ) {
|
|
Argus_Events::record( 'challenge_issued', 'medium', 'Challenge issued: ' . $decision['reason'], $decision, $ip );
|
|
Argus_Challenge::render_and_exit( $ip );
|
|
}
|
|
break;
|
|
|
|
case self::ACTION_RATE_LIMIT:
|
|
self::deny_and_exit( $ip, $decision['reason'], 429 );
|
|
break;
|
|
}
|
|
}
|
|
|
|
protected static function deny_and_exit( $ip, $reason, $status = 403 ) {
|
|
nocache_headers();
|
|
status_header( $status );
|
|
header( 'Content-Type: text/plain; charset=utf-8' );
|
|
echo 'Forbidden.';
|
|
exit;
|
|
}
|
|
|
|
const RETENTION_DAYS = 90;
|
|
|
|
public static function prune() {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'policy_decisions' );
|
|
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::RETENTION_DAYS * DAY_IN_SECONDS ) );
|
|
|
|
return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); // phpcs:ignore
|
|
}
|
|
}
|