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

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
}
}