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.
187 lines
7.8 KiB
PHP
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
|
|
)
|
|
);
|
|
}
|
|
}
|