$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 ) ); } }