Files
argus-wp-defence/includes/class-argus-login-guard.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

187 lines
7.8 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Argus_Login_Guard {
public static function init() {
if ( ! Argus_Settings::get( 'login_protection_enabled', true ) ) {
return;
}
add_filter( 'authenticate', array( __CLASS__, 'pre_auth_check' ), 20, 1 );
add_action( 'wp_login_failed', array( __CLASS__, 'on_failure' ), 10, 2 );
add_action( 'wp_login', array( __CLASS__, 'on_success' ), 10, 2 );
}
public static function pre_auth_check( $user ) {
if ( empty( $_POST['log'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only check, no state change.
return $user;
}
$ip = Argus_Request_Inputs::client_ip();
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
Argus_Policy_Engine::deny_already_banned( $ip );
return $user;
}
$failures = self::recent_failures( $ip );
$decision = Argus_Policy_Engine::evaluate( $ip, 'login_failure', array( 'recent_failures' => $failures ) );
if ( in_array( $decision['action'], array( Argus_Policy_Engine::ACTION_BLOCK, Argus_Policy_Engine::ACTION_CHALLENGE ), true ) ) {
self::record_finding( $ip, (string) $_POST['log'], $failures, $decision ); // phpcs:ignore WordPress.Security.NonceVerification
}
Argus_Policy_Engine::enforce_decision( $ip, 'login_failure', $decision, array( 'recent_failures' => $failures ) );
return $user;
}
public static function on_failure( $username, $error = null ) {
global $wpdb;
$ip = Argus_Request_Inputs::client_ip();
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
Argus_Policy_Engine::deny_already_banned( $ip );
return;
}
$wpdb->insert(
Argus_DB::table( 'login_attempts' ),
array(
'ip' => $ip,
'username' => mb_substr( (string) $username, 0, 60 ),
'success' => 0,
'created_at' => current_time( 'mysql', true ),
),
array( '%s', '%s', '%d', '%s' )
);
$failures = self::recent_failures( $ip );
Argus_Events::record( 'login_failed', $failures >= Argus_Settings::get( 'login_attempt_threshold', 5 ) ? 'high' : 'medium', sprintf( 'Failed login for "%s" from %s (%d recent failures)', $username, $ip, $failures ), array( 'username' => $username, 'failures' => $failures ), $ip );
self::maybe_check_anis_reputation( $ip, $failures );
$decision = Argus_Policy_Engine::evaluate( $ip, 'login_failure', array( 'recent_failures' => $failures ) );
if ( in_array( $decision['action'], array( Argus_Policy_Engine::ACTION_BLOCK, Argus_Policy_Engine::ACTION_CHALLENGE ), true ) ) {
self::record_finding( $ip, $username, $failures, $decision );
}
Argus_Policy_Engine::enforce_decision( $ip, 'login_failure', $decision, array( 'recent_failures' => $failures ) );
}
protected static function record_finding( $ip, $username, $failures, array $decision ) {
$blocked = Argus_Policy_Engine::ACTION_BLOCK === $decision['action'];
Argus_Findings::record(
'account',
$blocked ? 'high' : 'medium',
array(
'what_happened' => sprintf( 'Brute-force login attempts from %s', $ip ),
'why_it_matters' => 'Repeated failed logins from one source in a short window is the classic pattern of an automated credential-guessing attack against wp-login.php.',
'what_argus_found' => sprintf( '%d failed login attempts from %s. Action taken: %s (%s).', $failures, $ip, $decision['action'], $decision['observation_only'] ? 'observed only, MONITOR mode' : 'enforced' ),
'when_it_happened' => current_time( 'mysql' ),
'why_suspicious' => 'A real person very rarely fails to log in this many times in a row -- this volume is consistent with automated password guessing, not a forgetful admin.',
'what_could_be_affected' => 'If a guessed password succeeds, the attacker gains full access under that account\'s role -- often an administrator.',
'what_should_you_do' => $decision['observation_only']
? 'ARGUS is in MONITOR mode and did not block this. Review recent login activity and switch to BLOCK mode once you are confident legitimate traffic is not being flagged.'
: ( $blocked
? 'No action needed -- ARGUS already banned this IP.'
: 'ARGUS is challenging further attempts from this IP with a proof-of-work check before allowing another login try. If attempts continue, it will be banned outright.' ),
),
array( 'ip' => $ip, 'username' => $username, 'failures' => $failures, 'decision' => $decision )
);
}
protected static function maybe_check_anis_reputation( $ip, $failures ) {
if ( ! class_exists( 'Argus_ANIS_Client' ) || ! Argus_ANIS_Client::is_enabled() ) {
return;
}
$threshold = (int) Argus_Settings::get( 'login_attempt_threshold', 5 );
if ( $failures < max( 1, $threshold - 2 ) || $failures >= $threshold ) {
return;
}
if ( Argus_Ban_Engine::is_banned( $ip ) ) {
return;
}
$reputation = Argus_ANIS_Client::check_ip_live( $ip );
if ( $reputation && 'ban' === $reputation['action'] && $reputation['score'] >= Argus_ANIS_Client::AUTO_BAN_CONFIDENCE_THRESHOLD ) {
Argus_Ban_Engine::ban(
$ip,
Argus_Ban_Engine::SOURCE_ARGUS_CLOUD,
sprintf( 'Flagged by ANIS community threat intelligence during an active login attempt (score %d)', $reputation['score'] ),
array( 'anis_score' => $reputation['score'], 'login_failures' => $failures ),
Argus_Ban_Engine::LEVEL_EXTENDED
);
}
}
public static function on_success( $username, $user ) {
global $wpdb;
$ip = Argus_Request_Inputs::client_ip();
$wpdb->insert(
Argus_DB::table( 'login_attempts' ),
array(
'ip' => $ip,
'username' => mb_substr( (string) $username, 0, 60 ),
'success' => 1,
'created_at' => current_time( 'mysql', true ),
),
array( '%s', '%s', '%d', '%s' )
);
if ( $user instanceof WP_User && in_array( 'administrator', (array) $user->roles, true ) ) {
$account_age_days = ( time() - strtotime( $user->user_registered . ' UTC' ) ) / DAY_IN_SECONDS;
if ( $account_age_days < 1 ) {
Argus_Findings::record(
'account',
'medium',
array(
'what_happened' => sprintf( 'Administrator "%s" logged in for the first time within a day of the account being created', $username ),
'why_it_matters' => 'A brand-new administrator account is a common outcome of a successful privilege-escalation attack, not just a legitimate new team member.',
'what_argus_found' => sprintf( 'Account created %s, first login %s, from %s.', $user->user_registered, current_time( 'mysql' ), $ip ),
'when_it_happened' => current_time( 'mysql' ),
'why_suspicious' => 'New administrator accounts created shortly before their first login are worth a quick manual confirmation, especially if you did not create this account yourself.',
'what_could_be_affected' => 'A rogue administrator account has full control of the site: content, users, plugins, and themes.',
'what_should_you_do' => 'If you created this account intentionally, you can dismiss this finding. If not, remove the account immediately and review recent file/plugin changes.',
),
array( 'username' => $username, 'user_id' => $user->ID, 'ip' => $ip )
);
}
}
}
const ATTEMPTS_RETENTION_SECS = 7 * DAY_IN_SECONDS;
public static function prune_attempts() {
global $wpdb;
$table = Argus_DB::table( 'login_attempts' );
$cutoff = gmdate( 'Y-m-d H:i:s', time() - self::ATTEMPTS_RETENTION_SECS );
return $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); // phpcs:ignore
}
public static function recent_failures( $ip ) {
global $wpdb;
$table = Argus_DB::table( 'login_attempts' );
$window = (int) Argus_Settings::get( 'login_attempt_window_secs', 600 );
$since = gmdate( 'Y-m-d H:i:s', time() - $window );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE ip = %s AND success = 0 AND created_at >= %s", // phpcs:ignore
$ip,
$since
)
);
}
}