commit 9f06a4c2a24c6cd7758fdeae5f4b4c86f5388933 Author: Yuri Karamian Date: Sun Jan 25 20:21:17 2026 +0100 first commit diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..6cb6213 --- /dev/null +++ b/README.txt @@ -0,0 +1,145 @@ +=== WP Security Pack === +Contributors: arkhost +Tags: security, login, firewall, 2fa, malware +Requires at least: 5.0 +Tested up to: 6.4 +Requires PHP: 7.4 +Stable tag: 1.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +A security plugin. No upsells, no premium version, no "threat counters". Just security settings that work. + +== Description == + +WP Security Pack adds security features to WordPress. It's free, open source, and doesn't nag you to upgrade. + +**What it does:** + += Login Protection = +* Limit login attempts (block IPs after failed logins) +* Custom login URL (hide wp-login.php) +* Hide wp-admin from non-logged-in users +* Honeypot field to catch bots +* Hide login error messages to prevent username enumeration +* Email alerts when admins log in from new IPs +* Restrict login page access by country or IP + += IP Control = +* Whitelist IPs (always allowed) +* Blacklist IPs (always blocked) +* Auto-blacklist IPs after repeated lockouts +* Supports IPv4, IPv6, and CIDR notation + += Geo Blocking = +* Block access from specific countries +* Uses free IP2Location LITE database +* One-click database download + += Hardening = +* Disable XML-RPC +* Disable file editing in dashboard +* Disable application passwords +* Restrict REST API to logged-in users +* Remove WordPress version from source +* Disable user enumeration (?author=1 and REST API) +* Disable pingbacks and trackbacks + += Security Headers = +* X-Content-Type-Options +* X-Frame-Options +* X-XSS-Protection +* Referrer-Policy +* Permissions-Policy +* Content-Security-Policy +* Strict-Transport-Security (HSTS) + += Two-Factor Authentication = +* TOTP-based (works with Google Authenticator, Authy, etc.) +* Backup codes +* Option to enforce 2FA for administrators + += File Integrity Monitoring = +* Monitors WordPress core files +* Compares against official WordPress.org checksums +* Daily automatic scans +* Email alerts when files change + += Malware Scanner = +* Scans plugins, themes, and uploads +* Pattern-based detection for common threats +* Does not scan WordPress core (use File Integrity for that) +* Weekly automatic scans + += Activity Log = +* Records login attempts (success and failed) +* Records lockouts and blocks +* Shows IP, country, username, timestamp +* Configurable retention period +* Export to CSV + += Tools = +* Export/import settings +* Force logout all users +* Test email delivery + +**What it doesn't do:** + +* Firewall at the server level (use your host's firewall) +* Real-time threat intelligence (we don't phone home) +* Backup your site (use a backup plugin) +* CDN or performance optimization + +**Privacy:** + +The plugin doesn't send data anywhere except: + +* WordPress.org API to verify core file checksums +* IP2Location to download the geo database (if you click the download button) + +No tracking, no analytics, no telemetry. + +== Installation == + +1. Upload the plugin files to `/wp-content/plugins/wp-security-pack/` +2. Activate the plugin through the 'Plugins' screen +3. Go to Security in the admin menu to configure + +== Frequently Asked Questions == + += Is there a premium version? = + +No. This is the complete plugin. + += Will this slow down my site? = + +The plugin runs security checks on specific actions (login, admin access). It doesn't add overhead to frontend page loads for regular visitors. + += I locked myself out = + +Connect to your server via FTP or SSH and rename the plugin folder. This disables the plugin and you can log in normally. + += Does geo-blocking work without the database? = + +No. You need to download the IP2Location LITE database from the plugin settings. It's free. + += Can I use this with other security plugins? = + +Probably, but there may be conflicts. We recommend using one security plugin at a time. + +== Screenshots == + +1. Status overview +2. Login protection settings +3. Activity log +4. Two-factor authentication setup + +== Changelog == + += 1.0 = +* Initial release + +== Upgrade Notice == + += 1.0 = +Initial release. diff --git a/wp-security-pack-logo.png b/wp-security-pack-logo.png new file mode 100644 index 0000000..0d25afb Binary files /dev/null and b/wp-security-pack-logo.png differ diff --git a/wp-security-pack/assets/css/admin.css b/wp-security-pack/assets/css/admin.css new file mode 100644 index 0000000..86cea20 --- /dev/null +++ b/wp-security-pack/assets/css/admin.css @@ -0,0 +1,524 @@ +/** + * WP Security Pack Admin Styles + * + * Clean, modern design - no scary dashboards or fake threat counters. + */ + +/* CSS Custom Properties */ +:root { + --wpsp-primary: #2271b1; + --wpsp-primary-hover: #135e96; + --wpsp-success: #00a32a; + --wpsp-success-bg: #edfaef; + --wpsp-warning: #dba617; + --wpsp-warning-bg: #fcf9e8; + --wpsp-danger: #d63638; + --wpsp-danger-bg: #fcf0f1; + --wpsp-info: #72aee6; + --wpsp-info-bg: #f0f6fc; + --wpsp-border: #e0e0e0; + --wpsp-border-light: #f0f0f1; + --wpsp-text: #1d2327; + --wpsp-text-light: #646970; + --wpsp-bg: #fff; + --wpsp-bg-alt: #f6f7f7; + --wpsp-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + --wpsp-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.08); + --wpsp-radius: 8px; + --wpsp-radius-sm: 4px; + --wpsp-transition: 0.15s ease; +} + +/* Main wrapper */ +.wpsp-wrap { + max-width: 1200px; +} + +.wpsp-wrap h1 { + font-size: 24px; + font-weight: 600; + margin-bottom: 1.5em; + padding: 0; + color: var(--wpsp-text); +} + +/* Tabs */ +.wpsp-tabs { + margin-bottom: 24px; + border-bottom: 1px solid var(--wpsp-border); + display: flex; + flex-wrap: wrap; + gap: 2px; +} + +.wpsp-tabs .nav-tab { + margin-left: 0; + margin-right: 0; + border-radius: var(--wpsp-radius-sm) var(--wpsp-radius-sm) 0 0; + transition: background var(--wpsp-transition), color var(--wpsp-transition); + padding: 8px 16px; + font-weight: 500; +} + +.wpsp-tabs .nav-tab:hover { + background: var(--wpsp-bg-alt); +} + +.wpsp-tabs .nav-tab-active { + background: var(--wpsp-bg); + border-bottom-color: var(--wpsp-bg); +} + +/* Sections */ +.wpsp-section { + background: var(--wpsp-bg); + border: 1px solid var(--wpsp-border); + border-radius: var(--wpsp-radius); + padding: 24px; + margin-bottom: 20px; + box-shadow: var(--wpsp-shadow); + transition: box-shadow var(--wpsp-transition); +} + +.wpsp-section:hover { + box-shadow: var(--wpsp-shadow-hover); +} + +.wpsp-section h2 { + margin: 0 0 16px 0; + padding: 0 0 12px 0; + font-size: 1.2em; + font-weight: 600; + border-bottom: 1px solid var(--wpsp-border-light); + color: var(--wpsp-text); +} + +.wpsp-section h3 { + margin-top: 1.5em; + font-size: 1em; + font-weight: 600; + color: var(--wpsp-text); +} + +/* Form tables */ +.wpsp-section .form-table { + margin-top: 0; +} + +.wpsp-section .form-table th { + width: 220px; + padding-left: 0; + font-weight: 500; + color: var(--wpsp-text); +} + +.wpsp-section .form-table td { + padding-top: 12px; + padding-bottom: 12px; +} + +/* Status indicators */ +.wpsp-status { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 50px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.wpsp-status-ok { + background: var(--wpsp-success-bg); + color: var(--wpsp-success); +} + +.wpsp-status-warning { + background: var(--wpsp-warning-bg); + color: #8a6d00; +} + +.wpsp-status-error { + background: var(--wpsp-danger-bg); + color: var(--wpsp-danger); +} + +/* Stats */ +.wpsp-stats { + display: flex; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.wpsp-stat { + background: var(--wpsp-bg); + border: 1px solid var(--wpsp-border); + padding: 20px 28px; + border-radius: var(--wpsp-radius); + text-align: center; + min-width: 130px; + transition: transform var(--wpsp-transition), box-shadow var(--wpsp-transition); +} + +.wpsp-stat:hover { + transform: translateY(-2px); + box-shadow: var(--wpsp-shadow-hover); +} + +.wpsp-stat-value { + display: block; + font-size: 32px; + font-weight: 700; + color: var(--wpsp-text); + line-height: 1.2; +} + +.wpsp-stat-label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--wpsp-text-light); + margin-top: 6px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Stat color variations */ +.wpsp-stat-success { + border-left: 4px solid var(--wpsp-success); +} + +.wpsp-stat-success .wpsp-stat-value { + color: var(--wpsp-success); +} + +.wpsp-stat-warning { + border-left: 4px solid var(--wpsp-warning); +} + +.wpsp-stat-warning .wpsp-stat-value { + color: #8a6d00; +} + +.wpsp-stat-danger { + border-left: 4px solid var(--wpsp-danger); +} + +.wpsp-stat-danger .wpsp-stat-value { + color: var(--wpsp-danger); +} + +.wpsp-stat-info { + border-left: 4px solid var(--wpsp-primary); +} + +.wpsp-stat-info .wpsp-stat-value { + color: var(--wpsp-primary); +} + +/* Tables */ +.wpsp-section .widefat { + margin-top: 16px; + border-radius: var(--wpsp-radius-sm); + overflow: hidden; +} + +.wpsp-section .widefat td, +.wpsp-section .widefat th { + padding: 12px 14px; +} + +.wpsp-section .widefat thead th { + font-weight: 600; + background: var(--wpsp-bg-alt); +} + +.wpsp-section .widefat tbody tr { + transition: background var(--wpsp-transition); +} + +.wpsp-section .widefat tbody tr:hover { + background: var(--wpsp-bg-alt); +} + +/* Country select */ +.wpsp-country-select { + min-width: 300px; + height: 200px !important; + border-radius: var(--wpsp-radius-sm); +} + +/* Event type colors */ +.wpsp-event-login_success { + color: var(--wpsp-success); +} + +.wpsp-event-login_failed { + color: #8a6d00; +} + +.wpsp-event-lockout, +.wpsp-event-ip_blocked, +.wpsp-event-geo_blocked { + color: var(--wpsp-danger); +} + +/* Country badge */ +.wpsp-country { + display: inline-block; + background: var(--wpsp-bg-alt); + padding: 2px 8px; + border-radius: 50px; + font-size: 11px; + font-weight: 500; + margin-left: 6px; +} + +/* Severity badges */ +.wpsp-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 50px; + font-size: 10px; + font-weight: 700; + color: #fff; + margin-right: 6px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* Footer */ +.wpsp-footer { + margin-top: 32px; + padding-top: 20px; + border-top: 1px solid var(--wpsp-border); + color: var(--wpsp-text-light); + font-size: 13px; +} + +.wpsp-footer a { + color: var(--wpsp-primary); + text-decoration: none; + transition: color var(--wpsp-transition); +} + +.wpsp-footer a:hover { + color: var(--wpsp-primary-hover); + text-decoration: underline; +} + +/* 2FA status */ +.wpsp-2fa-status { + display: inline-flex; + align-items: center; + padding: 6px 14px; + border-radius: 50px; + font-weight: 600; + font-size: 13px; +} + +.wpsp-2fa-enabled { + background: var(--wpsp-success-bg); + color: var(--wpsp-success); +} + +.wpsp-2fa-disabled { + background: var(--wpsp-danger-bg); + color: var(--wpsp-danger); +} + +/* Responsive */ +@media screen and (max-width: 782px) { + .wpsp-stats { + flex-direction: column; + } + + .wpsp-stat { + min-width: auto; + } + + .wpsp-section .form-table th { + width: auto; + } + + .wpsp-country-select { + min-width: 100%; + } + + .wpsp-tabs { + gap: 4px; + } + + .wpsp-tabs .nav-tab { + padding: 6px 12px; + font-size: 13px; + } +} + +/* Code blocks */ +.wpsp-section code { + background: var(--wpsp-bg-alt); + padding: 3px 8px; + border-radius: var(--wpsp-radius-sm); + font-size: 13px; +} + +/* Button spacing & styling */ +.wpsp-section .button { + transition: all var(--wpsp-transition); +} + +.wpsp-section .button:hover { + transform: translateY(-1px); +} + +.wpsp-section .button + .button { + margin-left: 8px; +} + +/* Tablenav */ +.wpsp-section .tablenav { + margin: 16px 0; +} + +.wpsp-section .tablenav .actions { + padding: 0; +} + +/* Alert boxes */ +.wpsp-alert { + padding: 14px 18px; + border-radius: var(--wpsp-radius); + margin-bottom: 16px; + border-left: 4px solid; +} + +.wpsp-alert-info { + background: var(--wpsp-info-bg); + border-left-color: var(--wpsp-info); + color: #0a4b78; +} + +.wpsp-alert-warning { + background: var(--wpsp-warning-bg); + border-left-color: var(--wpsp-warning); + color: #6e5600; +} + +.wpsp-alert-error { + background: var(--wpsp-danger-bg); + border-left-color: var(--wpsp-danger); + color: #8a1f1f; +} + +/* Loading states */ +.wpsp-loading { + opacity: 0.5; + pointer-events: none; +} + +/* QR code container */ +#wpsp-2fa-qr img, +#wpsp-2fa-qr svg { + border: 1px solid var(--wpsp-border); + padding: 12px; + background: var(--wpsp-bg); + border-radius: var(--wpsp-radius); + margin: 12px 0; +} + +/* Backup codes display */ +#wpsp-backup-codes-display pre, +#wpsp-new-backup-codes { + background: var(--wpsp-bg-alt); + padding: 16px; + border-radius: var(--wpsp-radius-sm); + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 14px; + line-height: 2; + border: 1px solid var(--wpsp-border); +} + +/* Scan results */ +.wpsp-scan-results { + max-height: 400px; + overflow-y: auto; +} + +/* Checkbox styling */ +.wpsp-section input[type="checkbox"] { + margin-right: 10px; + width: 18px; + height: 18px; +} + +/* Description text */ +.wpsp-section .description { + color: var(--wpsp-text-light); + font-style: normal; + margin-top: 6px; + font-size: 13px; + line-height: 1.5; +} + +/* Tab intro descriptions */ +.wpsp-section p.wpsp-description { + color: var(--wpsp-text-light); + font-style: normal; + font-size: 14px; + margin: -8px 0 20px 0; + line-height: 1.6; +} + +/* Import/export section */ +#wpsp-import-file { + margin-right: 12px; +} + +#wpsp-import-status { + margin-left: 12px; + color: var(--wpsp-success); + font-weight: 500; +} + +/* System info table */ +.wpsp-section .widefat td:first-child { + font-weight: 600; + width: 200px; + color: var(--wpsp-text); +} + +/* Status checklist icons */ +.wpsp-section .widefat td span[style*="color: #46b450"], +.wpsp-section .widefat td span[style*="color: #dc3232"] { + font-size: 20px; + line-height: 1; +} + +/* Notice improvements within sections */ +.wpsp-section .notice { + border-radius: var(--wpsp-radius-sm); + margin: 12px 0; +} + +/* Input focus states */ +.wpsp-section input[type="text"]:focus, +.wpsp-section input[type="number"]:focus, +.wpsp-section textarea:focus, +.wpsp-section select:focus { + border-color: var(--wpsp-primary); + box-shadow: 0 0 0 1px var(--wpsp-primary); + outline: none; +} + +/* Textarea styling */ +.wpsp-section textarea { + border-radius: var(--wpsp-radius-sm); +} + +/* Select styling */ +.wpsp-section select { + border-radius: var(--wpsp-radius-sm); +} diff --git a/wp-security-pack/assets/css/index.php b/wp-security-pack/assets/css/index.php new file mode 100644 index 0000000..0343009 --- /dev/null +++ b/wp-security-pack/assets/css/index.php @@ -0,0 +1,6 @@ + + Order Allow,Deny + Deny from all + diff --git a/wp-security-pack/data/index.php b/wp-security-pack/data/index.php new file mode 100644 index 0000000..0343009 --- /dev/null +++ b/wp-security-pack/data/index.php @@ -0,0 +1,6 @@ +get_component( 'geo_blocking' ); + if ( $geo_blocking && $ip_address ) { + $country_code = $geo_blocking->get_country_code( $ip_address ); + } + + $table = WPSP_DB::get_log_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( + $table, + array( + 'event_type' => $event_type, + 'ip_address' => $ip_address ? $ip_address : '', + 'username' => $username, + 'user_agent' => $user_agent, + 'country_code' => $country_code, + 'details' => $details, + 'created_at' => current_time( 'mysql' ), + ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + + return $result ? $wpdb->insert_id : false; + } + + /** + * Get recent logs. + * + * @param array $args Query arguments. + * @return array + */ + public static function get_logs( $args = array() ) { + global $wpdb; + + $defaults = array( + 'limit' => 50, + 'offset' => 0, + 'event_type' => '', + 'ip_address' => '', + 'orderby' => 'created_at', + 'order' => 'DESC', + ); + + $args = wp_parse_args( $args, $defaults ); + $table = WPSP_DB::get_log_table(); + + $where_clauses = array( '1=1' ); + $where_values = array(); + + if ( ! empty( $args['event_type'] ) ) { + $where_clauses[] = 'event_type = %s'; + $where_values[] = $args['event_type']; + } + + if ( ! empty( $args['ip_address'] ) ) { + $where_clauses[] = 'ip_address = %s'; + $where_values[] = $args['ip_address']; + } + + $where_sql = implode( ' AND ', $where_clauses ); + + // Sanitize orderby. + $allowed_orderby = array( 'id', 'event_type', 'ip_address', 'username', 'created_at' ); + $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'created_at'; + $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; + + $limit = absint( $args['limit'] ); + $offset = absint( $args['offset'] ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d"; + + $where_values[] = $limit; + $where_values[] = $offset; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared + return $wpdb->get_results( $wpdb->prepare( $sql, $where_values ) ); + } + + /** + * Get total log count. + * + * @param array $args Query arguments. + * @return int + */ + public static function get_log_count( $args = array() ) { + global $wpdb; + + $table = WPSP_DB::get_log_table(); + + $where_clauses = array( '1=1' ); + $where_values = array(); + + if ( ! empty( $args['event_type'] ) ) { + $where_clauses[] = 'event_type = %s'; + $where_values[] = $args['event_type']; + } + + if ( ! empty( $args['ip_address'] ) ) { + $where_clauses[] = 'ip_address = %s'; + $where_values[] = $args['ip_address']; + } + + $where_sql = implode( ' AND ', $where_clauses ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; + + if ( ! empty( $where_values ) ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared + return (int) $wpdb->get_var( $wpdb->prepare( $sql, $where_values ) ); + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared + return (int) $wpdb->get_var( $sql ); + } + + /** + * Get log statistics. + * + * @param int $days Number of days to look back. + * @return array + */ + public static function get_stats( $days = 30 ) { + global $wpdb; + + $table = WPSP_DB::get_log_table(); + $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $results = $wpdb->get_results( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT event_type, COUNT(*) as count FROM {$table} WHERE created_at >= %s GROUP BY event_type", + $cutoff + ) + ); + + $stats = array( + 'login_success' => 0, + 'login_failed' => 0, + 'lockout' => 0, + 'ip_blocked' => 0, + 'geo_blocked' => 0, + 'total' => 0, + ); + + foreach ( $results as $row ) { + $stats[ $row->event_type ] = (int) $row->count; + $stats['total'] += (int) $row->count; + } + + return $stats; + } + + /** + * Cleanup old logs (cron job). + */ + public static function cleanup_old_logs() { + global $wpdb; + + $retention_days = WP_Security_Pack::get_setting( 'log_retention_days', 30 ); + $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) ); + + $log_table = WPSP_DB::get_log_table(); + $lockout_table = WPSP_DB::get_lockout_table(); + + // Delete old logs. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "DELETE FROM {$log_table} WHERE created_at < %s", + $cutoff + ) + ); + + // Delete expired lockouts (lockout_until stores Unix timestamp as integer). + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "DELETE FROM {$lockout_table} WHERE lockout_until IS NOT NULL AND lockout_until > 0 AND lockout_until < %d", + time() + ) + ); + } + + /** + * Clear all logs. + * + * @return bool + */ + public static function clear_all_logs() { + global $wpdb; + + $table = WPSP_DB::get_log_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return false !== $wpdb->query( "TRUNCATE TABLE {$table}" ); + } + + /** + * Get event type label. + * + * @param string $event_type Event type. + * @return string + */ + public static function get_event_label( $event_type ) { + $labels = array( + self::EVENT_LOGIN_SUCCESS => __( 'Login Success', 'wp-security-pack' ), + self::EVENT_LOGIN_FAILED => __( 'Login Failed', 'wp-security-pack' ), + self::EVENT_LOCKOUT => __( 'Lockout', 'wp-security-pack' ), + self::EVENT_IP_BLOCKED => __( 'IP Blocked', 'wp-security-pack' ), + self::EVENT_GEO_BLOCKED => __( 'Geo Blocked', 'wp-security-pack' ), + self::EVENT_LOCKOUT_LIFTED => __( 'Lockout Lifted', 'wp-security-pack' ), + ); + + return isset( $labels[ $event_type ] ) ? $labels[ $event_type ] : $event_type; + } +} diff --git a/wp-security-pack/includes/class-wpsp-admin.php b/wp-security-pack/includes/class-wpsp-admin.php new file mode 100644 index 0000000..e667127 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-admin.php @@ -0,0 +1,2829 @@ + __( 'Status', 'wp-security-pack' ), + 'login' => __( 'Login Protection', 'wp-security-pack' ), + 'ip' => __( 'IP Control', 'wp-security-pack' ), + 'hardening' => __( 'Hardening', 'wp-security-pack' ), + 'headers' => __( 'Security Headers', 'wp-security-pack' ), + '2fa' => __( '2FA', 'wp-security-pack' ), + 'scanner' => __( 'Scanner', 'wp-security-pack' ), + 'logs' => __( 'Activity Log', 'wp-security-pack' ), + 'tools' => __( 'Tools', 'wp-security-pack' ), + ); + + foreach ( $submenus as $slug => $title ) { + add_submenu_page( + 'wp-security-pack', + $title . ' - ' . __( 'WP Security Pack', 'wp-security-pack' ), + $title, + 'manage_options', + 'status' === $slug ? 'wp-security-pack' : 'wp-security-pack&tab=' . $slug, + array( $this, 'render_settings_page' ) + ); + } + } + + /** + * Add plugin action links (left side - Settings, Deactivate, etc.). + * + * @param array $links Existing links. + * @return array + */ + public function plugin_action_links( $links ) { + $settings_link = sprintf( + '%s', + admin_url( 'admin.php?page=wp-security-pack' ), + __( 'Settings', 'wp-security-pack' ) + ); + array_unshift( $links, $settings_link ); + return $links; + } + + /** + * Add plugin row meta links (right side - after version). + * + * @param array $links Existing meta links. + * @param string $file Plugin file. + * @return array + */ + public function plugin_row_meta( $links, $file ) { + if ( WPSP_PLUGIN_BASENAME !== $file ) { + return $links; + } + + $links[] = sprintf( + '%s', + 'https://arkhost.com/products-menu.php', + __( 'Get Hosting', 'wp-security-pack' ) + ); + + return $links; + } + + /** + * Add dashboard widget. + */ + public function add_dashboard_widget() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + wp_add_dashboard_widget( + 'wpsp_security_widget', + __( 'Security Status', 'wp-security-pack' ), + array( $this, 'render_dashboard_widget' ) + ); + } + + /** + * Render dashboard widget. + */ + public function render_dashboard_widget() { + $checks = $this->get_security_checks(); + $enabled_count = 0; + $total_count = count( $checks ); + + foreach ( $checks as $check ) { + if ( $check['status'] ) { + $enabled_count++; + } + } + + $score = $total_count > 0 ? round( ( $enabled_count / $total_count ) * 100 ) : 0; + + // Get recent activity stats. + $stats = WPSP_Activity_Log::get_stats( 7 ); + ?> + + +
+
%
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + __( 'Status', 'wp-security-pack' ), + 'login' => __( 'Login Protection', 'wp-security-pack' ), + 'ip' => __( 'IP Control', 'wp-security-pack' ), + 'hardening' => __( 'Hardening', 'wp-security-pack' ), + 'headers' => __( 'Security Headers', 'wp-security-pack' ), + '2fa' => __( '2FA', 'wp-security-pack' ), + 'scanner' => __( 'Scanner', 'wp-security-pack' ), + 'logs' => __( 'Activity Log', 'wp-security-pack' ), + 'tools' => __( 'Tools', 'wp-security-pack' ), + ); + ?> +
+

+ + + +
+ render_status_tab(); + break; + case 'login': + $this->render_login_tab(); + break; + case 'ip': + $this->render_ip_tab(); + break; + case 'hardening': + $this->render_hardening_tab(); + break; + case 'headers': + $this->render_headers_tab(); + break; + case '2fa': + $this->render_2fa_tab(); + break; + case 'scanner': + $this->render_scanner_tab(); + break; + case 'logs': + $this->render_logs_tab(); + break; + case 'tools': + $this->render_tools_tab(); + break; + } + + if ( ! in_array( $current_tab, array( 'status', 'logs', 'tools' ), true ) ) { + submit_button(); + } + ?> +
+ + +
+ get_security_checks(); + $enabled_count = 0; + $total_count = count( $checks ); + + foreach ( $checks as $check ) { + if ( $check['status'] ) { + $enabled_count++; + } + } + + $score = $total_count > 0 ? round( ( $enabled_count / $total_count ) * 100 ) : 0; + ?> +
+

+ +
+
+ % + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + $check ) : ?> + + + + + + + + +
+ + + + + + + + + + + +
+
+ +
+

+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + +
+
+ __( 'Login Attempt Limiting', 'wp-security-pack' ), + 'description' => __( 'Limits failed login attempts to prevent brute force attacks', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'login_limit_enabled', true ), + 'tab' => 'login', + ); + + $checks['honeypot'] = array( + 'name' => __( 'Login Honeypot', 'wp-security-pack' ), + 'description' => __( 'Hidden field that traps automated bots', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'honeypot_enabled', true ), + 'tab' => 'login', + ); + + $checks['hide_login_errors'] = array( + 'name' => __( 'Login Errors Hidden', 'wp-security-pack' ), + 'description' => __( 'Generic error message prevents username enumeration', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'hide_login_errors', true ), + 'tab' => 'login', + ); + + // Hardening. + $checks['xmlrpc'] = array( + 'name' => __( 'XML-RPC Disabled', 'wp-security-pack' ), + 'description' => __( 'Blocks XML-RPC endpoint commonly used in attacks', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'disable_xmlrpc', true ), + 'tab' => 'hardening', + ); + + $checks['file_editing'] = array( + 'name' => __( 'File Editor Disabled', 'wp-security-pack' ), + 'description' => __( 'Prevents editing theme/plugin files from dashboard', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'disable_file_editing', true ), + 'tab' => 'hardening', + ); + + $checks['rest_api'] = array( + 'name' => __( 'REST API Restricted', 'wp-security-pack' ), + 'description' => __( 'Requires authentication for REST API access', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'restrict_rest_api', true ), + 'tab' => 'hardening', + ); + + $checks['user_enum'] = array( + 'name' => __( 'User Enumeration Blocked', 'wp-security-pack' ), + 'description' => __( 'Prevents attackers from discovering usernames', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'disable_user_enumeration', true ), + 'tab' => 'hardening', + ); + + $checks['pingbacks'] = array( + 'name' => __( 'Pingbacks Disabled', 'wp-security-pack' ), + 'description' => __( 'Prevents pingback-based DDoS amplification', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'disable_pingbacks', true ), + 'tab' => 'hardening', + ); + + $checks['wp_version'] = array( + 'name' => __( 'WP Version Hidden', 'wp-security-pack' ), + 'description' => __( 'Hides WordPress version from public view', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'remove_wp_version', true ), + 'tab' => 'hardening', + ); + + $checks['security_headers'] = array( + 'name' => __( 'Security Headers', 'wp-security-pack' ), + 'description' => __( 'HTTP headers that enable browser security features', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'add_security_headers', true ), + 'tab' => 'headers', + ); + + // 2FA - only green if enforced for admins (just "enabled" means users CAN set it up, not that they have). + $two_fa_enabled = WP_Security_Pack::get_setting( 'two_factor_enabled', false ); + $two_fa_enforced = WP_Security_Pack::get_setting( 'two_factor_enforce_admin', false ); + $checks['two_factor'] = array( + 'name' => __( 'Two-Factor Authentication', 'wp-security-pack' ), + 'description' => $two_fa_enabled && ! $two_fa_enforced + ? __( 'Available but not enforced for admins', 'wp-security-pack' ) + : __( 'Enforced for administrator accounts', 'wp-security-pack' ), + 'status' => $two_fa_enabled && $two_fa_enforced, + 'tab' => '2fa', + ); + + // Monitoring. + $checks['file_integrity'] = array( + 'name' => __( 'File Integrity Monitoring', 'wp-security-pack' ), + 'description' => __( 'Detects unauthorized changes to WordPress core', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'file_integrity_enabled', true ), + 'tab' => 'scanner', + ); + + $checks['malware_scan'] = array( + 'name' => __( 'Malware Scanning', 'wp-security-pack' ), + 'description' => __( 'Scans files for malicious code patterns', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'malware_scan_enabled', true ), + 'tab' => 'scanner', + ); + + // Optional features (not counted negatively if disabled). + $checks['custom_login'] = array( + 'name' => __( 'Custom Login URL', 'wp-security-pack' ), + 'description' => __( 'Hides wp-login.php from automated scanners', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'login_rename_enabled', false ), + 'tab' => 'login', + ); + + $checks['geo_blocking'] = array( + 'name' => __( 'Country Blocking', 'wp-security-pack' ), + 'description' => __( 'Blocks access from specific countries', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'geo_blocking_enabled', false ), + 'tab' => 'ip', + ); + + $checks['auto_blacklist'] = array( + 'name' => __( 'Auto-Blacklist Repeat Offenders', 'wp-security-pack' ), + 'description' => __( 'Permanently blocks IPs with repeated lockouts', 'wp-security-pack' ), + 'status' => WP_Security_Pack::get_setting( 'auto_blacklist_enabled', false ), + 'tab' => 'ip', + ); + + // Environment checks (no tab - these are recommendations). + global $wpdb; + $checks['ssl'] = array( + 'name' => __( 'SSL/HTTPS', 'wp-security-pack' ), + 'description' => is_ssl() + ? __( 'Site is served over HTTPS', 'wp-security-pack' ) + : __( 'Site should use HTTPS for security', 'wp-security-pack' ), + 'status' => is_ssl(), + 'tab' => '', + ); + + $checks['debug_mode'] = array( + 'name' => __( 'Debug Mode Disabled', 'wp-security-pack' ), + 'description' => defined( 'WP_DEBUG' ) && WP_DEBUG + ? __( 'WP_DEBUG is enabled - disable in production', 'wp-security-pack' ) + : __( 'Debug mode is properly disabled', 'wp-security-pack' ), + 'status' => ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ), + 'tab' => '', + ); + + $checks['db_prefix'] = array( + 'name' => __( 'Database Prefix Changed', 'wp-security-pack' ), + 'description' => 'wp_' === $wpdb->prefix + ? __( 'Using default wp_ prefix - consider changing', 'wp-security-pack' ) + : __( 'Using custom database prefix', 'wp-security-pack' ), + 'status' => 'wp_' !== $wpdb->prefix, + 'tab' => '', + ); + + $admin_user = get_user_by( 'login', 'admin' ); + $checks['admin_username'] = array( + 'name' => __( 'No "admin" Username', 'wp-security-pack' ), + 'description' => $admin_user + ? __( 'Default "admin" username exists - consider renaming', 'wp-security-pack' ) + : __( 'No user with "admin" username', 'wp-security-pack' ), + 'status' => ! $admin_user, + 'tab' => '', + ); + + $checks['php_version'] = array( + 'name' => __( 'PHP Version', 'wp-security-pack' ), + 'description' => version_compare( PHP_VERSION, '8.0', '<' ) + ? sprintf( + /* translators: %s: PHP version */ + __( 'PHP %s is outdated - update recommended', 'wp-security-pack' ), + PHP_VERSION + ) + : sprintf( + /* translators: %s: PHP version */ + __( 'Running PHP %s', 'wp-security-pack' ), + PHP_VERSION + ), + 'status' => version_compare( PHP_VERSION, '8.0', '>=' ), + 'tab' => '', + ); + + // Check if auto-updates are enabled for core. + $auto_updates_enabled = defined( 'WP_AUTO_UPDATE_CORE' ) && WP_AUTO_UPDATE_CORE; + // Also check the database option. + if ( ! $auto_updates_enabled ) { + $auto_updates_enabled = 'true' === get_site_option( 'auto_update_core_major' ) || + 'true' === get_site_option( 'auto_update_core_minor' ); + } + $checks['auto_updates'] = array( + 'name' => __( 'Auto-Updates Enabled', 'wp-security-pack' ), + 'description' => $auto_updates_enabled + ? __( 'WordPress core auto-updates are enabled', 'wp-security-pack' ) + : __( 'Enable auto-updates for security patches', 'wp-security-pack' ), + 'status' => $auto_updates_enabled, + 'tab' => '', + ); + + return $checks; + } + + /** + * Render login protection tab. + */ + private function render_login_tab() { + ?> + +
+

+

+
+ + +

+ get_component( 'geo_blocking' ); + $db_exists = $geo_blocking && $geo_blocking->database_exists(); + if ( ! $db_exists ) : + ?> +
+

+
+ ' . esc_html__( 'Geo Blocking', 'wp-security-pack' ) . '' + ); + ?> +

+
+ +
+

+
+ + + + + + + + + + + + + + +
+ +
+ + +

+
+ +

+
+
+ ' . esc_html( WPSP_Helper::get_client_ip() ) . '' ); + ?> +

+
+
+ +
+

+

+ +

+ + + + + + + + + + + + + +
+ +
+ + + +

+
+ +

+
+
+ +
+

+ + + + + + + + + + + + + +
+ +
+ +

+
+ + +
+
+ +
+

+ + + + + + + + + + + + + +
+ +
+ + +

+
+ +

+ +

+
+
+ +
+

+

+ +

+ + + + + + + + + +
+ +
+ + +

+
+
+ +
+

+ + + + + + + + + +
+ +

+
+ +

+
+
+ + + get_component( 'ip_control' ); + if ( $ip_control ) { + $blocked_ips = $ip_control->get_blocked_ips(); + } + + $geo_blocking = wpsp()->get_component( 'geo_blocking' ); + $db_info = $geo_blocking ? $geo_blocking->get_database_info() : array( 'exists' => false ); + $countries = WPSP_Helper::get_countries_with_flags(); + ?> + +
+

+

+ + +

+ + + + + + + + + + + + + +
+ +
+ + + + + + - + + + - + + +

+ +

+ +

+ + + + + +

+ +

+ +
+ +

+ +

+
+
+ +
+

+

+ +

+ + + + + +
+ +

+
+ ' . esc_html( WPSP_Helper::get_client_ip() ) . '' ); + ?> +

+
+
+ +
+

+ + + + + + + + + + + + + +
+ +

+
+ +

+
+ + +

+
+
+ + +
+

+ + + + + + + + + + + + + + + + + + + +
ip_address ); ?>failed_attempts ); ?>lockout_until ); ?> + +
+
+ + + + + +
+

+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

+
+ +

Theme Editor and Plugins > Plugin Editor. If an attacker gains admin access, they cannot inject malicious code directly.', 'wp-security-pack' ); ?>

+
+ +

+
+ +

+
+ +

+
+ +

+
+ +

+
+ +

+
+ +

+
+
+ +
+

+

+ +

+ +
+ get_default_headers(); + $headers = WP_Security_Pack::get_setting( 'security_headers', $default_headers ); + ?> + +
+

+

+ +

+
+

+ securityheaders.com' + ); + ?> + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

+
+ +

+
+ +

+
+ +

+
+ +

+
+ +

+ + +

+

+ +
+

+
+ +

+ +
+

+
+
+ + + '_wpsp_2fa_enabled', + 'meta_value' => '1', + 'fields' => 'ID', + ) ); + $total_admins = count( get_users( array( + 'role' => 'administrator', + 'fields' => 'ID', + ) ) ); + $admins_with_2fa = 0; + foreach ( $users_with_2fa as $user_id ) { + if ( user_can( $user_id, 'manage_options' ) ) { + $admins_with_2fa++; + } + } + ?> + +
+

+

+ +

+ + 0 || $total_admins > 0 ) : ?> +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + +
+ +

+
+ +

+
+
+ +
+

+

+ +
+ +
+

+
    +
  1. +
  2. + ' . esc_html__( 'Profile page', 'wp-security-pack' ) . '' + ); + ?> +
  3. +
  4. +
  5. +
  6. +
+
+ +
+

+

+ +

+ +
+ +
+

+

+ +

+
+ get_last_scan_results(); + + $malware_scanner = new WPSP_Malware_Scanner(); + $malware_results = $malware_scanner->get_last_scan_results(); + + $file_changes_count = count( $file_results['changes']['modified'] ?? array() ); + $malware_issues_count = count( $malware_results['results'] ?? array() ); + ?> + + + 0 || $malware_results['time'] > 0 ) : ?> +
+
+ + 0 ? esc_html( $file_changes_count ) : '✓'; ?> + + +
+
+ + 0 ? esc_html( $malware_issues_count ) : '✓'; ?> + + +
+
+ + +
+

+

+ +

+ + + + + + + + + +
+ +

+
+ 0 ) : ?> + + + + + + 0 ) : ?> + + +
+ + +

+
+

+ +

+
    +
  • +
  • +
  • +
+
+ + + + + + + + + + + + + + + +
+

+ + +

+ 0 ) : ?> +
+

+
+ +
+ + +
+

+

+ +

+ + + + + + + + + + +
+ +

+
+ 0 ) : ?> + + + + + + +
+ + +

+
+

+ +

+
+ + + + + + + + + $findings ) : ?> + + + + + + +
+ + + + + + + + + +
+ + + +

+ + +

+ 0 ) : ?> +
+

+
+ +
+ +
+

+ +

+ + +

+ + + + + + + + + + + + + + + + +
+
+ + + $per_page, + 'offset' => $offset, + 'event_type' => $filter_type, + ) ); + + $total = WPSP_Activity_Log::get_log_count( array( 'event_type' => $filter_type ) ); + $pages = ceil( $total / $per_page ); + + $stats = WPSP_Activity_Log::get_stats( 30 ); + ?> + +
+

+

+ +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+ +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
created_at ); ?> + + event_type ) ); ?> + + + country_code ) : ?> + country_code ) ); ?> + + - + + ip_address ); ?>username ? $log->username : '-' ); ?>details ? $log->details : '-' ); ?>
+ + 1 ) : ?> +
+
+ add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'current' => $page, + 'total' => $pages, + ) ) ); + ?> +
+
+ +
+ +
+

+

+ + + + + + + + + + + + + + +
+ +
+ +

+
+ + +

+
+
+ +
+

+ + + + + + +
+ + +

+
+ +
+ + + get_component( 'geo_blocking' ); + $geo_info = $geo_blocking ? $geo_blocking->get_database_info() : array( 'exists' => false ); + $country_code = $geo_blocking && $current_ip ? $geo_blocking->get_country_code( $current_ip ) : null; + ?> + +
+

+

+ + + + + + + + + + +
+ + + + + get_component( 'ip_control' ); + $is_whitelisted = $ip_control && $ip_control->is_whitelisted( $current_ip ); + ?> + + + + + +
+ + + +

+
+ +

+ + +

+ +
+
+ +
+

+

+ + + + + + + + + + + + + + +
+ +
+ + wp-content/plugins/wp-security-pack +

+
+ +
TRUNCATE TABLE prefix ); ?>wpsp_lockouts; +
+
+ +
+

+ + + + + + +
+ + +

+
+
+ +
+

+

+ + + + + + +
+ + +

+
+
+ +
+

+

+ + + + + + + + + + +
+ +

+
+ + + +

+
+
+ +
+

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + () + +
+ + + () + + + () + +
+ + + + + +
+
+ +
+

+

+ + + + + + +
+ +

+
+
+ + + __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + WPSP_Activity_Log::clear_all_logs(); + wp_send_json_success(); + } + + /** + * AJAX: Whitelist IP. + * + * Adds an IP to the whitelist setting. + */ + public function ajax_whitelist_ip() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : ''; + + if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid IP address.', 'wp-security-pack' ) ) ); + } + + // Get current whitelist. + $whitelist = WP_Security_Pack::get_setting( 'ip_whitelist', '' ); + + // Check if IP is already in whitelist. + $whitelist_array = array_filter( array_map( 'trim', explode( "\n", $whitelist ) ) ); + if ( in_array( $ip, $whitelist_array, true ) ) { + wp_send_json_error( array( 'message' => __( 'IP is already whitelisted.', 'wp-security-pack' ) ) ); + } + + // Add IP to whitelist. + $whitelist_array[] = $ip; + $new_whitelist = implode( "\n", $whitelist_array ); + + WP_Security_Pack::update_setting( 'ip_whitelist', $new_whitelist ); + + // Also remove from lockouts if present. + $ip_control = wpsp()->get_component( 'ip_control' ); + if ( $ip_control ) { + $ip_control->unblock_ip( $ip ); + } + + wp_send_json_success(); + } + + /** + * AJAX: Run file scan. + */ + public function ajax_run_file_scan() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $file_integrity = new WPSP_File_Integrity(); + $results = $file_integrity->scan_core_files( true ); + + wp_send_json_success( $results ); + } + + /** + * AJAX: Run malware scan. + */ + public function ajax_run_malware_scan() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + // Increase time limit. + set_time_limit( 300 ); + + $scanner = new WPSP_Malware_Scanner(); + $results = $scanner->scan_files(); + + update_option( WPSP_Malware_Scanner::RESULTS_OPTION, $results ); + update_option( WPSP_Malware_Scanner::LAST_SCAN_OPTION, time() ); + + wp_send_json_success( $results ); + } + + /** + * AJAX: Reset file integrity baseline. + */ + public function ajax_reset_file_baseline() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $file_integrity = new WPSP_File_Integrity(); + $file_integrity->reset_baseline(); + + // Run a fresh scan to establish new baseline. + $file_integrity->scan_core_files( true ); + + wp_send_json_success(); + } + + /** + * AJAX: Clear malware scan results. + */ + public function ajax_clear_malware_results() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $scanner = new WPSP_Malware_Scanner(); + $scanner->clear_results(); + + wp_send_json_success(); + } + + /** + * AJAX: Download GeoIP database. + */ + public function ajax_download_geo_db() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $geo_blocking = wpsp()->get_component( 'geo_blocking' ); + if ( ! $geo_blocking ) { + wp_send_json_error( array( 'message' => __( 'Geo blocking not available.', 'wp-security-pack' ) ) ); + } + + $result = $geo_blocking->download_database(); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( array( 'message' => $result->get_error_message() ) ); + } + + wp_send_json_success(); + } + + /** + * AJAX: Send test email. + */ + public function ajax_test_email() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + + if ( empty( $email ) ) { + $email = get_option( 'admin_email' ); + } + + if ( ! is_email( $email ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid email address.', 'wp-security-pack' ) ) ); + } + + $site_name = get_bloginfo( 'name' ); + $subject = sprintf( + /* translators: %s: Site name */ + __( '[%s] WP Security Pack - Test Email', 'wp-security-pack' ), + $site_name + ); + + $message = sprintf( + /* translators: 1: Site name, 2: Site URL */ + __( "This is a test email from WP Security Pack.\n\nIf you received this email, your email alerts are configured correctly.\n\nSite: %1\$s\nURL: %2\$s", 'wp-security-pack' ), + $site_name, + home_url() + ); + + $sent = wp_mail( $email, $subject, $message ); + + if ( $sent ) { + wp_send_json_success(); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to send email. Check your server mail configuration.', 'wp-security-pack' ) ) ); + } + } + + /** + * AJAX: Force logout all users. + */ + public function ajax_force_logout_all() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + // Get all users and destroy their sessions. + $users = get_users( array( 'fields' => 'ID' ) ); + foreach ( $users as $user_id ) { + $sessions = WP_Session_Tokens::get_instance( $user_id ); + $sessions->destroy_all(); + } + + // Log the action. + WPSP_Activity_Log::log( 'force_logout', WPSP_Helper::get_client_ip(), wp_get_current_user()->user_login, __( 'All user sessions terminated', 'wp-security-pack' ) ); + + wp_send_json_success(); + } + + /** + * AJAX: Export settings. + */ + public function ajax_export_settings() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $settings = get_option( 'wpsp_settings', array() ); + + wp_send_json_success( $settings ); + } + + /** + * AJAX: Import settings. + */ + public function ajax_import_settings() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $settings_json = isset( $_POST['settings'] ) ? wp_unslash( $_POST['settings'] ) : ''; + + if ( empty( $settings_json ) ) { + wp_send_json_error( array( 'message' => __( 'No settings data provided.', 'wp-security-pack' ) ) ); + } + + $settings = json_decode( $settings_json, true ); + + if ( null === $settings ) { + wp_send_json_error( array( 'message' => __( 'Invalid JSON format.', 'wp-security-pack' ) ) ); + } + + // Sanitize the imported settings. + $sanitized = $this->sanitize_settings( $settings ); + + update_option( 'wpsp_settings', $sanitized ); + + wp_send_json_success(); + } + + /** + * AJAX: Export logs as CSV. + */ + public function ajax_export_logs() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $logs = WPSP_Activity_Log::get_logs( array( 'limit' => 10000 ) ); + + $csv_lines = array(); + $csv_lines[] = 'Time,Event,IP Address,Country,Username,Details,User Agent'; + + foreach ( $logs as $log ) { + $csv_lines[] = sprintf( + '"%s","%s","%s","%s","%s","%s","%s"', + str_replace( '"', '""', $log->created_at ), + str_replace( '"', '""', WPSP_Activity_Log::get_event_label( $log->event_type ) ), + str_replace( '"', '""', $log->ip_address ), + str_replace( '"', '""', $log->country_code ? $log->country_code : '' ), + str_replace( '"', '""', $log->username ? $log->username : '' ), + str_replace( '"', '""', $log->details ? $log->details : '' ), + str_replace( '"', '""', $log->user_agent ? $log->user_agent : '' ) + ); + } + + wp_send_json_success( array( 'csv' => implode( "\n", $csv_lines ) ) ); + } + + /** + * AJAX: Clear all lockouts. + */ + public function ajax_clear_lockouts() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + global $wpdb; + $table = WPSP_DB::get_lockout_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "TRUNCATE TABLE {$table}" ); + + wp_send_json_success(); + } + + /** + * AJAX: Reset all settings to defaults. + */ + public function ajax_reset_settings() { + check_ajax_referer( 'wpsp_admin' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $defaults = WP_Security_Pack::get_default_settings(); + update_option( 'wpsp_settings', $defaults ); + + wp_send_json_success(); + } +} diff --git a/wp-security-pack/includes/class-wpsp-db.php b/wp-security-pack/includes/class-wpsp-db.php new file mode 100644 index 0000000..bbcbe49 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-db.php @@ -0,0 +1,162 @@ +prefix . 'wpsp_activity_log'; + } + + /** + * Get lockouts table name. + * + * @return string + */ + public static function get_lockout_table() { + global $wpdb; + return $wpdb->prefix . 'wpsp_lockouts'; + } + + /** + * Plugin activation. + */ + public static function activate() { + self::create_tables(); + self::set_default_options(); + + // Store DB version. + update_option( 'wpsp_db_version', self::DB_VERSION ); + + // Clear rewrite rules for custom login URL. + flush_rewrite_rules(); + } + + /** + * Check if database needs upgrading. + */ + public static function maybe_upgrade() { + $current_version = get_option( 'wpsp_db_version', '0' ); + + if ( version_compare( $current_version, self::DB_VERSION, '<' ) ) { + self::create_tables(); + update_option( 'wpsp_db_version', self::DB_VERSION ); + } + } + + /** + * Plugin deactivation. + */ + public static function deactivate() { + // Clear scheduled events. + wp_clear_scheduled_hook( 'wpsp_daily_cleanup' ); + + // Flush rewrite rules. + flush_rewrite_rules(); + } + + /** + * Create database tables. + */ + public static function create_tables() { + global $wpdb; + + $charset_collate = $wpdb->get_charset_collate(); + + $log_table = self::get_log_table(); + $lockout_table = self::get_lockout_table(); + + // Activity log table. + $sql_log = "CREATE TABLE {$log_table} ( + id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + event_type varchar(50) NOT NULL, + ip_address varchar(45) NOT NULL, + username varchar(60) DEFAULT NULL, + user_agent varchar(255) DEFAULT NULL, + country_code varchar(2) DEFAULT NULL, + details text DEFAULT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY event_type (event_type), + KEY ip_address (ip_address), + KEY created_at (created_at) + ) {$charset_collate};"; + + // Lockouts table. + // Note: lockout_until stores Unix timestamp as BIGINT for reliable comparisons. + $sql_lockout = "CREATE TABLE {$lockout_table} ( + id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + ip_address varchar(45) NOT NULL, + failed_attempts int(11) NOT NULL DEFAULT 0, + lockout_until bigint(20) DEFAULT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ip_address (ip_address), + KEY lockout_until (lockout_until) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql_log ); + dbDelta( $sql_lockout ); + } + + /** + * Set default options on activation. + */ + private static function set_default_options() { + $existing = get_option( 'wpsp_settings', false ); + + if ( false === $existing ) { + $defaults = WP_Security_Pack::get_default_settings(); + update_option( 'wpsp_settings', $defaults ); + } + } + + /** + * Uninstall plugin (called from uninstall.php). + */ + public static function uninstall() { + global $wpdb; + + // Drop tables. + $log_table = self::get_log_table(); + $lockout_table = self::get_lockout_table(); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS {$log_table}" ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS {$lockout_table}" ); + + // Delete options. + delete_option( 'wpsp_settings' ); + delete_option( 'wpsp_db_version' ); + + // Delete transients. + delete_transient( 'wpsp_geo_cache' ); + } +} diff --git a/wp-security-pack/includes/class-wpsp-file-integrity.php b/wp-security-pack/includes/class-wpsp-file-integrity.php new file mode 100644 index 0000000..624eba5 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-file-integrity.php @@ -0,0 +1,338 @@ +scan_core_files(); + + if ( ! empty( $changes['modified'] ) || ! empty( $changes['added'] ) || ! empty( $changes['removed'] ) ) { + // Store changes. + update_option( self::CHANGES_OPTION, $changes ); + + // Send alert if enabled. + $this->send_alert( $changes ); + } + } + + /** + * Scan WordPress core files. + * + * @param bool $update_baseline Whether to update the baseline. + * @return array + */ + public function scan_core_files( $update_baseline = false ) { + global $wp_version; + + $changes = array( + 'modified' => array(), + 'added' => array(), + 'removed' => array(), + ); + + // Get stored hashes. + $stored_hashes = get_option( self::HASHES_OPTION, array() ); + + // Get official checksums from WordPress.org. + $official_checksums = $this->get_official_checksums( $wp_version ); + + // Current file hashes. + $current_hashes = array(); + + // Core directories to scan. + $core_paths = array( + ABSPATH . 'wp-admin/', + ABSPATH . 'wp-includes/', + ABSPATH . 'index.php', + ABSPATH . 'wp-activate.php', + ABSPATH . 'wp-blog-header.php', + ABSPATH . 'wp-comments-post.php', + ABSPATH . 'wp-cron.php', + ABSPATH . 'wp-links-opml.php', + ABSPATH . 'wp-load.php', + ABSPATH . 'wp-login.php', + ABSPATH . 'wp-mail.php', + ABSPATH . 'wp-settings.php', + ABSPATH . 'wp-signup.php', + ABSPATH . 'wp-trackback.php', + ABSPATH . 'xmlrpc.php', + ); + + // Scan each path. + foreach ( $core_paths as $path ) { + if ( is_file( $path ) ) { + $relative_path = str_replace( ABSPATH, '', $path ); + $hash = md5_file( $path ); + + $current_hashes[ $relative_path ] = $hash; + } elseif ( is_dir( $path ) ) { + $files = $this->get_directory_files( $path ); + + foreach ( $files as $file ) { + $relative_path = str_replace( ABSPATH, '', $file ); + $hash = md5_file( $file ); + + $current_hashes[ $relative_path ] = $hash; + } + } + } + + // Compare with official checksums if available. + if ( ! empty( $official_checksums ) ) { + foreach ( $current_hashes as $file => $hash ) { + if ( isset( $official_checksums[ $file ] ) ) { + if ( $hash !== $official_checksums[ $file ] ) { + $changes['modified'][] = array( + 'file' => $file, + 'expected' => $official_checksums[ $file ], + 'actual' => $hash, + 'source' => 'official', + ); + } + } + } + } elseif ( ! empty( $stored_hashes ) ) { + // Compare with stored baseline. + foreach ( $current_hashes as $file => $hash ) { + if ( isset( $stored_hashes[ $file ] ) ) { + if ( $hash !== $stored_hashes[ $file ] ) { + $changes['modified'][] = array( + 'file' => $file, + 'previous' => $stored_hashes[ $file ], + 'current' => $hash, + 'source' => 'baseline', + ); + } + } else { + $changes['added'][] = $file; + } + } + + // Check for removed files. + foreach ( $stored_hashes as $file => $hash ) { + if ( ! isset( $current_hashes[ $file ] ) ) { + $changes['removed'][] = $file; + } + } + } + + // Update baseline if requested or first scan. + if ( $update_baseline || empty( $stored_hashes ) ) { + update_option( self::HASHES_OPTION, $current_hashes ); + } + + // Update last scan time. + update_option( self::LAST_SCAN_OPTION, time() ); + + return $changes; + } + + /** + * Get official checksums from WordPress.org. + * + * @param string $version WordPress version. + * @return array + */ + private function get_official_checksums( $version ) { + $locale = get_locale(); + + // Try to get from cache. + $cache_key = 'wpsp_checksums_' . md5( $version . $locale ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + // Fetch from WordPress.org. + $url = sprintf( + 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s', + $version, + $locale + ); + + $response = wp_remote_get( $url, array( 'timeout' => 30 ) ); + + if ( is_wp_error( $response ) ) { + return array(); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( ! isset( $data['checksums'] ) ) { + return array(); + } + + $checksums = $data['checksums']; + + // Cache for 1 day. + set_transient( $cache_key, $checksums, DAY_IN_SECONDS ); + + return $checksums; + } + + /** + * Get all PHP files in a directory recursively. + * + * @param string $dir Directory path. + * @return array + */ + private function get_directory_files( $dir ) { + $files = array(); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $iterator as $file ) { + if ( $file->isFile() ) { + $ext = strtolower( $file->getExtension() ); + // Only track PHP files and critical files. + if ( in_array( $ext, array( 'php', 'js', 'css' ), true ) ) { + $files[] = $file->getPathname(); + } + } + } + + return $files; + } + + /** + * Send email alert about file changes. + * + * @param array $changes File changes. + */ + private function send_alert( $changes ) { + if ( ! WP_Security_Pack::get_setting( 'email_alerts_enabled', false ) ) { + return; + } + + $email = WP_Security_Pack::get_setting( 'email_alerts_address', get_option( 'admin_email' ) ); + $site_name = get_bloginfo( 'name' ); + $site_url = home_url(); + + $subject = sprintf( + /* translators: %s: Site name */ + __( '[%s] File Integrity Alert - Core Files Changed', 'wp-security-pack' ), + $site_name + ); + + $message = sprintf( + /* translators: %s: Site URL */ + __( "WP Security Pack has detected changes to WordPress core files on %s.\n\n", 'wp-security-pack' ), + $site_url + ); + + if ( ! empty( $changes['modified'] ) ) { + $message .= __( "Modified files:\n", 'wp-security-pack' ); + foreach ( $changes['modified'] as $file ) { + $message .= '- ' . ( is_array( $file ) ? $file['file'] : $file ) . "\n"; + } + $message .= "\n"; + } + + if ( ! empty( $changes['added'] ) ) { + $message .= __( "New files detected:\n", 'wp-security-pack' ); + foreach ( $changes['added'] as $file ) { + $message .= '- ' . $file . "\n"; + } + $message .= "\n"; + } + + if ( ! empty( $changes['removed'] ) ) { + $message .= __( "Removed files:\n", 'wp-security-pack' ); + foreach ( $changes['removed'] as $file ) { + $message .= '- ' . $file . "\n"; + } + $message .= "\n"; + } + + $message .= __( "This could indicate:\n", 'wp-security-pack' ); + $message .= __( "- A recent WordPress update (normal)\n", 'wp-security-pack' ); + $message .= __( "- Unauthorized modifications (investigate)\n", 'wp-security-pack' ); + $message .= __( "- Plugin/theme conflicts (rare)\n\n", 'wp-security-pack' ); + $message .= __( "Review these changes in your WordPress admin panel.", 'wp-security-pack' ); + + wp_mail( $email, $subject, $message ); + } + + /** + * Get last scan results. + * + * @return array + */ + public function get_last_scan_results() { + return array( + 'time' => get_option( self::LAST_SCAN_OPTION, 0 ), + 'changes' => get_option( self::CHANGES_OPTION, array() ), + ); + } + + /** + * Clear file changes. + */ + public function clear_changes() { + delete_option( self::CHANGES_OPTION ); + } + + /** + * Reset baseline. + */ + public function reset_baseline() { + delete_option( self::HASHES_OPTION ); + delete_option( self::CHANGES_OPTION ); + } +} diff --git a/wp-security-pack/includes/class-wpsp-geo-blocking.php b/wp-security-pack/includes/class-wpsp-geo-blocking.php new file mode 100644 index 0000000..e85d626 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-geo-blocking.php @@ -0,0 +1,312 @@ +db_path = WP_Security_Pack::get_setting( 'geo_database_path', '' ); + + if ( empty( $this->db_path ) ) { + // Default path in plugin directory. + $this->db_path = WPSP_PLUGIN_DIR . 'data/IP2LOCATION-LITE-DB1.BIN'; + } + + // Check geo-blocking immediately (constructor runs during init). + // This blocks access to the entire website for blocked countries. + if ( WP_Security_Pack::get_setting( 'geo_blocking_enabled', false ) ) { + $this->check_geo_access(); + } + } + + /** + * Check if geo-blocking should apply. + */ + public function check_geo_access() { + $ip = WPSP_Helper::get_client_ip(); + + if ( ! $ip ) { + return; + } + + // Check if IP is whitelisted. + $ip_control = wpsp()->get_component( 'ip_control' ); + if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) { + return; + } + + // Get country code. + $country_code = $this->get_country_code( $ip ); + + if ( ! $country_code ) { + return; // Can't determine country, allow access. + } + + // Check if country is blocked. + $blocked_countries = WP_Security_Pack::get_setting( 'geo_blocked_countries', array() ); + + if ( ! empty( $blocked_countries ) && in_array( $country_code, $blocked_countries, true ) ) { + WPSP_Activity_Log::log( + WPSP_Activity_Log::EVENT_GEO_BLOCKED, + $ip, + null, + sprintf( + /* translators: %s: Country code */ + __( 'Blocked country: %s', 'wp-security-pack' ), + $country_code + ) + ); + + $this->block_access( $country_code ); + } + } + + /** + * Get country code for an IP address. + * + * @param string $ip IP address. + * @return string|null Two-letter country code or null. + */ + public function get_country_code( $ip ) { + // Check cache first. + if ( isset( $this->cache[ $ip ] ) ) { + return $this->cache[ $ip ]; + } + + // Check transient cache. + $cache_key = 'wpsp_geo_' . md5( $ip ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + $this->cache[ $ip ] = $cached; + return $cached; + } + + $country_code = null; + + // Try IP2Location database first. + $country_code = $this->lookup_ip2location( $ip ); + + // Fallback to PHP geoip extension. + if ( ! $country_code && function_exists( 'geoip_country_code_by_name' ) ) { + // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.geoip_country_code_by_nameDeprecated + $country_code = @geoip_country_code_by_name( $ip ); + } + + // Cache the result. + if ( $country_code ) { + $country_code = strtoupper( $country_code ); + $this->cache[ $ip ] = $country_code; + set_transient( $cache_key, $country_code, HOUR_IN_SECONDS ); + } + + return $country_code; + } + + /** + * Lookup IP using IP2Location database. + * + * @param string $ip IP address. + * @return string|null + */ + private function lookup_ip2location( $ip ) { + if ( ! file_exists( $this->db_path ) ) { + return null; + } + + // Load the database reader. + if ( null === $this->db ) { + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-ip2location.php'; + $this->db = new WPSP_IP2Location( $this->db_path ); + } + + if ( ! $this->db ) { + return null; + } + + $record = $this->db->lookup( $ip ); + + if ( $record && ! empty( $record['country_code'] ) && '-' !== $record['country_code'] ) { + return $record['country_code']; + } + + return null; + } + + /** + * Block access for geo-blocked countries. + * + * @param string $country_code Country code. + */ + private function block_access( $country_code ) { + status_header( 403 ); + nocache_headers(); + + $countries = WPSP_Helper::get_countries(); + $country_name = isset( $countries[ $country_code ] ) ? $countries[ $country_code ] : $country_code; + + $message = sprintf( + /* translators: %s: Country name */ + __( 'Access from %s is not permitted.', 'wp-security-pack' ), + $country_name + ); + + if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { + wp_send_json_error( array( 'message' => $message ), 403 ); + } + + wp_die( + esc_html( $message ), + esc_html__( 'Access Denied', 'wp-security-pack' ), + array( + 'response' => 403, + 'back_link' => false, + ) + ); + } + + /** + * Check if geo-database exists. + * + * @return bool + */ + public function database_exists() { + return file_exists( $this->db_path ); + } + + /** + * Get database info. + * + * @return array + */ + public function get_database_info() { + $info = array( + 'exists' => false, + 'path' => $this->db_path, + 'size' => 0, + 'modified' => null, + 'type' => '', + ); + + if ( file_exists( $this->db_path ) ) { + $info['exists'] = true; + $info['size'] = filesize( $this->db_path ); + $info['modified'] = filemtime( $this->db_path ); + + // Try to get database type. + if ( null === $this->db ) { + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-ip2location.php'; + $this->db = new WPSP_IP2Location( $this->db_path ); + } + + if ( $this->db ) { + $info['type'] = $this->db->get_database_type(); + } + } + + return $info; + } + + /** + * Download IP2Location Lite database. + * + * @param string $download_token IP2Location download token (optional for LITE). + * @return bool|WP_Error + */ + public function download_database( $download_token = '' ) { + // IP2Location LITE DB1 (Country only) - free, no token required. + $download_url = 'https://download.ip2location.com/lite/IP2LOCATION-LITE-DB1.BIN.ZIP'; + + // If token provided, use the authenticated endpoint. + if ( ! empty( $download_token ) ) { + $download_url = 'https://www.ip2location.com/download/?token=' . urlencode( $download_token ) . '&file=DB1LITEBIN'; + } + + // Create data directory. + $data_dir = WPSP_PLUGIN_DIR . 'data'; + if ( ! file_exists( $data_dir ) ) { + wp_mkdir_p( $data_dir ); + } + + // Download the file. + $tmp_file = download_url( $download_url, 300 ); + + if ( is_wp_error( $tmp_file ) ) { + return $tmp_file; + } + + // Check if it's a ZIP file. + $finfo = finfo_open( FILEINFO_MIME_TYPE ); + $mime = finfo_file( $finfo, $tmp_file ); + finfo_close( $finfo ); + + $target_path = $data_dir . '/IP2LOCATION-LITE-DB1.BIN'; + + if ( 'application/zip' === $mime || 'application/x-zip-compressed' === $mime ) { + // Extract ZIP. + $zip = new ZipArchive(); + if ( true === $zip->open( $tmp_file ) ) { + // Find the BIN file. + for ( $i = 0; $i < $zip->numFiles; $i++ ) { + $filename = $zip->getNameIndex( $i ); + if ( preg_match( '/\.BIN$/i', $filename ) ) { + $content = $zip->getFromIndex( $i ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $target_path, $content ); + break; + } + } + $zip->close(); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $tmp_file ); + } else { + // Direct BIN file. + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + rename( $tmp_file, $target_path ); + } + + if ( file_exists( $target_path ) ) { + // Update database path setting. + WP_Security_Pack::update_setting( 'geo_database_path', $target_path ); + return true; + } + + return new WP_Error( 'download_failed', __( 'Failed to download or extract database.', 'wp-security-pack' ) ); + } +} diff --git a/wp-security-pack/includes/class-wpsp-hardening.php b/wp-security-pack/includes/class-wpsp-hardening.php new file mode 100644 index 0000000..1631ed6 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-hardening.php @@ -0,0 +1,331 @@ +disable_file_editing(); + } + + // Remove WordPress version. + if ( WP_Security_Pack::get_setting( 'remove_wp_version', true ) ) { + add_filter( 'the_generator', '__return_empty_string' ); + remove_action( 'wp_head', 'wp_generator' ); + add_filter( 'style_loader_src', array( $this, 'remove_version_strings' ), 10, 2 ); + add_filter( 'script_loader_src', array( $this, 'remove_version_strings' ), 10, 2 ); + } + + // Add security headers. + if ( WP_Security_Pack::get_setting( 'add_security_headers', true ) ) { + add_action( 'send_headers', array( $this, 'add_security_headers' ) ); + } + + // Restrict REST API. + if ( WP_Security_Pack::get_setting( 'restrict_rest_api', true ) ) { + add_filter( 'rest_authentication_errors', array( $this, 'restrict_rest_api' ) ); + } + + // Disable application passwords for non-admins. + if ( WP_Security_Pack::get_setting( 'disable_application_passwords', false ) ) { + add_filter( 'wp_is_application_passwords_available', '__return_false' ); + } + + // Remove unnecessary headers. + add_action( 'init', array( $this, 'remove_unnecessary_headers' ) ); + + // Disable user enumeration. + if ( WP_Security_Pack::get_setting( 'disable_user_enumeration', true ) ) { + add_action( 'init', array( $this, 'block_author_scanning' ) ); + add_filter( 'rest_endpoints', array( $this, 'restrict_users_endpoint' ) ); + add_filter( 'oembed_response_data', array( $this, 'remove_author_from_oembed' ) ); + } + + // Disable pingbacks/trackbacks. + if ( WP_Security_Pack::get_setting( 'disable_pingbacks', true ) ) { + add_filter( 'xmlrpc_methods', array( $this, 'disable_pingback_methods' ) ); + add_filter( 'wp_headers', array( $this, 'remove_pingback_header' ) ); + add_filter( 'pings_open', '__return_false', 20, 2 ); + } + } + + /** + * Remove XML-RPC related headers. + * + * @param array $headers HTTP headers. + * @return array + */ + public function remove_xmlrpc_headers( $headers ) { + unset( $headers['X-Pingback'] ); + return $headers; + } + + /** + * Block direct XML-RPC requests. + */ + public function block_xmlrpc_requests() { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; + + if ( strpos( $request_uri, 'xmlrpc.php' ) !== false ) { + status_header( 403 ); + exit( 'XML-RPC is disabled.' ); + } + } + + /** + * Disable file editing in dashboard. + */ + private function disable_file_editing() { + if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) { + define( 'DISALLOW_FILE_EDIT', true ); + } + } + + /** + * Remove version strings from scripts and styles. + * + * @param string $src Source URL. + * @param string $handle Handle name. + * @return string + */ + public function remove_version_strings( $src, $handle ) { + if ( strpos( $src, 'ver=' ) !== false ) { + $src = remove_query_arg( 'ver', $src ); + } + return $src; + } + + /** + * Add security headers. + */ + public function add_security_headers() { + // Don't add headers for admin pages if user is logged in. + if ( is_admin() && is_user_logged_in() ) { + // Still add some basic headers. + header( 'X-Content-Type-Options: nosniff' ); + return; + } + + // Get header settings. + $headers = WP_Security_Pack::get_setting( 'security_headers', $this->get_default_headers() ); + + // X-Content-Type-Options. + if ( ! empty( $headers['x_content_type_options'] ) ) { + header( 'X-Content-Type-Options: ' . $headers['x_content_type_options'] ); + } + + // X-Frame-Options. + if ( ! empty( $headers['x_frame_options'] ) ) { + header( 'X-Frame-Options: ' . $headers['x_frame_options'] ); + } + + // X-XSS-Protection. + if ( ! empty( $headers['x_xss_protection'] ) ) { + header( 'X-XSS-Protection: ' . $headers['x_xss_protection'] ); + } + + // Referrer-Policy. + if ( ! empty( $headers['referrer_policy'] ) ) { + header( 'Referrer-Policy: ' . $headers['referrer_policy'] ); + } + + // Permissions-Policy. + if ( ! empty( $headers['permissions_policy'] ) ) { + header( 'Permissions-Policy: ' . $headers['permissions_policy'] ); + } + + // Content-Security-Policy. + if ( ! empty( $headers['content_security_policy'] ) ) { + header( 'Content-Security-Policy: ' . $headers['content_security_policy'] ); + } + + // Strict-Transport-Security (HSTS). + if ( ! empty( $headers['strict_transport_security'] ) && is_ssl() ) { + header( 'Strict-Transport-Security: ' . $headers['strict_transport_security'] ); + } + } + + /** + * Get default security headers. + * + * @return array + */ + public function get_default_headers() { + return array( + 'x_content_type_options' => 'nosniff', + 'x_frame_options' => 'SAMEORIGIN', + 'x_xss_protection' => '1; mode=block', + 'referrer_policy' => 'strict-origin-when-cross-origin', + 'permissions_policy' => 'geolocation=(), microphone=(), camera=()', + 'content_security_policy' => '', + 'strict_transport_security' => 'max-age=31536000; includeSubDomains', + ); + } + + /** + * Restrict REST API to authenticated users. + * + * @param WP_Error|null|bool $result Authentication result. + * @return WP_Error|null|bool + */ + public function restrict_rest_api( $result ) { + // If there's already an error, return it. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Allow if user is logged in. + if ( is_user_logged_in() ) { + return $result; + } + + // Get allowed REST routes. + $allowed_routes = WP_Security_Pack::get_setting( 'rest_api_allowed_routes', array() ); + + // Always allow some essential routes. + $essential_routes = array( + '/wp/v2/oembed', + '/wp-site-health', + ); + + $allowed_routes = array_merge( $allowed_routes, $essential_routes ); + + // Get current route. + $current_route = $GLOBALS['wp']->query_vars['rest_route'] ?? ''; + + // Check if current route is allowed. + foreach ( $allowed_routes as $route ) { + if ( strpos( $current_route, $route ) === 0 ) { + return $result; + } + } + + // Block unauthenticated access. + return new WP_Error( + 'rest_not_logged_in', + __( 'You must be authenticated to access this endpoint.', 'wp-security-pack' ), + array( 'status' => 401 ) + ); + } + + /** + * Remove unnecessary headers. + */ + public function remove_unnecessary_headers() { + // Remove Really Simple Discovery link. + remove_action( 'wp_head', 'rsd_link' ); + + // Remove Windows Live Writer manifest link. + remove_action( 'wp_head', 'wlwmanifest_link' ); + + // Remove shortlink. + remove_action( 'wp_head', 'wp_shortlink_wp_head' ); + + // Remove feed links. + if ( WP_Security_Pack::get_setting( 'remove_feed_links', false ) ) { + remove_action( 'wp_head', 'feed_links', 2 ); + remove_action( 'wp_head', 'feed_links_extra', 3 ); + } + } + + /** + * Block author scanning via ?author=N URLs. + */ + public function block_author_scanning() { + if ( is_admin() ) { + return; + } + + // Block ?author=N requests for non-logged-in users. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! is_user_logged_in() && isset( $_GET['author'] ) ) { + wp_safe_redirect( home_url(), 301 ); + exit; + } + } + + /** + * Restrict REST API users endpoint to authenticated users. + * + * @param array $endpoints REST API endpoints. + * @return array + */ + public function restrict_users_endpoint( $endpoints ) { + if ( is_user_logged_in() ) { + return $endpoints; + } + + // Remove users endpoint for unauthenticated requests. + if ( isset( $endpoints['/wp/v2/users'] ) ) { + unset( $endpoints['/wp/v2/users'] ); + } + if ( isset( $endpoints['/wp/v2/users/(?P[\d]+)'] ) ) { + unset( $endpoints['/wp/v2/users/(?P[\d]+)'] ); + } + + return $endpoints; + } + + /** + * Remove author information from oEmbed responses. + * + * @param array $data oEmbed response data. + * @return array + */ + public function remove_author_from_oembed( $data ) { + if ( isset( $data['author_name'] ) ) { + unset( $data['author_name'] ); + } + if ( isset( $data['author_url'] ) ) { + unset( $data['author_url'] ); + } + return $data; + } + + /** + * Disable pingback XML-RPC methods. + * + * @param array $methods XML-RPC methods. + * @return array + */ + public function disable_pingback_methods( $methods ) { + unset( $methods['pingback.ping'] ); + unset( $methods['pingback.extensions.getPingbacks'] ); + return $methods; + } + + /** + * Remove X-Pingback header. + * + * @param array $headers HTTP headers. + * @return array + */ + public function remove_pingback_header( $headers ) { + unset( $headers['X-Pingback'] ); + return $headers; + } +} diff --git a/wp-security-pack/includes/class-wpsp-helper.php b/wp-security-pack/includes/class-wpsp-helper.php new file mode 100644 index 0000000..cbd1f46 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-helper.php @@ -0,0 +1,506 @@ + 0 && $full_bytes < 16 ) { + $bit_mask = ( ( 1 << $remaining_bits ) - 1 ) << ( 8 - $remaining_bits ); + if ( ( ord( $ip_bin[ $full_bytes ] ) & $bit_mask ) !== ( ord( $network_bin[ $full_bytes ] ) & $bit_mask ) ) { + return false; + } + } + + return true; + } + + /** + * Check if an IP matches any rule in a list. + * + * @param string $ip IP address to check. + * @param array $rules Array of IP addresses or CIDR ranges. + * @return bool + */ + public static function ip_matches_rules( $ip, $rules ) { + if ( empty( $rules ) || ! is_array( $rules ) ) { + return false; + } + + foreach ( $rules as $rule ) { + $rule = trim( $rule ); + if ( empty( $rule ) || strpos( $rule, '#' ) === 0 ) { + continue; // Skip empty lines and comments. + } + + if ( self::ip_in_cidr( $ip, $rule ) ) { + return true; + } + } + + return false; + } + + /** + * Parse IP list from textarea. + * + * @param string $text Textarea content with IPs/CIDRs. + * @return array + */ + public static function parse_ip_list( $text ) { + if ( empty( $text ) ) { + return array(); + } + + $lines = explode( "\n", $text ); + $ips = array(); + + foreach ( $lines as $line ) { + $line = trim( $line ); + + // Skip empty lines and comments. + if ( empty( $line ) || strpos( $line, '#' ) === 0 ) { + continue; + } + + // Remove inline comments. + if ( strpos( $line, '#' ) !== false ) { + $line = trim( substr( $line, 0, strpos( $line, '#' ) ) ); + } + + // Validate IP or CIDR. + if ( self::is_valid_ip_or_cidr( $line ) ) { + $ips[] = $line; + } + } + + return $ips; + } + + /** + * Check if a string is a valid IP or CIDR. + * + * @param string $value Value to check. + * @return bool + */ + public static function is_valid_ip_or_cidr( $value ) { + // Plain IP. + if ( filter_var( $value, FILTER_VALIDATE_IP ) ) { + return true; + } + + // CIDR notation. + if ( strpos( $value, '/' ) !== false ) { + list( $ip, $mask ) = explode( '/', $value ); + + if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + return false; + } + + $mask = (int) $mask; + $is_ipv6 = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ); + $max_mask = $is_ipv6 ? 128 : 32; + + return $mask >= 0 && $mask <= $max_mask; + } + + return false; + } + + /** + * Get country list for geo-blocking UI. + * + * @return array + */ + public static function get_countries() { + return array( + 'AF' => __( 'Afghanistan', 'wp-security-pack' ), + 'AL' => __( 'Albania', 'wp-security-pack' ), + 'DZ' => __( 'Algeria', 'wp-security-pack' ), + 'AS' => __( 'American Samoa', 'wp-security-pack' ), + 'AD' => __( 'Andorra', 'wp-security-pack' ), + 'AO' => __( 'Angola', 'wp-security-pack' ), + 'AI' => __( 'Anguilla', 'wp-security-pack' ), + 'AQ' => __( 'Antarctica', 'wp-security-pack' ), + 'AG' => __( 'Antigua and Barbuda', 'wp-security-pack' ), + 'AR' => __( 'Argentina', 'wp-security-pack' ), + 'AM' => __( 'Armenia', 'wp-security-pack' ), + 'AW' => __( 'Aruba', 'wp-security-pack' ), + 'AU' => __( 'Australia', 'wp-security-pack' ), + 'AT' => __( 'Austria', 'wp-security-pack' ), + 'AZ' => __( 'Azerbaijan', 'wp-security-pack' ), + 'BS' => __( 'Bahamas', 'wp-security-pack' ), + 'BH' => __( 'Bahrain', 'wp-security-pack' ), + 'BD' => __( 'Bangladesh', 'wp-security-pack' ), + 'BB' => __( 'Barbados', 'wp-security-pack' ), + 'BY' => __( 'Belarus', 'wp-security-pack' ), + 'BE' => __( 'Belgium', 'wp-security-pack' ), + 'BZ' => __( 'Belize', 'wp-security-pack' ), + 'BJ' => __( 'Benin', 'wp-security-pack' ), + 'BM' => __( 'Bermuda', 'wp-security-pack' ), + 'BT' => __( 'Bhutan', 'wp-security-pack' ), + 'BO' => __( 'Bolivia', 'wp-security-pack' ), + 'BA' => __( 'Bosnia and Herzegovina', 'wp-security-pack' ), + 'BW' => __( 'Botswana', 'wp-security-pack' ), + 'BR' => __( 'Brazil', 'wp-security-pack' ), + 'BN' => __( 'Brunei', 'wp-security-pack' ), + 'BG' => __( 'Bulgaria', 'wp-security-pack' ), + 'BF' => __( 'Burkina Faso', 'wp-security-pack' ), + 'BI' => __( 'Burundi', 'wp-security-pack' ), + 'KH' => __( 'Cambodia', 'wp-security-pack' ), + 'CM' => __( 'Cameroon', 'wp-security-pack' ), + 'CA' => __( 'Canada', 'wp-security-pack' ), + 'CV' => __( 'Cape Verde', 'wp-security-pack' ), + 'KY' => __( 'Cayman Islands', 'wp-security-pack' ), + 'CF' => __( 'Central African Republic', 'wp-security-pack' ), + 'TD' => __( 'Chad', 'wp-security-pack' ), + 'CL' => __( 'Chile', 'wp-security-pack' ), + 'CN' => __( 'China', 'wp-security-pack' ), + 'CO' => __( 'Colombia', 'wp-security-pack' ), + 'KM' => __( 'Comoros', 'wp-security-pack' ), + 'CG' => __( 'Congo', 'wp-security-pack' ), + 'CD' => __( 'Congo (DRC)', 'wp-security-pack' ), + 'CR' => __( 'Costa Rica', 'wp-security-pack' ), + 'CI' => __( 'Ivory Coast', 'wp-security-pack' ), + 'HR' => __( 'Croatia', 'wp-security-pack' ), + 'CU' => __( 'Cuba', 'wp-security-pack' ), + 'CY' => __( 'Cyprus', 'wp-security-pack' ), + 'CZ' => __( 'Czech Republic', 'wp-security-pack' ), + 'DK' => __( 'Denmark', 'wp-security-pack' ), + 'DJ' => __( 'Djibouti', 'wp-security-pack' ), + 'DM' => __( 'Dominica', 'wp-security-pack' ), + 'DO' => __( 'Dominican Republic', 'wp-security-pack' ), + 'EC' => __( 'Ecuador', 'wp-security-pack' ), + 'EG' => __( 'Egypt', 'wp-security-pack' ), + 'SV' => __( 'El Salvador', 'wp-security-pack' ), + 'GQ' => __( 'Equatorial Guinea', 'wp-security-pack' ), + 'ER' => __( 'Eritrea', 'wp-security-pack' ), + 'EE' => __( 'Estonia', 'wp-security-pack' ), + 'ET' => __( 'Ethiopia', 'wp-security-pack' ), + 'FJ' => __( 'Fiji', 'wp-security-pack' ), + 'FI' => __( 'Finland', 'wp-security-pack' ), + 'FR' => __( 'France', 'wp-security-pack' ), + 'GA' => __( 'Gabon', 'wp-security-pack' ), + 'GM' => __( 'Gambia', 'wp-security-pack' ), + 'GE' => __( 'Georgia', 'wp-security-pack' ), + 'DE' => __( 'Germany', 'wp-security-pack' ), + 'GH' => __( 'Ghana', 'wp-security-pack' ), + 'GR' => __( 'Greece', 'wp-security-pack' ), + 'GL' => __( 'Greenland', 'wp-security-pack' ), + 'GD' => __( 'Grenada', 'wp-security-pack' ), + 'GU' => __( 'Guam', 'wp-security-pack' ), + 'GT' => __( 'Guatemala', 'wp-security-pack' ), + 'GN' => __( 'Guinea', 'wp-security-pack' ), + 'GW' => __( 'Guinea-Bissau', 'wp-security-pack' ), + 'GY' => __( 'Guyana', 'wp-security-pack' ), + 'HT' => __( 'Haiti', 'wp-security-pack' ), + 'HN' => __( 'Honduras', 'wp-security-pack' ), + 'HK' => __( 'Hong Kong', 'wp-security-pack' ), + 'HU' => __( 'Hungary', 'wp-security-pack' ), + 'IS' => __( 'Iceland', 'wp-security-pack' ), + 'IN' => __( 'India', 'wp-security-pack' ), + 'ID' => __( 'Indonesia', 'wp-security-pack' ), + 'IR' => __( 'Iran', 'wp-security-pack' ), + 'IQ' => __( 'Iraq', 'wp-security-pack' ), + 'IE' => __( 'Ireland', 'wp-security-pack' ), + 'IL' => __( 'Israel', 'wp-security-pack' ), + 'IT' => __( 'Italy', 'wp-security-pack' ), + 'JM' => __( 'Jamaica', 'wp-security-pack' ), + 'JP' => __( 'Japan', 'wp-security-pack' ), + 'JO' => __( 'Jordan', 'wp-security-pack' ), + 'KZ' => __( 'Kazakhstan', 'wp-security-pack' ), + 'KE' => __( 'Kenya', 'wp-security-pack' ), + 'KI' => __( 'Kiribati', 'wp-security-pack' ), + 'KP' => __( 'North Korea', 'wp-security-pack' ), + 'KR' => __( 'South Korea', 'wp-security-pack' ), + 'KW' => __( 'Kuwait', 'wp-security-pack' ), + 'KG' => __( 'Kyrgyzstan', 'wp-security-pack' ), + 'LA' => __( 'Laos', 'wp-security-pack' ), + 'LV' => __( 'Latvia', 'wp-security-pack' ), + 'LB' => __( 'Lebanon', 'wp-security-pack' ), + 'LS' => __( 'Lesotho', 'wp-security-pack' ), + 'LR' => __( 'Liberia', 'wp-security-pack' ), + 'LY' => __( 'Libya', 'wp-security-pack' ), + 'LI' => __( 'Liechtenstein', 'wp-security-pack' ), + 'LT' => __( 'Lithuania', 'wp-security-pack' ), + 'LU' => __( 'Luxembourg', 'wp-security-pack' ), + 'MO' => __( 'Macao', 'wp-security-pack' ), + 'MK' => __( 'North Macedonia', 'wp-security-pack' ), + 'MG' => __( 'Madagascar', 'wp-security-pack' ), + 'MW' => __( 'Malawi', 'wp-security-pack' ), + 'MY' => __( 'Malaysia', 'wp-security-pack' ), + 'MV' => __( 'Maldives', 'wp-security-pack' ), + 'ML' => __( 'Mali', 'wp-security-pack' ), + 'MT' => __( 'Malta', 'wp-security-pack' ), + 'MH' => __( 'Marshall Islands', 'wp-security-pack' ), + 'MR' => __( 'Mauritania', 'wp-security-pack' ), + 'MU' => __( 'Mauritius', 'wp-security-pack' ), + 'MX' => __( 'Mexico', 'wp-security-pack' ), + 'FM' => __( 'Micronesia', 'wp-security-pack' ), + 'MD' => __( 'Moldova', 'wp-security-pack' ), + 'MC' => __( 'Monaco', 'wp-security-pack' ), + 'MN' => __( 'Mongolia', 'wp-security-pack' ), + 'ME' => __( 'Montenegro', 'wp-security-pack' ), + 'MA' => __( 'Morocco', 'wp-security-pack' ), + 'MZ' => __( 'Mozambique', 'wp-security-pack' ), + 'MM' => __( 'Myanmar', 'wp-security-pack' ), + 'NA' => __( 'Namibia', 'wp-security-pack' ), + 'NR' => __( 'Nauru', 'wp-security-pack' ), + 'NP' => __( 'Nepal', 'wp-security-pack' ), + 'NL' => __( 'Netherlands', 'wp-security-pack' ), + 'NZ' => __( 'New Zealand', 'wp-security-pack' ), + 'NI' => __( 'Nicaragua', 'wp-security-pack' ), + 'NE' => __( 'Niger', 'wp-security-pack' ), + 'NG' => __( 'Nigeria', 'wp-security-pack' ), + 'NO' => __( 'Norway', 'wp-security-pack' ), + 'OM' => __( 'Oman', 'wp-security-pack' ), + 'PK' => __( 'Pakistan', 'wp-security-pack' ), + 'PW' => __( 'Palau', 'wp-security-pack' ), + 'PS' => __( 'Palestine', 'wp-security-pack' ), + 'PA' => __( 'Panama', 'wp-security-pack' ), + 'PG' => __( 'Papua New Guinea', 'wp-security-pack' ), + 'PY' => __( 'Paraguay', 'wp-security-pack' ), + 'PE' => __( 'Peru', 'wp-security-pack' ), + 'PH' => __( 'Philippines', 'wp-security-pack' ), + 'PL' => __( 'Poland', 'wp-security-pack' ), + 'PT' => __( 'Portugal', 'wp-security-pack' ), + 'PR' => __( 'Puerto Rico', 'wp-security-pack' ), + 'QA' => __( 'Qatar', 'wp-security-pack' ), + 'RO' => __( 'Romania', 'wp-security-pack' ), + 'RU' => __( 'Russia', 'wp-security-pack' ), + 'RW' => __( 'Rwanda', 'wp-security-pack' ), + 'SA' => __( 'Saudi Arabia', 'wp-security-pack' ), + 'SN' => __( 'Senegal', 'wp-security-pack' ), + 'RS' => __( 'Serbia', 'wp-security-pack' ), + 'SC' => __( 'Seychelles', 'wp-security-pack' ), + 'SL' => __( 'Sierra Leone', 'wp-security-pack' ), + 'SG' => __( 'Singapore', 'wp-security-pack' ), + 'SK' => __( 'Slovakia', 'wp-security-pack' ), + 'SI' => __( 'Slovenia', 'wp-security-pack' ), + 'SB' => __( 'Solomon Islands', 'wp-security-pack' ), + 'SO' => __( 'Somalia', 'wp-security-pack' ), + 'ZA' => __( 'South Africa', 'wp-security-pack' ), + 'SS' => __( 'South Sudan', 'wp-security-pack' ), + 'ES' => __( 'Spain', 'wp-security-pack' ), + 'LK' => __( 'Sri Lanka', 'wp-security-pack' ), + 'SD' => __( 'Sudan', 'wp-security-pack' ), + 'SR' => __( 'Suriname', 'wp-security-pack' ), + 'SZ' => __( 'Eswatini', 'wp-security-pack' ), + 'SE' => __( 'Sweden', 'wp-security-pack' ), + 'CH' => __( 'Switzerland', 'wp-security-pack' ), + 'SY' => __( 'Syria', 'wp-security-pack' ), + 'TW' => __( 'Taiwan', 'wp-security-pack' ), + 'TJ' => __( 'Tajikistan', 'wp-security-pack' ), + 'TZ' => __( 'Tanzania', 'wp-security-pack' ), + 'TH' => __( 'Thailand', 'wp-security-pack' ), + 'TL' => __( 'Timor-Leste', 'wp-security-pack' ), + 'TG' => __( 'Togo', 'wp-security-pack' ), + 'TO' => __( 'Tonga', 'wp-security-pack' ), + 'TT' => __( 'Trinidad and Tobago', 'wp-security-pack' ), + 'TN' => __( 'Tunisia', 'wp-security-pack' ), + 'TR' => __( 'Turkey', 'wp-security-pack' ), + 'TM' => __( 'Turkmenistan', 'wp-security-pack' ), + 'TV' => __( 'Tuvalu', 'wp-security-pack' ), + 'UG' => __( 'Uganda', 'wp-security-pack' ), + 'UA' => __( 'Ukraine', 'wp-security-pack' ), + 'AE' => __( 'United Arab Emirates', 'wp-security-pack' ), + 'GB' => __( 'United Kingdom', 'wp-security-pack' ), + 'US' => __( 'United States', 'wp-security-pack' ), + 'UY' => __( 'Uruguay', 'wp-security-pack' ), + 'UZ' => __( 'Uzbekistan', 'wp-security-pack' ), + 'VU' => __( 'Vanuatu', 'wp-security-pack' ), + 'VE' => __( 'Venezuela', 'wp-security-pack' ), + 'VN' => __( 'Vietnam', 'wp-security-pack' ), + 'YE' => __( 'Yemen', 'wp-security-pack' ), + 'ZM' => __( 'Zambia', 'wp-security-pack' ), + 'ZW' => __( 'Zimbabwe', 'wp-security-pack' ), + ); + } + + /** + * Sanitize a custom login URL slug. + * + * @param string $slug The slug to sanitize. + * @return string + */ + public static function sanitize_login_slug( $slug ) { + $slug = sanitize_title( $slug ); + $slug = preg_replace( '/[^a-z0-9\-]/', '', $slug ); + + // Prevent common reserved slugs. + $reserved = array( 'wp-admin', 'wp-login', 'admin', 'login', 'wp-content', 'wp-includes' ); + if ( in_array( $slug, $reserved, true ) ) { + return ''; + } + + return $slug; + } + + /** + * Get country flag emoji from country code. + * + * Converts ISO 3166-1 alpha-2 country codes to Unicode flag emojis. + * + * @param string $country_code Two-letter country code (e.g., "US", "GB"). + * @return string Flag emoji or globe emoji for invalid codes. + */ + public static function get_country_flag( $country_code ) { + if ( empty( $country_code ) || strlen( $country_code ) !== 2 ) { + return '🌐'; // Globe emoji for invalid codes. + } + + $country_code = strtoupper( $country_code ); + + // Convert country code to Unicode regional indicator symbols. + // Regional indicators are U+1F1E6 (A) through U+1F1FF (Z). + $first_letter = mb_chr( ord( $country_code[0] ) - ord( 'A' ) + 0x1F1E6, 'UTF-8' ); + $second_letter = mb_chr( ord( $country_code[1] ) - ord( 'A' ) + 0x1F1E6, 'UTF-8' ); + + return $first_letter . $second_letter; + } + + /** + * Get countries array with flags. + * + * @return array Country code => "Flag Name" format. + */ + public static function get_countries_with_flags() { + $countries = self::get_countries(); + $countries_flags = array(); + + foreach ( $countries as $code => $name ) { + $flag = self::get_country_flag( $code ); + $countries_flags[ $code ] = $flag . ' ' . $name; + } + + return $countries_flags; + } +} diff --git a/wp-security-pack/includes/class-wpsp-ip-control.php b/wp-security-pack/includes/class-wpsp-ip-control.php new file mode 100644 index 0000000..caf4e42 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-ip-control.php @@ -0,0 +1,326 @@ +check_ip_access(); + } + + /** + * Check IP access on every request. + */ + public function check_ip_access() { + $ip = WPSP_Helper::get_client_ip(); + + if ( ! $ip ) { + return; + } + + // Always allow whitelisted IPs. + if ( $this->is_whitelisted( $ip ) ) { + return; + } + + // Block blacklisted IPs. + if ( $this->is_blacklisted( $ip ) ) { + WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_IP_BLOCKED, $ip, null, __( 'IP blacklisted', 'wp-security-pack' ) ); + $this->block_access( __( 'Your IP address has been blocked.', 'wp-security-pack' ) ); + } + + // Check auto-blocked IPs. + if ( $this->is_auto_blocked( $ip ) ) { + WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_IP_BLOCKED, $ip, null, __( 'IP auto-blocked', 'wp-security-pack' ) ); + $this->block_access( __( 'Your IP address has been temporarily blocked due to suspicious activity.', 'wp-security-pack' ) ); + } + } + + /** + * Check if IP is whitelisted. + * + * @param string $ip IP address to check. + * @return bool + */ + public function is_whitelisted( $ip ) { + $whitelist = $this->get_whitelist(); + return WPSP_Helper::ip_matches_rules( $ip, $whitelist ); + } + + /** + * Check if IP is blacklisted. + * + * @param string $ip IP address to check. + * @return bool + */ + public function is_blacklisted( $ip ) { + $blacklist = $this->get_blacklist(); + return WPSP_Helper::ip_matches_rules( $ip, $blacklist ); + } + + /** + * Check if IP is auto-blocked (temporary block from failed logins). + * + * @param string $ip IP address to check. + * @return bool + */ + public function is_auto_blocked( $ip ) { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + $max_attempts = (int) WP_Security_Pack::get_setting( 'login_max_attempts', 5 ); + $lockout_minutes = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $lockout = $wpdb->get_row( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE ip_address = %s", + $ip + ) + ); + + if ( ! $lockout ) { + return false; + } + + // Check if IP has reached max attempts and is still within lockout window. + if ( (int) $lockout->failed_attempts >= $max_attempts ) { + // Calculate if lockout is still active based on updated_at + duration. + $updated_time = strtotime( $lockout->updated_at ); + $lockout_expires = $updated_time + ( $lockout_minutes * 60 ); + $current_time = time(); + + if ( $current_time < $lockout_expires ) { + return true; // Still locked out. + } + } + + // Also check lockout_until for honeypot/manual blocks (stored as Unix timestamp). + if ( ! empty( $lockout->lockout_until ) && is_numeric( $lockout->lockout_until ) ) { + if ( time() < (int) $lockout->lockout_until ) { + return true; // Still locked out via lockout_until. + } + } + + return false; + } + + /** + * Get whitelist IPs. + * + * @return array + */ + public function get_whitelist() { + if ( null === $this->whitelist_cache ) { + $whitelist_text = WP_Security_Pack::get_setting( 'ip_whitelist', '' ); + $this->whitelist_cache = WPSP_Helper::parse_ip_list( $whitelist_text ); + } + return $this->whitelist_cache; + } + + /** + * Get blacklist IPs. + * + * @return array + */ + public function get_blacklist() { + if ( null === $this->blacklist_cache ) { + $blacklist_text = WP_Security_Pack::get_setting( 'ip_blacklist', '' ); + $this->blacklist_cache = WPSP_Helper::parse_ip_list( $blacklist_text ); + } + return $this->blacklist_cache; + } + + /** + * Clear internal caches (call after modifying whitelist/blacklist). + */ + public function clear_cache() { + $this->whitelist_cache = null; + $this->blacklist_cache = null; + } + + /** + * Add IP to auto-block list (temporary lockout). + * + * @param string $ip IP address. + * @param int $duration Duration in minutes. + * @param string $reason Reason for blocking. + * @return bool + */ + public function auto_block_ip( $ip, $duration = 15, $reason = '' ) { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + // Store as Unix timestamp for consistency with lockout_ip(). + $lockout_until = time() + ( $duration * 60 ); + + // Check if already exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $existing = $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT id FROM {$table} WHERE ip_address = %s", + $ip + ) + ); + + if ( $existing ) { + // Update existing record. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return false !== $wpdb->update( + $table, + array( + 'lockout_until' => $lockout_until, + 'updated_at' => current_time( 'mysql' ), + ), + array( 'ip_address' => $ip ), + array( '%d', '%s' ), + array( '%s' ) + ); + } + + // Insert new record. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return false !== $wpdb->insert( + $table, + array( + 'ip_address' => $ip, + 'failed_attempts' => 0, + 'lockout_until' => $lockout_until, + 'created_at' => current_time( 'mysql' ), + 'updated_at' => current_time( 'mysql' ), + ), + array( '%s', '%d', '%d', '%s', '%s' ) + ); + } + + /** + * Remove IP from auto-block list. + * + * @param string $ip IP address. + * @return bool + */ + public function unblock_ip( $ip ) { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return false !== $wpdb->delete( + $table, + array( 'ip_address' => $ip ), + array( '%s' ) + ); + } + + /** + * Get all currently blocked IPs. + * + * @return array + */ + public function get_blocked_ips() { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + $max_attempts = (int) WP_Security_Pack::get_setting( 'login_max_attempts', 5 ); + $lockout_minutes = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 ); + $current_time = time(); + + // Get all lockout records. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $lockouts = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} ORDER BY updated_at DESC" + ); + + $blocked = array(); + foreach ( $lockouts as $lockout ) { + $is_blocked = false; + $lockout_expires = 0; + + // Check login-based lockout (failed_attempts >= max and within time window). + if ( (int) $lockout->failed_attempts >= $max_attempts ) { + $updated_time = strtotime( $lockout->updated_at ); + $lockout_expires = $updated_time + ( $lockout_minutes * 60 ); + if ( $current_time < $lockout_expires ) { + $is_blocked = true; + } + } + + // Check honeypot/manual lockout (lockout_until as Unix timestamp). + if ( ! empty( $lockout->lockout_until ) && is_numeric( $lockout->lockout_until ) ) { + $lockout_until_ts = (int) $lockout->lockout_until; + if ( $current_time < $lockout_until_ts ) { + $is_blocked = true; + // Use the later expiry time. + if ( $lockout_until_ts > $lockout_expires ) { + $lockout_expires = $lockout_until_ts; + } + } + } + + if ( $is_blocked ) { + // Add computed lockout_until field for display. + $lockout->lockout_until = gmdate( 'Y-m-d H:i:s', $lockout_expires ); + $blocked[] = $lockout; + } + } + + return $blocked; + } + + /** + * Block access and exit. + * + * @param string $message Error message. + */ + private function block_access( $message ) { + status_header( 403 ); + nocache_headers(); + + if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { + wp_send_json_error( array( 'message' => $message ), 403 ); + } + + // Simple blocked page. + wp_die( + esc_html( $message ), + esc_html__( 'Access Denied', 'wp-security-pack' ), + array( + 'response' => 403, + 'back_link' => false, + ) + ); + } +} diff --git a/wp-security-pack/includes/class-wpsp-ip2location.php b/wp-security-pack/includes/class-wpsp-ip2location.php new file mode 100644 index 0000000..11dc681 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-ip2location.php @@ -0,0 +1,384 @@ +handle = fopen( $db_path, 'rb' ); + + if ( ! $this->handle ) { + return; + } + + // Read database header. + $this->db_type = $this->read_byte( 1 ); + $this->db_column = $this->read_byte( 2 ); + $this->db_year = $this->read_byte( 3 ); + $this->db_month = $this->read_byte( 4 ); + $this->db_day = $this->read_byte( 5 ); + + $this->ipv4_count = $this->read_word( 6 ); + $this->ipv4_addr = $this->read_word( 10 ); + $this->ipv6_count = $this->read_word( 14 ); + $this->ipv6_addr = $this->read_word( 18 ); + + $this->ipv4_index_addr = $this->read_word( 22 ); + $this->ipv6_index_addr = $this->read_word( 26 ); + } + + /** + * Destructor. + */ + public function __destruct() { + if ( $this->handle ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + fclose( $this->handle ); + } + } + + /** + * Lookup an IP address. + * + * @param string $ip IP address. + * @return array|null + */ + public function lookup( $ip ) { + if ( ! $this->handle ) { + return null; + } + + // Determine IP version. + $ip_version = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ? 6 : 4; + + if ( 4 === $ip_version ) { + return $this->lookup_ipv4( $ip ); + } + + return $this->lookup_ipv6( $ip ); + } + + /** + * Lookup IPv4 address. + * + * @param string $ip IPv4 address. + * @return array|null + */ + private function lookup_ipv4( $ip ) { + if ( $this->ipv4_count <= 0 ) { + return null; + } + + $ip_num = sprintf( '%u', ip2long( $ip ) ); + + // Use index for faster lookup. + $index = ( $ip_num >> 16 ); + + $low = 0; + $high = $this->ipv4_count; + + if ( $this->ipv4_index_addr > 0 ) { + $low = $this->read_word( $this->ipv4_index_addr + ( $index * 8 ) ); + $high = $this->read_word( $this->ipv4_index_addr + ( $index * 8 ) + 4 ); + } + + // Binary search. + while ( $low <= $high ) { + $mid = (int) ( ( $low + $high ) / 2 ); + $ip_from = $this->read_word( $this->ipv4_addr + $mid * $this->db_column * 4 ); + $ip_to = $this->read_word( $this->ipv4_addr + ( $mid + 1 ) * $this->db_column * 4 ); + + if ( $ip_num >= $ip_from && $ip_num < $ip_to ) { + return $this->read_record( $this->ipv4_addr + $mid * $this->db_column * 4, 4 ); + } + + if ( $ip_num < $ip_from ) { + $high = $mid - 1; + } else { + $low = $mid + 1; + } + } + + return null; + } + + /** + * Lookup IPv6 address. + * + * @param string $ip IPv6 address. + * @return array|null + */ + private function lookup_ipv6( $ip ) { + if ( $this->ipv6_count <= 0 ) { + return null; + } + + $ip_num = $this->ipv6_to_number( $ip ); + + $low = 0; + $high = $this->ipv6_count; + + if ( $this->ipv6_index_addr > 0 ) { + $index_value = bcdiv( $ip_num, bcpow( '2', '112' ) ); + $index = bcmod( $index_value, '65536' ); + + $low = $this->read_word( $this->ipv6_index_addr + (int) $index * 8 ); + $high = $this->read_word( $this->ipv6_index_addr + (int) $index * 8 + 4 ); + } + + // Binary search. + while ( $low <= $high ) { + $mid = (int) ( ( $low + $high ) / 2 ); + $ip_from = $this->read_ipv6( $this->ipv6_addr + $mid * ( $this->db_column * 4 + 12 ) ); + $ip_to = $this->read_ipv6( $this->ipv6_addr + ( $mid + 1 ) * ( $this->db_column * 4 + 12 ) ); + + if ( bccomp( $ip_num, $ip_from ) >= 0 && bccomp( $ip_num, $ip_to ) < 0 ) { + return $this->read_record( $this->ipv6_addr + $mid * ( $this->db_column * 4 + 12 ) + 12, 6 ); + } + + if ( bccomp( $ip_num, $ip_from ) < 0 ) { + $high = $mid - 1; + } else { + $low = $mid + 1; + } + } + + return null; + } + + /** + * Read record from database. + * + * @param int $row_addr Row address. + * @param int $ip_version IP version. + * @return array + */ + private function read_record( $row_addr, $ip_version ) { + $record = array( + 'country_code' => '-', + 'country_name' => '-', + ); + + // Country is always at column 0 for DB1-DB24. + $country_pos = $row_addr + 4; + + if ( 6 === $ip_version ) { + $country_pos = $row_addr; + } + + $country_offset = $this->read_word( $country_pos ); + + if ( $country_offset > 0 ) { + $record['country_code'] = $this->read_string( $country_offset ); + $record['country_name'] = $this->read_string( $country_offset + 3 ); + } + + return $record; + } + + /** + * Read a byte from database. + * + * @param int $pos Position. + * @return int + */ + private function read_byte( $pos ) { + fseek( $this->handle, $pos - 1, SEEK_SET ); + $data = fread( $this->handle, 1 ); + return unpack( 'C', $data )[1]; + } + + /** + * Read a 32-bit word from database. + * + * @param int $pos Position. + * @return int + */ + private function read_word( $pos ) { + fseek( $this->handle, $pos - 1, SEEK_SET ); + $data = fread( $this->handle, 4 ); + return unpack( 'V', $data )[1]; + } + + /** + * Read a string from database. + * + * @param int $pos Position. + * @return string + */ + private function read_string( $pos ) { + fseek( $this->handle, $pos, SEEK_SET ); + $length = unpack( 'C', fread( $this->handle, 1 ) )[1]; + return fread( $this->handle, $length ); + } + + /** + * Read IPv6 address from database. + * + * @param int $pos Position. + * @return string + */ + private function read_ipv6( $pos ) { + fseek( $this->handle, $pos - 1, SEEK_SET ); + $data = fread( $this->handle, 16 ); + $int = unpack( 'V4', $data ); + + $result = bcadd( bcadd( bcmul( $int[4], bcpow( '4294967296', '3' ) ), bcmul( $int[3], bcpow( '4294967296', '2' ) ) ), bcadd( bcmul( $int[2], '4294967296' ), $int[1] ) ); + + return $result; + } + + /** + * Convert IPv6 to number. + * + * @param string $ip IPv6 address. + * @return string + */ + private function ipv6_to_number( $ip ) { + $bin = inet_pton( $ip ); + $hex = bin2hex( $bin ); + + $result = '0'; + for ( $i = 0; $i < strlen( $hex ); $i++ ) { + $result = bcadd( bcmul( $result, '16' ), hexdec( $hex[ $i ] ) ); + } + + return $result; + } + + /** + * Get database type string. + * + * @return string + */ + public function get_database_type() { + if ( ! $this->handle ) { + return ''; + } + + $types = array( + 1 => 'DB1 (Country)', + 2 => 'DB2 (Country + ISP)', + 3 => 'DB3 (Country + Region + City)', + 4 => 'DB4 (Country + Region + City + ISP)', + 5 => 'DB5 (Country + Region + City + Lat/Long)', + 11 => 'DB11 (Full)', + 24 => 'DB24 (Full)', + ); + + $date = sprintf( '%04d-%02d-%02d', 2000 + $this->db_year, $this->db_month, $this->db_day ); + + $type = isset( $types[ $this->db_type ] ) ? $types[ $this->db_type ] : 'DB' . $this->db_type; + + return $type . ' (' . $date . ')'; + } +} diff --git a/wp-security-pack/includes/class-wpsp-login-protection.php b/wp-security-pack/includes/class-wpsp-login-protection.php new file mode 100644 index 0000000..b9e278d --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-login-protection.php @@ -0,0 +1,1177 @@ +custom_login_slug = WP_Security_Pack::get_setting( 'login_custom_url', '' ); + if ( ! empty( $this->custom_login_slug ) ) { + // Handle custom login slug immediately (constructor runs during plugins_loaded). + $this->handle_custom_login_slug_early(); + + // These hooks can run on init. + add_action( 'init', array( $this, 'restrict_wp_login' ), 1 ); + add_filter( 'site_url', array( $this, 'filter_login_url' ), 10, 4 ); + add_filter( 'wp_redirect', array( $this, 'filter_redirect_url' ), 10, 2 ); + add_filter( 'login_url', array( $this, 'filter_login_url_direct' ), 10, 3 ); + } + } + + // Hide wp-admin for non-logged-in users. + if ( WP_Security_Pack::get_setting( 'hide_wp_admin', false ) ) { + // Must run early - WordPress redirects wp-admin to wp-login.php before init. + $this->block_wp_admin_early(); + add_action( 'init', array( $this, 'hide_wp_admin' ), 1 ); + } + + // Honeypot field. + if ( WP_Security_Pack::get_setting( 'honeypot_enabled', true ) ) { + add_action( 'login_form', array( $this, 'add_honeypot_field' ) ); + add_action( 'register_form', array( $this, 'add_honeypot_field' ) ); + add_filter( 'authenticate', array( $this, 'check_honeypot' ), 1, 3 ); + add_filter( 'registration_errors', array( $this, 'check_honeypot_registration' ), 10, 3 ); + } + + // Hide login error messages (prevents username enumeration). + if ( WP_Security_Pack::get_setting( 'hide_login_errors', true ) ) { + add_filter( 'login_errors', array( $this, 'hide_login_errors' ) ); + } + } + + /** + * Block locked out IPs from seeing the login page. + * Runs on login_init before the form is displayed. + */ + public function block_locked_out_on_login_page() { + // Check if custom login URL is enabled and block direct wp-login.php access. + if ( $this->should_block_direct_wp_login() ) { + $this->redirect_to_404(); + } + + // Check lockout first. + if ( $this->is_ip_locked_out_early() ) { + $this->show_lockout_message_early(); + } + + // Check admin access restriction. + if ( $this->is_admin_access_restricted() ) { + $this->show_admin_access_denied_message(); + } + } + + /** + * Check if direct wp-login.php access should be blocked. + * + * @return bool True if access should be blocked. + */ + private function should_block_direct_wp_login() { + // Only applies when custom login URL is enabled. + if ( ! WP_Security_Pack::get_setting( 'login_rename_enabled', false ) ) { + return false; + } + + $custom_slug = WP_Security_Pack::get_setting( 'login_custom_url', '' ); + if ( empty( $custom_slug ) ) { + return false; + } + + // Allow logged-in users (for logout, profile actions, etc.). + $logged_in = false; + foreach ( $_COOKIE as $name => $value ) { + if ( strpos( $name, 'wordpress_logged_in_' ) === 0 ) { + $logged_in = true; + break; + } + } + if ( $logged_in ) { + return false; + } + + // Allow if user has the access cookie (visited custom login URL before). + // This allows all WordPress flows (password reset, etc.) to work normally. + $expected_value = wp_hash( 'wpsp_login_' . $custom_slug ); + $cookie_value = isset( $_COOKIE['wpsp_login_access'] ) ? $_COOKIE['wpsp_login_access'] : ''; + + if ( $cookie_value === $expected_value ) { + return false; + } + + // No valid cookie - block direct wp-login.php access. + return true; + } + + /** + * Check if admin access is restricted for current IP/country. + * + * @return bool True if access should be denied. + */ + private function is_admin_access_restricted() { + if ( ! WP_Security_Pack::get_setting( 'admin_access_restriction', false ) ) { + return false; + } + + $ip = WPSP_Helper::get_client_ip(); + if ( ! $ip ) { + return false; + } + + // Check if IP is whitelisted (always allow). + $ip_control = wpsp()->get_component( 'ip_control' ); + if ( ! $ip_control ) { + $ip_control = new WPSP_IP_Control(); + } + if ( $ip_control->is_whitelisted( $ip ) ) { + return false; + } + + $allowed_countries = WP_Security_Pack::get_setting( 'admin_allowed_countries', array() ); + $allowed_ips = WP_Security_Pack::get_setting( 'admin_allowed_ips', '' ); + $allowed_ip_list = WPSP_Helper::parse_ip_list( $allowed_ips ); + + $has_country_restriction = ! empty( $allowed_countries ); + $has_ip_restriction = ! empty( $allowed_ip_list ); + + // If no restrictions configured, allow access. + if ( ! $has_country_restriction && ! $has_ip_restriction ) { + return false; + } + + $access_granted = false; + + // Check IP restriction. + if ( $has_ip_restriction && WPSP_Helper::ip_matches_rules( $ip, $allowed_ip_list ) ) { + $access_granted = true; + } + + // Check country restriction. + if ( ! $access_granted && $has_country_restriction ) { + $geo_blocking = wpsp()->get_component( 'geo_blocking' ); + if ( ! $geo_blocking ) { + $geo_blocking = new WPSP_Geo_Blocking(); + } + + $country_code = $geo_blocking->get_country_code( $ip ); + if ( $country_code && in_array( $country_code, $allowed_countries, true ) ) { + $access_granted = true; + } + } + + // If access not granted, it should be restricted. + return ! $access_granted; + } + + /** + * Show admin access denied message and exit. + */ + private function show_admin_access_denied_message() { + $ip = WPSP_Helper::get_client_ip(); + + // Log the blocked access attempt. + WPSP_Activity_Log::log( + WPSP_Activity_Log::EVENT_IP_BLOCKED, + $ip, + null, + __( 'Admin access denied: country/IP not allowed', 'wp-security-pack' ) + ); + + status_header( 403 ); + nocache_headers(); + + wp_die( + esc_html__( 'Admin access is not permitted from your location.', 'wp-security-pack' ), + esc_html__( 'Access Denied', 'wp-security-pack' ), + array( + 'response' => 403, + 'back_link' => false, + ) + ); + } + + /** + * Handle failed login attempt. + * + * @param string $username Username attempted. + */ + public function handle_failed_login( $username ) { + if ( ! WP_Security_Pack::get_setting( 'login_limit_enabled', true ) ) { + return; + } + + $ip = WPSP_Helper::get_client_ip(); + + if ( ! $ip ) { + return; + } + + // Check if IP is whitelisted. + $ip_control = wpsp()->get_component( 'ip_control' ); + if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) { + WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_LOGIN_FAILED, $ip, $username, __( 'Failed login (whitelisted IP)', 'wp-security-pack' ) ); + return; + } + + // Log the failed attempt. + WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_LOGIN_FAILED, $ip, $username ); + + // Increment failed attempts counter. + $attempts = $this->increment_failed_attempts( $ip ); + + // Check if lockout threshold reached. + $max_attempts = WP_Security_Pack::get_setting( 'login_max_attempts', 5 ); + + if ( $attempts >= $max_attempts ) { + $this->lockout_ip( $ip ); + } + + // Send email alert if enabled. + $this->maybe_send_alert( 'failed_login', $ip, $username, $attempts ); + } + + /** + * Handle successful login. + * + * @param string $user_login Username. + * @param WP_User $user User object. + */ + public function handle_successful_login( $user_login, $user ) { + $ip = WPSP_Helper::get_client_ip(); + + // Log the successful login. + WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_LOGIN_SUCCESS, $ip, $user_login ); + + // Clear failed attempts for this IP. + $this->clear_failed_attempts( $ip ); + + // Send admin login notification if enabled. + if ( WP_Security_Pack::get_setting( 'admin_login_notify', false ) && user_can( $user, 'manage_options' ) ) { + $this->send_admin_login_notification( $user, $ip ); + } + } + + /** + * Send notification when an admin logs in. + * + * @param WP_User $user User object. + * @param string $ip IP address. + */ + private function send_admin_login_notification( $user, $ip ) { + $email = WP_Security_Pack::get_setting( 'email_alerts_address', get_option( 'admin_email' ) ); + if ( empty( $email ) ) { + return; + } + + // Check if this is a new IP for this user. + $known_ips = get_user_meta( $user->ID, '_wpsp_known_ips', true ); + if ( ! is_array( $known_ips ) ) { + $known_ips = array(); + } + + // Only notify for new IPs (or always if no IPs recorded yet). + $is_new_ip = ! in_array( $ip, $known_ips, true ); + + // Add IP to known list (keep last 10). + if ( $is_new_ip ) { + $known_ips[] = $ip; + $known_ips = array_slice( $known_ips, -10 ); + update_user_meta( $user->ID, '_wpsp_known_ips', $known_ips ); + } + + // Only send notification for new IPs. + if ( ! $is_new_ip ) { + return; + } + + $site_name = get_bloginfo( 'name' ); + $subject = sprintf( + /* translators: %s: Site name */ + __( '[%s] Admin Login from New IP', 'wp-security-pack' ), + $site_name + ); + + $message = sprintf( + /* translators: 1: Username, 2: Site name */ + __( 'An administrator account logged in to %2$s from a new IP address.', 'wp-security-pack' ), + $user->user_login, + $site_name + ) . "\n\n"; + + $message .= sprintf( __( 'Username: %s', 'wp-security-pack' ), $user->user_login ) . "\n"; + $message .= sprintf( __( 'IP Address: %s', 'wp-security-pack' ), $ip ) . "\n"; + $message .= sprintf( __( 'Time: %s', 'wp-security-pack' ), current_time( 'mysql' ) ) . "\n\n"; + $message .= __( 'If this was not you, please secure your account immediately.', 'wp-security-pack' ) . "\n"; + + wp_mail( $email, $subject, $message ); + } + + /** + * Hide specific login error messages to prevent username enumeration. + * + * @param string $error Error message. + * @return string + */ + public function hide_login_errors( $error ) { + // Return generic message instead of revealing if username or password was wrong. + return __( 'Invalid username or password.', 'wp-security-pack' ); + } + + /** + * Check if IP is locked out before authentication. + * + * @param WP_User|WP_Error|null $user User object or error. + * @param string $username Username. + * @param string $password Password. + * @return WP_User|WP_Error|null + */ + public function check_lockout( $user, $username, $password ) { + if ( ! WP_Security_Pack::get_setting( 'login_limit_enabled', true ) ) { + return $user; + } + + if ( empty( $username ) ) { + return $user; + } + + $ip = WPSP_Helper::get_client_ip(); + + if ( ! $ip ) { + return $user; + } + + // Check if IP is whitelisted. + $ip_control = wpsp()->get_component( 'ip_control' ); + if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) { + return $user; + } + + // Check if IP is locked out. + $lockout = $this->get_lockout( $ip ); + $max_attempts = (int) WP_Security_Pack::get_setting( 'login_max_attempts', 5 ); + $lockout_duration = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 ); + + if ( $lockout && (int) $lockout->failed_attempts >= $max_attempts ) { + // Calculate lockout expiry from updated_at + duration. + // Use current_time('timestamp') for WordPress timezone consistency. + $updated_time = strtotime( $lockout->updated_at ); + $lockout_expires = $updated_time + ( $lockout_duration * 60 ); + $current_time = current_time( 'timestamp' ); + + if ( $current_time < $lockout_expires ) { + // Still locked out. + $remaining = human_time_diff( $current_time, $lockout_expires ); + + return new WP_Error( + 'wpsp_locked_out', + sprintf( + /* translators: %s: Time remaining */ + __( 'Too many failed login attempts. Please try again in %s.', 'wp-security-pack' ), + $remaining + ) + ); + } else { + // Lockout expired - clear the record so they can try again. + $this->clear_failed_attempts( $ip ); + } + } + + return $user; + } + + /** + * Increment failed attempts counter. + * + * @param string $ip IP address. + * @return int New count. + */ + private function increment_failed_attempts( $ip ) { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $existing = $wpdb->get_row( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE ip_address = %s", + $ip + ) + ); + + if ( $existing ) { + $new_count = $existing->failed_attempts + 1; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->update( + $table, + array( + 'failed_attempts' => $new_count, + 'updated_at' => current_time( 'mysql' ), + ), + array( 'ip_address' => $ip ), + array( '%d', '%s' ), + array( '%s' ) + ); + + return $new_count; + } + + // Create new record. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'ip_address' => $ip, + 'failed_attempts' => 1, + 'created_at' => current_time( 'mysql' ), + ), + array( '%s', '%d', '%s' ) + ); + + return 1; + } + + /** + * Clear failed attempts for an IP. + * + * @param string $ip IP address. + */ + private function clear_failed_attempts( $ip ) { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->delete( + $table, + array( 'ip_address' => $ip ), + array( '%s' ) + ); + } + + /** + * Lockout an IP address. + * + * @param string $ip IP address. + */ + private function lockout_ip( $ip ) { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + $duration = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 ); + // Store Unix timestamp to avoid timezone issues. + $lockout_until = time() + ( $duration * 60 ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->update( + $table, + array( + 'lockout_until' => $lockout_until, + 'updated_at' => current_time( 'mysql' ), + ), + array( 'ip_address' => $ip ), + array( '%d', '%s' ), + array( '%s' ) + ); + + // Log the lockout. + WPSP_Activity_Log::log( + WPSP_Activity_Log::EVENT_LOCKOUT, + $ip, + null, + sprintf( + /* translators: %d: Duration in minutes */ + __( 'Locked out for %d minutes', 'wp-security-pack' ), + $duration + ) + ); + + // Send email alert. + $this->maybe_send_alert( 'lockout', $ip ); + + // Check for auto-blacklist of repeat offenders. + $this->maybe_auto_blacklist( $ip ); + } + + /** + * Check if IP should be auto-blacklisted for repeat lockouts. + * + * @param string $ip IP address. + */ + private function maybe_auto_blacklist( $ip ) { + if ( ! WP_Security_Pack::get_setting( 'auto_blacklist_enabled', false ) ) { + return; + } + + $threshold = (int) WP_Security_Pack::get_setting( 'auto_blacklist_threshold', 3 ); + if ( $threshold < 1 ) { + return; + } + + // Count lockouts for this IP in the activity log. + $lockout_count = $this->count_ip_lockouts( $ip ); + + if ( $lockout_count >= $threshold ) { + $this->add_ip_to_blacklist( $ip ); + + // Log the auto-blacklist. + WPSP_Activity_Log::log( + WPSP_Activity_Log::EVENT_IP_BLOCKED, + $ip, + null, + sprintf( + /* translators: %d: Number of lockouts */ + __( 'Auto-blacklisted after %d lockouts', 'wp-security-pack' ), + $lockout_count + ) + ); + + // Send email alert. + $this->maybe_send_alert( 'auto_blacklist', $ip, $lockout_count ); + } + } + + /** + * Count the number of lockouts for an IP address. + * + * @param string $ip IP address. + * @return int + */ + private function count_ip_lockouts( $ip ) { + global $wpdb; + + $table = WPSP_DB::get_log_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $count = $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT COUNT(*) FROM {$table} WHERE ip_address = %s AND event_type = %s", + $ip, + WPSP_Activity_Log::EVENT_LOCKOUT + ) + ); + + return (int) $count; + } + + /** + * Add an IP address to the blacklist. + * + * @param string $ip IP address. + */ + private function add_ip_to_blacklist( $ip ) { + $current_blacklist = WP_Security_Pack::get_setting( 'ip_blacklist', '' ); + + // Check if IP is already in the blacklist. + $blacklist_array = array_filter( array_map( 'trim', explode( "\n", $current_blacklist ) ) ); + if ( in_array( $ip, $blacklist_array, true ) ) { + return; + } + + // Add IP to blacklist. + $blacklist_array[] = $ip; + $new_blacklist = implode( "\n", $blacklist_array ); + + WP_Security_Pack::update_setting( 'ip_blacklist', $new_blacklist ); + + // Clear the IP control cache. + $ip_control = wpsp()->get_component( 'ip_control' ); + if ( $ip_control ) { + $ip_control->clear_cache(); + } + } + + /** + * Get lockout record for an IP. + * + * @param string $ip IP address. + * @return object|null + */ + private function get_lockout( $ip ) { + global $wpdb; + + $table = WPSP_DB::get_lockout_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return $wpdb->get_row( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE ip_address = %s", + $ip + ) + ); + } + + /** + * Block wp-admin access early - called from constructor. + * Runs before WordPress can redirect to wp-login.php. + */ + private function block_wp_admin_early() { + // Check if user is logged in - we can't use is_user_logged_in() this early. + // Check for the logged_in cookie instead. + $logged_in = false; + foreach ( $_COOKIE as $name => $value ) { + if ( strpos( $name, 'wordpress_logged_in_' ) === 0 ) { + $logged_in = true; + break; + } + } + + if ( $logged_in ) { + return; + } + + // Check if requesting wp-admin. + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; + $is_wp_admin = strpos( $request_uri, '/wp-admin' ) !== false; + $is_ajax = strpos( $request_uri, 'admin-ajax.php' ) !== false; + + if ( $is_wp_admin && ! $is_ajax ) { + // Redirect to a non-existent URL to trigger WordPress's themed 404 page. + $home = get_option( 'home', '' ); + $fake_url = rtrim( $home, '/' ) . '/wpsp-404-' . mt_rand( 1000, 9999 ); + header( 'Location: ' . $fake_url, true, 302 ); + exit; + } + } + + /** + * Handle access to the custom login slug - called early from constructor. + * Directly loads wp-login.php when accessed via custom slug. + */ + public function handle_custom_login_slug_early() { + if ( ! $this->is_custom_login_slug_request_early() ) { + return; + } + + // Check if IP is locked out before showing login form. + if ( $this->is_ip_locked_out_early() ) { + $this->show_lockout_message_early(); + } + + // Check admin access restriction before showing login form. + if ( $this->is_admin_access_restricted() ) { + $this->show_admin_access_denied_message(); + } + + // Set a flag that user accessed via custom login slug. + // This will be checked by restrict_wp_login on subsequent requests. + $cookie_value = wp_hash( 'wpsp_login_' . $this->custom_login_slug ); + + // Define cookie constants if not already defined. + if ( ! defined( 'COOKIEPATH' ) ) { + define( 'COOKIEPATH', '/' ); + } + if ( ! defined( 'COOKIE_DOMAIN' ) ) { + define( 'COOKIE_DOMAIN', '' ); + } + + setcookie( + $this->cookie_name, + $cookie_value, + time() + DAY_IN_SECONDS, + COOKIEPATH, + COOKIE_DOMAIN, + is_ssl(), + true + ); + + // Also set it in the superglobal for immediate availability. + $_COOKIE[ $this->cookie_name ] = $cookie_value; + + // Directly load the login page (like WPS Hide Login does). + require_once ABSPATH . 'wp-login.php'; + exit; + } + + /** + * Check if IP is locked out - early version that works before init. + * + * @return bool + */ + private function is_ip_locked_out_early() { + if ( ! WP_Security_Pack::get_setting( 'login_limit_enabled', true ) ) { + return false; + } + + $ip = WPSP_Helper::get_client_ip(); + if ( ! $ip ) { + return false; + } + + // Check whitelist first. + $ip_control = wpsp()->get_component( 'ip_control' ); + if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) { + return false; + } + + // Check lockout status directly from database. + global $wpdb; + $table = WPSP_DB::get_lockout_table(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $lockout = $wpdb->get_row( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE ip_address = %s", + $ip + ) + ); + + if ( ! $lockout ) { + return false; + } + + $max_attempts = (int) WP_Security_Pack::get_setting( 'login_max_attempts', 5 ); + $lockout_minutes = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 ); + $current_time = time(); + + // Check if IP has reached max attempts and is still within lockout window. + if ( (int) $lockout->failed_attempts >= $max_attempts && ! empty( $lockout->updated_at ) ) { + $updated_time = strtotime( $lockout->updated_at ); + $lockout_expires = $updated_time + ( $lockout_minutes * 60 ); + + if ( $current_time < $lockout_expires ) { + return true; + } + } + + // Also check lockout_until for honeypot/manual blocks. + if ( ! empty( $lockout->lockout_until ) && is_numeric( $lockout->lockout_until ) ) { + if ( $current_time < (int) $lockout->lockout_until ) { + return true; + } + } + + return false; + } + + /** + * Show lockout message and exit - early version. + */ + private function show_lockout_message_early() { + $lockout_minutes = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 ); + + status_header( 403 ); + nocache_headers(); + + wp_die( + sprintf( + /* translators: %d: Lockout duration in minutes */ + esc_html__( 'Too many failed login attempts. Please try again in %d minutes.', 'wp-security-pack' ), + $lockout_minutes + ), + esc_html__( 'Access Denied', 'wp-security-pack' ), + array( + 'response' => 403, + 'back_link' => false, + ) + ); + } + + /** + * Check if the current request is for the custom login slug - early version. + * Uses native PHP functions to avoid dependency on WordPress functions. + * + * @return bool + */ + private function is_custom_login_slug_request_early() { + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; + $request_path = trim( parse_url( $request_uri, PHP_URL_PATH ), '/' ); + + // Get the site path for subdirectory installations. + // Use get_option directly since home_url() might have filters. + $home = get_option( 'home', '' ); + $home_path = trim( parse_url( $home, PHP_URL_PATH ), '/' ); + + // Remove the home path prefix from the request path. + if ( ! empty( $home_path ) && strpos( $request_path, $home_path ) === 0 ) { + $request_path = trim( substr( $request_path, strlen( $home_path ) ), '/' ); + } + + return $request_path === $this->custom_login_slug; + } + + /** + * Restrict direct access to wp-login.php. + * Block unless user has the access cookie from custom login slug. + */ + public function restrict_wp_login() { + // Check if we're on wp-login.php using the reliable $pagenow global. + if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) { + return; + } + + // Allow logged-in users. + if ( is_user_logged_in() ) { + return; + } + + // Allow if user has the access cookie (came through custom login slug before). + $expected_value = wp_hash( 'wpsp_login_' . $this->custom_login_slug ); + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $cookie_value = isset( $_COOKIE[ $this->cookie_name ] ) ? $_COOKIE[ $this->cookie_name ] : ''; + + if ( $cookie_value === $expected_value ) { + return; // Cookie is valid, allow access. + } + + // No valid cookie - block access with 404. + $this->redirect_to_404(); + } + + /** + * Check if the current request is for the custom login slug. + * + * @return bool + */ + private function is_custom_login_slug_request() { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; + $request_path = trim( wp_parse_url( $request_uri, PHP_URL_PATH ), '/' ); + + // Get the site path for subdirectory installations. + $home_path = trim( wp_parse_url( home_url(), PHP_URL_PATH ), '/' ); + + // Remove the home path prefix from the request path. + if ( ! empty( $home_path ) && strpos( $request_path, $home_path ) === 0 ) { + $request_path = trim( substr( $request_path, strlen( $home_path ) ), '/' ); + } + + return $request_path === $this->custom_login_slug; + } + + /** + * Redirect to 404 page. + */ + private function redirect_to_404() { + // Try to get the site's 404 page URL. + $home_url = home_url( '/' ); + + // Use a non-existent URL to trigger WordPress 404. + $fake_404_url = home_url( '/wpsp-not-found-' . wp_rand( 1000, 9999 ) . '/' ); + + status_header( 404 ); + nocache_headers(); + + wp_safe_redirect( $fake_404_url, 302 ); + exit; + } + + /** + * Hide wp-admin for non-logged-in users. + */ + public function hide_wp_admin() { + if ( is_user_logged_in() ) { + return; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; + + // Check if trying to access wp-admin (works for both root and subdirectory installs). + // Allow admin-ajax.php for frontend AJAX functionality. + $is_wp_admin = strpos( $request_uri, '/wp-admin' ) !== false; + $is_ajax = strpos( $request_uri, 'admin-ajax.php' ) !== false; + + if ( $is_wp_admin && ! $is_ajax ) { + $this->redirect_to_404(); + } + } + + /** + * Filter login URL in site_url. + * + * @param string $url The URL. + * @param string $path The path. + * @param string|null $scheme The scheme. + * @param int|null $blog_id Blog ID. + * @return string + */ + public function filter_login_url( $url, $path, $scheme, $blog_id ) { + if ( strpos( $url, 'wp-login.php' ) !== false && ! empty( $this->custom_login_slug ) ) { + $url = str_replace( 'wp-login.php', $this->custom_login_slug, $url ); + } + return $url; + } + + /** + * Filter login URL directly. + * + * @param string $login_url The login URL. + * @param string $redirect The redirect URL. + * @param bool $force_reauth Force reauth. + * @return string + */ + public function filter_login_url_direct( $login_url, $redirect, $force_reauth ) { + if ( ! empty( $this->custom_login_slug ) ) { + $login_url = str_replace( 'wp-login.php', $this->custom_login_slug, $login_url ); + } + return $login_url; + } + + /** + * Filter redirect URLs. + * + * @param string $location Redirect location. + * @param int $status HTTP status. + * @return string + */ + public function filter_redirect_url( $location, $status ) { + if ( strpos( $location, 'wp-login.php' ) !== false && ! empty( $this->custom_login_slug ) ) { + // Check the redirect_to parameter in the location URL. + $redirect_to = ''; + if ( preg_match( '/redirect_to=([^&]+)/', $location, $matches ) ) { + $redirect_to = urldecode( $matches[1] ); + } + + // If redirect_to contains wp-admin and hide_wp_admin is enabled, block with 404. + // This prevents /wp-admin from revealing the custom login URL. + // Use get_option directly to avoid any potential issues with class method. + $settings = get_option( 'wpsp_settings', array() ); + $hide_wp_admin = ! empty( $settings['hide_wp_admin'] ); + $is_wp_admin_redirect = strpos( $redirect_to, 'wp-admin' ) !== false && strpos( $redirect_to, 'admin-ajax.php' ) === false; + + if ( $hide_wp_admin && $is_wp_admin_redirect ) { + // Block with 404 - don't reveal the custom login URL. + status_header( 404 ); + nocache_headers(); + // Exit immediately to prevent the redirect. + exit; + } + + $location = str_replace( 'wp-login.php', $this->custom_login_slug, $location ); + } + return $location; + } + + /** + * Show 404 page. + */ + private function show_404() { + global $wp_query; + + status_header( 404 ); + nocache_headers(); + + if ( $wp_query ) { + $wp_query->set_404(); + } + + // Try to load theme 404 template. + $template = get_404_template(); + if ( $template ) { + include $template; + } else { + wp_die( + esc_html__( 'Page not found.', 'wp-security-pack' ), + esc_html__( '404 Not Found', 'wp-security-pack' ), + array( 'response' => 404 ) + ); + } + + exit; + } + + /** + * Add honeypot field to login form. + */ + public function add_honeypot_field() { + // Honeypot field - hidden via CSS, bots will fill it. + ?> +

+ + +

+ get_component( 'ip_control' ); + $ban_duration = (int) WP_Security_Pack::get_setting( 'honeypot_ban_duration', 60 ); + if ( $ip_control ) { + $ip_control->auto_block_ip( $ip, $ban_duration, __( 'Honeypot triggered', 'wp-security-pack' ) ); + } + + return new WP_Error( + 'wpsp_honeypot', + __( 'Authentication failed.', 'wp-security-pack' ) + ); + } + + return $user; + } + + /** + * Check honeypot field during registration. + * + * @param WP_Error $errors Registration errors. + * @param string $sanitized_user_login User login. + * @param string $user_email User email. + * @return WP_Error + */ + public function check_honeypot_registration( $errors, $sanitized_user_login, $user_email ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['wpsp_hp_email'] ) ) { + $errors->add( + 'wpsp_honeypot', + __( 'Registration failed.', 'wp-security-pack' ) + ); + + // Auto-block this IP using configurable duration. + $ip = WPSP_Helper::get_client_ip(); + $ip_control = wpsp()->get_component( 'ip_control' ); + $ban_duration = (int) WP_Security_Pack::get_setting( 'honeypot_ban_duration', 60 ); + if ( $ip_control && $ip ) { + $ip_control->auto_block_ip( $ip, $ban_duration, __( 'Honeypot triggered on registration', 'wp-security-pack' ) ); + } + } + + return $errors; + } + + /** + * Maybe send email alert. + * + * @param string $type Alert type. + * @param string $ip IP address. + * @param string $username Username (optional). + * @param int $attempts Number of attempts (optional). + */ + private function maybe_send_alert( $type, $ip, $username = '', $attempts = 0 ) { + if ( ! WP_Security_Pack::get_setting( 'email_alerts_enabled', false ) ) { + return; + } + + $email = WP_Security_Pack::get_setting( 'email_alerts_address', get_option( 'admin_email' ) ); + if ( empty( $email ) ) { + return; + } + + $site_name = get_bloginfo( 'name' ); + $site_url = home_url(); + + switch ( $type ) { + case 'failed_login': + $threshold = WP_Security_Pack::get_setting( 'email_alert_threshold', 3 ); + if ( $attempts < $threshold ) { + return; + } + + $subject = sprintf( + /* translators: %s: Site name */ + __( '[%s] Multiple Failed Login Attempts', 'wp-security-pack' ), + $site_name + ); + + $message = sprintf( + /* translators: 1: Number of attempts, 2: IP address, 3: Username, 4: Site URL */ + __( "There have been %1\$d failed login attempts on your site.\n\nIP Address: %2\$s\nUsername attempted: %3\$s\nSite: %4\$s\n\nIf this wasn't you, the IP will be automatically locked out after reaching the threshold.", 'wp-security-pack' ), + $attempts, + $ip, + $username ? $username : __( '(empty)', 'wp-security-pack' ), + $site_url + ); + break; + + case 'lockout': + $subject = sprintf( + /* translators: %s: Site name */ + __( '[%s] IP Address Locked Out', 'wp-security-pack' ), + $site_name + ); + + $message = sprintf( + /* translators: 1: IP address, 2: Site URL */ + __( "An IP address has been locked out due to too many failed login attempts.\n\nIP Address: %1\$s\nSite: %2\$s", 'wp-security-pack' ), + $ip, + $site_url + ); + break; + + case 'auto_blacklist': + $subject = sprintf( + /* translators: %s: Site name */ + __( '[%s] IP Permanently Blacklisted', 'wp-security-pack' ), + $site_name + ); + + $message = sprintf( + /* translators: 1: IP address, 2: Number of lockouts, 3: Site URL */ + __( "An IP address has been permanently added to your blacklist due to repeated lockouts.\n\nIP Address: %1\$s\nTotal Lockouts: %2\$d\nSite: %3\$s\n\nThis IP will no longer be able to access your website.", 'wp-security-pack' ), + $ip, + $attempts, + $site_url + ); + break; + + default: + return; + } + + wp_mail( $email, $subject, $message ); + } +} diff --git a/wp-security-pack/includes/class-wpsp-malware-scanner.php b/wp-security-pack/includes/class-wpsp-malware-scanner.php new file mode 100644 index 0000000..2a415b4 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-malware-scanner.php @@ -0,0 +1,766 @@ +load_signatures(); + $this->load_malware_hashes(); + + if ( ! WP_Security_Pack::get_setting( 'malware_scan_enabled', true ) ) { + return; + } + + // Schedule weekly scan. + add_action( 'wpsp_weekly_malware_scan', array( $this, 'run_scheduled_scan' ) ); + + if ( ! wp_next_scheduled( 'wpsp_weekly_malware_scan' ) ) { + wp_schedule_event( time(), 'weekly', 'wpsp_weekly_malware_scan' ); + } + } + + /** + * Load known malware file hashes. + * + * These are MD5 hashes of known malicious files. When a file matches, + * it's 100% confirmed malware - no false positives possible. + */ + private function load_malware_hashes() { + // Try to load from database (updated hashes). + $stored_hashes = get_option( self::HASH_DB_OPTION, array() ); + + if ( ! empty( $stored_hashes ) ) { + $this->malware_hashes = $stored_hashes; + return; + } + + // Hash database starts empty - relies on signature detection. + // Users can add hashes via the 'wpsp_malware_hashes' filter or + // by updating from a trusted source using update_hash_database(). + $this->malware_hashes = array(); + + // Allow adding custom hashes via filter. + $this->malware_hashes = apply_filters( 'wpsp_malware_hashes', $this->malware_hashes ); + } + + /** + * Update malware hash database from remote source. + * + * @param string $source_url URL to fetch hashes from (JSON format). + * @return bool|WP_Error + */ + public function update_hash_database( $source_url = '' ) { + if ( empty( $source_url ) ) { + // Default: Could be a GitHub raw URL or your own endpoint. + // For now, just return - users can provide their own source. + return new WP_Error( 'no_source', __( 'No hash database source URL provided.', 'wp-security-pack' ) ); + } + + $response = wp_remote_get( $source_url, array( 'timeout' => 30 ) ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( ! is_array( $data ) ) { + return new WP_Error( 'invalid_data', __( 'Invalid hash database format.', 'wp-security-pack' ) ); + } + + // Merge with existing hashes. + $current_hashes = $this->malware_hashes; + $new_hashes = array_merge( $current_hashes, $data ); + + update_option( self::HASH_DB_OPTION, $new_hashes ); + update_option( self::HASH_DB_UPDATED_OPTION, time() ); + + $this->malware_hashes = $new_hashes; + + return true; + } + + /** + * Check if a file matches a known malware hash. + * + * @param string $file_path Path to file. + * @return array|false Malware info if matched, false otherwise. + */ + public function check_file_hash( $file_path ) { + if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { + return false; + } + + $md5_hash = md5_file( $file_path ); + + if ( isset( $this->malware_hashes[ $md5_hash ] ) ) { + return array( + 'hash' => $md5_hash, + 'name' => $this->malware_hashes[ $md5_hash ], + 'method' => 'hash', + ); + } + + return false; + } + + /** + * Get hash database info. + * + * @return array + */ + public function get_hash_database_info() { + return array( + 'count' => count( $this->malware_hashes ), + 'last_updated' => get_option( self::HASH_DB_UPDATED_OPTION, 0 ), + 'source' => empty( get_option( self::HASH_DB_OPTION ) ) ? 'built-in' : 'updated', + ); + } + + /** + * Load malware signatures. + * + * Patterns are organized by category and designed to minimize false positives + * while catching real threats. We do NOT scan WordPress core files. + */ + private function load_signatures() { + $this->signatures = array( + + // ===================================================================== + // CRITICAL: Code Execution with Obfuscation + // These patterns are almost always malicious. + // ===================================================================== + array( + 'name' => 'Base64 Decode Execution', + 'pattern' => '/\beval\s*\(\s*base64_decode\s*\(/i', + 'severity' => 'critical', + 'description' => 'Executing base64-encoded PHP code', + ), + array( + 'name' => 'Gzinflate Execution', + 'pattern' => '/\beval\s*\(\s*gzinflate\s*\(/i', + 'severity' => 'critical', + 'description' => 'Executing gzip-compressed PHP code', + ), + array( + 'name' => 'Gzuncompress Execution', + 'pattern' => '/\beval\s*\(\s*gzuncompress\s*\(/i', + 'severity' => 'critical', + 'description' => 'Executing compressed PHP code', + ), + array( + 'name' => 'Str_rot13 Execution', + 'pattern' => '/\beval\s*\(\s*str_rot13\s*\(/i', + 'severity' => 'critical', + 'description' => 'Executing ROT13-obfuscated PHP code', + ), + array( + 'name' => 'Multiple Decode Layers', + 'pattern' => '/base64_decode\s*\([^)]*base64_decode/is', + 'severity' => 'critical', + 'description' => 'Multiple layers of encoding (heavy obfuscation)', + ), + array( + 'name' => 'Preg_replace /e Modifier', + 'pattern' => '/preg_replace\s*\(\s*["\'][^"\']*\/[a-z]*e[a-z]*["\']/', + 'severity' => 'critical', + 'description' => 'Code execution via deprecated preg_replace /e modifier', + ), + + // ===================================================================== + // CRITICAL: User Input to Code Execution + // Direct path from user input to code execution. + // ===================================================================== + array( + 'name' => 'Eval with User Input', + 'pattern' => '/\beval\s*\(\s*[\$\.\s]*\$_(POST|GET|REQUEST|COOKIE|SERVER|FILES)/i', + 'severity' => 'critical', + 'description' => 'Direct code execution from user input', + ), + array( + 'name' => 'Assert with User Input', + 'pattern' => '/\bassert\s*\(\s*[\$\.\s]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Code execution via assert() from user input', + ), + array( + 'name' => 'Create_function with User Input', + 'pattern' => '/\bcreate_function\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Dynamic function creation with user input', + ), + array( + 'name' => 'Call_user_func with User Input', + 'pattern' => '/\bcall_user_func(_array)?\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Calling arbitrary function from user input', + ), + array( + 'name' => 'Variable Function with User Input', + 'pattern' => '/\$_(POST|GET|REQUEST|COOKIE)\s*\[[^\]]+\]\s*\(/i', + 'severity' => 'critical', + 'description' => 'Calling function name from user input', + ), + + // ===================================================================== + // CRITICAL: Shell Command Execution with User Input + // ===================================================================== + array( + 'name' => 'Shell_exec with User Input', + 'pattern' => '/\bshell_exec\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Shell command execution with user input', + ), + array( + 'name' => 'System with User Input', + 'pattern' => '/\bsystem\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'System command execution with user input', + ), + array( + 'name' => 'Passthru with User Input', + 'pattern' => '/\bpassthru\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Passthru command execution with user input', + ), + array( + 'name' => 'Exec with User Input', + 'pattern' => '/\bexec\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Exec command execution with user input', + ), + array( + 'name' => 'Popen with User Input', + 'pattern' => '/\bpopen\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Process opened with user-controlled command', + ), + array( + 'name' => 'Proc_open with User Input', + 'pattern' => '/\bproc_open\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Process opened with user-controlled command', + ), + + // ===================================================================== + // CRITICAL: File Inclusion Vulnerabilities + // ===================================================================== + array( + 'name' => 'Include with User Input', + 'pattern' => '/\b(include|require|include_once|require_once)\s*\(?\s*[\$\.\s]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Local/Remote File Inclusion vulnerability', + ), + + // ===================================================================== + // CRITICAL: File Write Vulnerabilities + // ===================================================================== + array( + 'name' => 'File_put_contents with User Input', + 'pattern' => '/\bfile_put_contents\s*\([^,]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Writing to user-controlled file path', + ), + array( + 'name' => 'Fwrite with User Content', + 'pattern' => '/\bfwrite\s*\([^,]+,\s*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'high', + 'description' => 'Writing user content to file', + ), + array( + 'name' => 'Fopen with User Path', + 'pattern' => '/\bfopen\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Opening user-controlled file path', + ), + array( + 'name' => 'Unrestricted File Upload Path', + 'pattern' => '/move_uploaded_file\s*\([^,]+,\s*[^)]*\$_(POST|GET|REQUEST)/i', + 'severity' => 'critical', + 'description' => 'Uploading file to user-controlled path', + ), + + // ===================================================================== + // HIGH: Known Backdoor Patterns + // Specific patterns that indicate known malware structures. + // ===================================================================== + array( + 'name' => 'Web Shell Upload Form', + 'pattern' => '/]*enctype=["\']multipart\/form-data["\'][^>]*>.*]*type=["\']file["\'].*\$_FILES/is', + 'severity' => 'high', + 'description' => 'File upload form with immediate processing (potential backdoor)', + ), + array( + 'name' => 'Eval POST/GET Pattern', + 'pattern' => '/\beval\s*\(\s*\$_(POST|GET)\s*\[\s*["\'][a-z0-9_]+["\']\s*\]\s*\)/i', + 'severity' => 'critical', + 'description' => 'Classic backdoor pattern: eval($_POST[key])', + ), + array( + 'name' => 'Base64 POST Execution', + 'pattern' => '/\beval\s*\(\s*base64_decode\s*\(\s*\$_(POST|GET|REQUEST)/i', + 'severity' => 'critical', + 'description' => 'Executing base64-encoded user input', + ), + array( + 'name' => 'Gunzip Eval Chain', + 'pattern' => '/\beval\s*\(\s*gzuncompress\s*\(\s*base64_decode/i', + 'severity' => 'critical', + 'description' => 'Multi-layer deobfuscation chain', + ), + + // ===================================================================== + // HIGH: Obfuscation Indicators + // ===================================================================== + array( + 'name' => 'Very Long Encoded String', + 'pattern' => '/["\'][A-Za-z0-9+\/=]{1500,}["\']/s', + 'severity' => 'high', + 'description' => 'Extremely long encoded string (likely obfuscated malware)', + ), + array( + 'name' => 'Hex Escape Sequence', + 'pattern' => '/(\\\\x[0-9a-fA-F]{2}){20,}/i', + 'severity' => 'high', + 'description' => 'Long hex-encoded string (obfuscation technique)', + ), + array( + 'name' => 'Chr() Obfuscation', + 'pattern' => '/(\bchr\s*\(\s*\d+\s*\)\s*\.?\s*){10,}/i', + 'severity' => 'high', + 'description' => 'Building string from chr() calls (obfuscation)', + ), + array( + 'name' => 'Array Character Building', + 'pattern' => '/\$\w+\s*=\s*["\'][A-Za-z]+["\'];\s*\$\w+\s*=\s*\$\w+\[\d+\]\s*\.\s*\$\w+\[\d+\]/i', + 'severity' => 'medium', + 'description' => 'Building function names from array indices (obfuscation)', + ), + + // ===================================================================== + // HIGH: WordPress-Specific Attacks + // ===================================================================== + array( + 'name' => 'WP User Creation Backdoor', + 'pattern' => '/wp_create_user\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Creating WordPress user from user input', + ), + array( + 'name' => 'WP Insert User Backdoor', + 'pattern' => '/wp_insert_user\s*\([^)]*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'critical', + 'description' => 'Inserting WordPress user from user input', + ), + array( + 'name' => 'WP Option Injection', + 'pattern' => '/update_option\s*\(\s*\$_(POST|GET|REQUEST)/i', + 'severity' => 'critical', + 'description' => 'Updating arbitrary WordPress option from user input', + ), + array( + 'name' => 'WP Auth Cookie Manipulation', + 'pattern' => '/wp_set_auth_cookie\s*\([^)]*\$_(POST|GET|REQUEST)/i', + 'severity' => 'critical', + 'description' => 'Setting auth cookie from user input (authentication bypass)', + ), + array( + 'name' => 'WP Role Escalation', + 'pattern' => '/->set_role\s*\(\s*["\']administrator["\']\s*\)|->add_cap\s*\([^)]*\$_(POST|GET|REQUEST)/i', + 'severity' => 'critical', + 'description' => 'Privilege escalation attempt', + ), + + // ===================================================================== + // HIGH: Suspicious Patterns with Context + // These require specific dangerous context to trigger. + // ===================================================================== + array( + 'name' => 'Error Suppression with Eval', + 'pattern' => '/@\s*eval\s*\(/i', + 'severity' => 'high', + 'description' => 'Eval with error suppression (hiding malicious activity)', + ), + array( + 'name' => 'Remote Code Fetch and Execute', + 'pattern' => '/eval\s*\(\s*file_get_contents\s*\(\s*["\']https?:\/\//i', + 'severity' => 'critical', + 'description' => 'Fetching and executing remote code', + ), + array( + 'name' => 'CURL Fetch and Execute', + 'pattern' => '/eval\s*\([^)]*curl_exec/is', + 'severity' => 'critical', + 'description' => 'Executing code fetched via CURL', + ), + array( + 'name' => 'Dynamic URL Fetch with User Input', + 'pattern' => '/file_get_contents\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i', + 'severity' => 'high', + 'description' => 'Fetching content from user-controlled URL', + ), + ); + + // Allow adding custom signatures via filter. + $this->signatures = apply_filters( 'wpsp_malware_signatures', $this->signatures ); + } + + /** + * Run scheduled scan. + */ + public function run_scheduled_scan() { + $results = $this->scan_files(); + + if ( ! empty( $results ) ) { + update_option( self::RESULTS_OPTION, $results ); + $this->send_alert( $results ); + } + + update_option( self::LAST_SCAN_OPTION, time() ); + } + + /** + * Scan files for malware signatures. + * + * Only scans plugins, themes, and uploads directories. + * Does NOT scan WordPress core to avoid false positives. + * + * @param array $paths Paths to scan (default: wp-content directories only). + * @return array + */ + public function scan_files( $paths = array() ) { + if ( empty( $paths ) ) { + // Only scan wp-content directories, NOT WordPress core. + $paths = array( + WP_CONTENT_DIR . '/plugins/', + WP_CONTENT_DIR . '/themes/', + WP_CONTENT_DIR . '/uploads/', + WP_CONTENT_DIR . '/mu-plugins/', + ); + } + + $results = array(); + $file_count = 0; + $max_files = 10000; // Limit to prevent timeout. + + foreach ( $paths as $path ) { + if ( ! file_exists( $path ) ) { + continue; + } + + if ( is_file( $path ) ) { + if ( ! $this->should_skip_file( $path ) ) { + $file_results = $this->scan_file( $path ); + if ( ! empty( $file_results ) ) { + $results[ $path ] = $file_results; + } + } + $file_count++; + } else { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $iterator as $file ) { + if ( $file_count >= $max_files ) { + break 2; + } + + if ( ! $file->isFile() ) { + continue; + } + + $file_path = $file->getPathname(); + $ext = strtolower( $file->getExtension() ); + + // Only scan PHP files. + if ( 'php' !== $ext ) { + continue; + } + + // Skip large files (> 2MB). + if ( $file->getSize() > 2 * 1024 * 1024 ) { + continue; + } + + // Skip known safe paths. + if ( $this->should_skip_file( $file_path ) ) { + continue; + } + + $file_results = $this->scan_file( $file_path ); + if ( ! empty( $file_results ) ) { + $results[ $file_path ] = $file_results; + } + + $file_count++; + } + } + } + + update_option( self::LAST_SCAN_OPTION, time() ); + + return $results; + } + + /** + * Check if a file should be skipped (known legitimate libraries). + * + * @param string $file_path File path to check. + * @return bool True if file should be skipped. + */ + private function should_skip_file( $file_path ) { + foreach ( $this->skip_paths as $skip ) { + if ( stripos( $file_path, $skip ) !== false ) { + return true; + } + } + return false; + } + + /** + * Scan a single file. + * + * @param string $file_path File path. + * @return array + */ + public function scan_file( $file_path ) { + if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { + return array(); + } + + $findings = array(); + + // First: Check against known malware hashes (100% accurate). + $hash_match = $this->check_file_hash( $file_path ); + if ( $hash_match ) { + $findings[] = array( + 'name' => 'Known Malware: ' . $hash_match['name'], + 'severity' => 'critical', + 'description' => 'File matches known malware hash (MD5: ' . $hash_match['hash'] . ')', + 'match' => 'Hash match - confirmed malware', + 'confirmed' => true, + ); + // Hash match is definitive - still scan for patterns but mark as confirmed. + } + + // Second: Pattern-based detection. + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $file_path ); + + if ( ! empty( $content ) ) { + foreach ( $this->signatures as $signature ) { + if ( preg_match( $signature['pattern'], $content, $matches ) ) { + $findings[] = array( + 'name' => $signature['name'], + 'severity' => $signature['severity'], + 'description' => $signature['description'], + 'match' => substr( $matches[0], 0, 100 ), + 'confirmed' => false, + ); + } + } + } + + return $findings; + } + + /** + * Send email alert about scan results. + * + * @param array $results Scan results. + */ + private function send_alert( $results ) { + if ( ! WP_Security_Pack::get_setting( 'email_alerts_enabled', false ) ) { + return; + } + + $email = WP_Security_Pack::get_setting( 'email_alerts_address', get_option( 'admin_email' ) ); + $site_name = get_bloginfo( 'name' ); + $site_url = home_url(); + + // Count by severity. + $counts = array( + 'critical' => 0, + 'high' => 0, + 'medium' => 0, + 'low' => 0, + ); + + foreach ( $results as $file => $findings ) { + foreach ( $findings as $finding ) { + $counts[ $finding['severity'] ]++; + } + } + + $subject = sprintf( + /* translators: %s: Site name */ + __( '[%s] Malware Scan Alert - Suspicious Files Found', 'wp-security-pack' ), + $site_name + ); + + $message = sprintf( + /* translators: %s: Site URL */ + __( "WP Security Pack malware scan has detected suspicious files on %s.\n\n", 'wp-security-pack' ), + $site_url + ); + + $message .= __( "Summary:\n", 'wp-security-pack' ); + $message .= sprintf( __( "- Critical: %d\n", 'wp-security-pack' ), $counts['critical'] ); + $message .= sprintf( __( "- High: %d\n", 'wp-security-pack' ), $counts['high'] ); + $message .= sprintf( __( "- Medium: %d\n", 'wp-security-pack' ), $counts['medium'] ); + $message .= sprintf( __( "- Low: %d\n\n", 'wp-security-pack' ), $counts['low'] ); + + $message .= __( "Files with issues:\n", 'wp-security-pack' ); + + $count = 0; + foreach ( $results as $file => $findings ) { + if ( $count >= 20 ) { + $message .= sprintf( + /* translators: %d: Number of additional files */ + __( "... and %d more files\n", 'wp-security-pack' ), + count( $results ) - 20 + ); + break; + } + + $relative_path = str_replace( ABSPATH, '', $file ); + $message .= "\n" . $relative_path . ":\n"; + + foreach ( $findings as $finding ) { + $message .= sprintf( " - [%s] %s\n", strtoupper( $finding['severity'] ), $finding['name'] ); + } + + $count++; + } + + $message .= "\n" . __( "Please review these files in your WordPress admin panel under Settings > WP Security Pack.", 'wp-security-pack' ); + $message .= "\n\n" . __( "Note: Some detections may be false positives. Review each file carefully before taking action.", 'wp-security-pack' ); + + wp_mail( $email, $subject, $message ); + } + + /** + * Get last scan results. + * + * @return array + */ + public function get_last_scan_results() { + return array( + 'time' => get_option( self::LAST_SCAN_OPTION, 0 ), + 'results' => get_option( self::RESULTS_OPTION, array() ), + ); + } + + /** + * Clear scan results. + */ + public function clear_results() { + delete_option( self::RESULTS_OPTION ); + } + + /** + * Get severity color. + * + * @param string $severity Severity level. + * @return string + */ + public static function get_severity_color( $severity ) { + $colors = array( + 'critical' => '#dc3545', + 'high' => '#fd7e14', + 'medium' => '#ffc107', + 'low' => '#17a2b8', + ); + + return isset( $colors[ $severity ] ) ? $colors[ $severity ] : '#6c757d'; + } +} diff --git a/wp-security-pack/includes/class-wpsp-two-factor.php b/wp-security-pack/includes/class-wpsp-two-factor.php new file mode 100644 index 0000000..4408b87 --- /dev/null +++ b/wp-security-pack/includes/class-wpsp-two-factor.php @@ -0,0 +1,765 @@ +is_2fa_enabled_for_user( $user->ID ) ) { + return $user; + } + + // Generate a token for the 2FA session. + $token = wp_generate_password( 32, false ); + + set_transient( 'wpsp_2fa_' . $token, array( + 'user_id' => $user->ID, + 'redirect' => isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(), + ), 5 * MINUTE_IN_SECONDS ); + + // Redirect to 2FA form immediately. + wp_safe_redirect( add_query_arg( array( + 'action' => 'wpsp_2fa', + 'token' => $token, + ), wp_login_url() ) ); + exit; + } + + /** + * Enforce 2FA setup for administrators. + */ + public function enforce_admin_2fa() { + // Skip AJAX requests. + if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { + return; + } + + $user = wp_get_current_user(); + + // Only apply to administrators. + if ( ! $user || ! user_can( $user, 'manage_options' ) ) { + return; + } + + // Skip if already has 2FA enabled. + if ( $this->is_2fa_enabled_for_user( $user->ID ) ) { + return; + } + + // Allow access to profile page for setup. + global $pagenow; + if ( 'profile.php' === $pagenow || 'admin-ajax.php' === $pagenow ) { + return; + } + + // Redirect to profile page with notice. + wp_safe_redirect( add_query_arg( 'wpsp_2fa_required', '1', admin_url( 'profile.php' ) ) ); + exit; + } + + /** + * Check if we should render the 2FA form (for custom login URLs). + */ + public function maybe_render_2fa_form() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['action'] ) && 'wpsp_2fa' === $_GET['action'] ) { + $this->render_2fa_form(); + } + } + + /** + * Render 2FA verification form. + */ + public function render_2fa_form() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; + + if ( empty( $token ) ) { + wp_safe_redirect( wp_login_url() ); + exit; + } + + $data = get_transient( 'wpsp_2fa_' . $token ); + + if ( ! $data ) { + wp_safe_redirect( wp_login_url() ); + exit; + } + + $error = ''; + + // Handle form submission. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( isset( $_POST['wpsp_2fa_code'] ) || isset( $_POST['wpsp_backup_code'] ) ) { + // Check TOTP code first, then backup code. + $code = ''; + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['wpsp_2fa_code'] ) ) { + $code = sanitize_text_field( wp_unslash( $_POST['wpsp_2fa_code'] ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing + } elseif ( ! empty( $_POST['wpsp_backup_code'] ) ) { + $code = sanitize_text_field( wp_unslash( $_POST['wpsp_backup_code'] ) ); + } + + $user_id = $data['user_id']; + + if ( ! empty( $code ) && $this->verify_code( $user_id, $code ) ) { + // Delete the token. + delete_transient( 'wpsp_2fa_' . $token ); + + // Log the user in. + wp_set_auth_cookie( $user_id, false ); + wp_set_current_user( $user_id ); + + // Redirect. + wp_safe_redirect( $data['redirect'] ); + exit; + } + + $error = __( 'Invalid verification code.', 'wp-security-pack' ); + } + + // Render the form. + login_header( __( 'Two-Factor Authentication', 'wp-security-pack' ) ); + ?> +
+ +
+ + +

+ +

+ + +

+ +

+ +

+ + + + +
+ is_2fa_enabled_for_user( $user->ID ); + + // Show notice if 2FA is required. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['wpsp_2fa_required'] ) && ! $is_enabled ) : + ?> +
+

+

+
+ +

+ + + + + + + + + __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $secret = $this->generate_secret(); + + // Store temporarily (not enabled yet). + update_user_meta( $user_id, self::SECRET_META_KEY . '_pending', $secret ); + + $user = get_user_by( 'id', $user_id ); + $site = wp_parse_url( home_url(), PHP_URL_HOST ); + + // Generate otpauth URL for QR code. + $otpauth = sprintf( + 'otpauth://totp/%s:%s?secret=%s&issuer=%s', + rawurlencode( $site ), + rawurlencode( $user->user_email ), + $secret, + rawurlencode( $site ) + ); + + wp_send_json_success( array( + 'secret' => $secret, + 'otpauth' => $otpauth, + ) ); + } + + /** + * Verify 2FA setup via AJAX. + */ + public function ajax_verify_setup() { + check_ajax_referer( 'wpsp_2fa_setup' ); + + $user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; + $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + $pending_secret = get_user_meta( $user_id, self::SECRET_META_KEY . '_pending', true ); + + if ( empty( $pending_secret ) ) { + wp_send_json_error( array( 'message' => __( 'No pending setup found.', 'wp-security-pack' ) ) ); + } + + // Verify the code. + if ( ! $this->verify_totp( $pending_secret, $code ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid code. Please try again.', 'wp-security-pack' ) ) ); + } + + // Enable 2FA. + update_user_meta( $user_id, self::SECRET_META_KEY, $pending_secret ); + update_user_meta( $user_id, self::ENABLED_META_KEY, '1' ); + delete_user_meta( $user_id, self::SECRET_META_KEY . '_pending' ); + + // Generate backup codes - show plain to user, store hashed. + $plain_codes = $this->generate_backup_codes(); + $hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes ); + update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes ); + + wp_send_json_success( array( + 'message' => __( '2FA enabled successfully. Save your backup codes now - they will not be shown again!', 'wp-security-pack' ), + 'backup_codes' => $plain_codes, + 'show_codes' => true, + ) ); + } + + /** + * Disable 2FA via AJAX. + */ + public function ajax_disable_2fa() { + check_ajax_referer( 'wpsp_2fa_setup' ); + + $user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + delete_user_meta( $user_id, self::SECRET_META_KEY ); + delete_user_meta( $user_id, self::ENABLED_META_KEY ); + delete_user_meta( $user_id, self::BACKUP_CODES_META_KEY ); + + wp_send_json_success( array( 'message' => __( '2FA disabled.', 'wp-security-pack' ) ) ); + } + + /** + * Regenerate backup codes via AJAX. + */ + public function ajax_regenerate_backup_codes() { + check_ajax_referer( 'wpsp_2fa_setup' ); + + $user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); + } + + // Generate new codes. + $plain_codes = $this->generate_backup_codes(); + + // Hash them before storing. + $hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes ); + update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes ); + + // Return plain codes to show user ONCE. + wp_send_json_success( array( + 'codes' => $plain_codes, + 'message' => __( 'Backup codes regenerated. Save these codes now - they will not be shown again.', 'wp-security-pack' ), + ) ); + } + + /** + * Check if 2FA is enabled for a user. + * + * @param int $user_id User ID. + * @return bool + */ + public function is_2fa_enabled_for_user( $user_id ) { + return '1' === get_user_meta( $user_id, self::ENABLED_META_KEY, true ); + } + + /** + * Verify a code (TOTP or backup). + * + * @param int $user_id User ID. + * @param string $code Code to verify. + * @return bool + */ + public function verify_code( $user_id, $code ) { + $code = preg_replace( '/\s+/', '', $code ); + + // Try TOTP first. + $secret = get_user_meta( $user_id, self::SECRET_META_KEY, true ); + + if ( $this->verify_totp( $secret, $code ) ) { + return true; + } + + // Try backup code. + return $this->verify_backup_code( $user_id, $code ); + } + + /** + * Verify TOTP code. + * + * @param string $secret Secret key. + * @param string $code Code to verify. + * @param int $window Time window tolerance. + * @return bool + */ + private function verify_totp( $secret, $code, $window = 1 ) { + if ( empty( $secret ) || empty( $code ) ) { + return false; + } + + $time = floor( time() / self::TIME_STEP ); + + for ( $i = -$window; $i <= $window; $i++ ) { + $calculated = $this->calculate_totp( $secret, $time + $i ); + if ( hash_equals( $calculated, $code ) ) { + return true; + } + } + + return false; + } + + /** + * Calculate TOTP code. + * + * @param string $secret Secret key. + * @param int $time Time counter. + * @return string + */ + private function calculate_totp( $secret, $time ) { + // Decode base32 secret. + $secret_decoded = $this->base32_decode( $secret ); + + // Pack time. + $time_packed = pack( 'N*', 0, $time ); + + // Calculate HMAC. + $hash = hash_hmac( 'sha1', $time_packed, $secret_decoded, true ); + + // Dynamic truncation. + $offset = ord( substr( $hash, -1 ) ) & 0x0F; + $code = ( ord( $hash[ $offset ] ) & 0x7F ) << 24; + $code |= ( ord( $hash[ $offset + 1 ] ) & 0xFF ) << 16; + $code |= ( ord( $hash[ $offset + 2 ] ) & 0xFF ) << 8; + $code |= ( ord( $hash[ $offset + 3 ] ) & 0xFF ); + $code = $code % pow( 10, self::CODE_LENGTH ); + + return str_pad( $code, self::CODE_LENGTH, '0', STR_PAD_LEFT ); + } + + /** + * Generate secret key. + * + * @param int $length Length in bytes (16 = 26 base32 chars). + * @return string + */ + private function generate_secret( $length = 16 ) { + $random = wp_generate_password( $length, false, false ); + return $this->base32_encode( $random ); + } + + /** + * Generate backup codes. + * + * @param int $count Number of codes. + * @return array + */ + private function generate_backup_codes( $count = 10 ) { + $codes = array(); + for ( $i = 0; $i < $count; $i++ ) { + $codes[] = strtoupper( wp_generate_password( 8, false, false ) ); + } + return $codes; + } + + /** + * Get backup codes count for user (hashed codes stored). + * + * @param int $user_id User ID. + * @return int + */ + public function get_backup_codes_count( $user_id ) { + $codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true ); + return is_array( $codes ) ? count( $codes ) : 0; + } + + /** + * Get backup codes for user (internal use only). + * + * @param int $user_id User ID. + * @return array + */ + private function get_backup_codes( $user_id ) { + $codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true ); + return is_array( $codes ) ? $codes : array(); + } + + /** + * Hash a backup code for secure storage. + * + * @param string $code Plain backup code. + * @return string Hashed code. + */ + private function hash_backup_code( $code ) { + return wp_hash( strtoupper( $code ) ); + } + + /** + * Verify and consume a backup code. + * + * @param int $user_id User ID. + * @param string $code Backup code. + * @return bool + */ + private function verify_backup_code( $user_id, $code ) { + $stored_codes = $this->get_backup_codes( $user_id ); + $code = strtoupper( preg_replace( '/[^A-Z0-9]/', '', $code ) ); + $hashed_input = $this->hash_backup_code( $code ); + + // Check against hashed codes. + foreach ( $stored_codes as $index => $stored_code ) { + // Support both hashed (new) and plain (legacy) codes. + if ( hash_equals( $stored_code, $hashed_input ) || hash_equals( $stored_code, $code ) ) { + // Remove used code. + unset( $stored_codes[ $index ] ); + update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, array_values( $stored_codes ) ); + return true; + } + } + + return false; + } + + /** + * Base32 encode. + * + * @param string $data Data to encode. + * @return string + */ + private function base32_encode( $data ) { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $binary = ''; + $encoded = ''; + + foreach ( str_split( $data ) as $char ) { + $binary .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT ); + } + + $chunks = str_split( $binary, 5 ); + + foreach ( $chunks as $chunk ) { + $chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT ); + $encoded .= $alphabet[ bindec( $chunk ) ]; + } + + return $encoded; + } + + /** + * Base32 decode. + * + * @param string $data Data to decode. + * @return string + */ + private function base32_decode( $data ) { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $data = strtoupper( $data ); + $binary = ''; + $decoded = ''; + + foreach ( str_split( $data ) as $char ) { + $pos = strpos( $alphabet, $char ); + if ( false !== $pos ) { + $binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT ); + } + } + + $chunks = str_split( $binary, 8 ); + + foreach ( $chunks as $chunk ) { + if ( strlen( $chunk ) === 8 ) { + $decoded .= chr( bindec( $chunk ) ); + } + } + + return $decoded; + } +} diff --git a/wp-security-pack/includes/index.php b/wp-security-pack/includes/index.php new file mode 100644 index 0000000..0343009 --- /dev/null +++ b/wp-security-pack/includes/index.php @@ -0,0 +1,6 @@ +query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_wpsp_%'" ); +$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_wpsp_%'" ); + +// Clear scheduled events. +wp_clear_scheduled_hook( 'wpsp_daily_cleanup' ); +wp_clear_scheduled_hook( 'wpsp_daily_file_scan' ); +wp_clear_scheduled_hook( 'wpsp_weekly_malware_scan' ); + +// Delete user meta for 2FA. +$wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE '_wpsp_%'" ); diff --git a/wp-security-pack/wp-security-pack.php b/wp-security-pack/wp-security-pack.php new file mode 100644 index 0000000..0d910eb --- /dev/null +++ b/wp-security-pack/wp-security-pack.php @@ -0,0 +1,278 @@ +load_dependencies(); + $this->init_hooks(); + } + + /** + * Load required files. + */ + private function load_dependencies() { + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-helper.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-db.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-activity-log.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-ip-control.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-login-protection.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-hardening.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-geo-blocking.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-two-factor.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-file-integrity.php'; + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-malware-scanner.php'; + + if ( is_admin() ) { + require_once WPSP_PLUGIN_DIR . 'includes/class-wpsp-admin.php'; + } + } + + /** + * Initialize hooks. + */ + private function init_hooks() { + register_activation_hook( WPSP_PLUGIN_FILE, array( 'WPSP_DB', 'activate' ) ); + register_deactivation_hook( WPSP_PLUGIN_FILE, array( 'WPSP_DB', 'deactivate' ) ); + + add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) ); + add_action( 'plugins_loaded', array( 'WPSP_DB', 'maybe_upgrade' ) ); + add_action( 'init', array( $this, 'init_components' ), 1 ); + + // Schedule cleanup cron. + add_action( 'wpsp_daily_cleanup', array( 'WPSP_Activity_Log', 'cleanup_old_logs' ) ); + + if ( ! wp_next_scheduled( 'wpsp_daily_cleanup' ) ) { + wp_schedule_event( time(), 'daily', 'wpsp_daily_cleanup' ); + } + } + + /** + * Load plugin textdomain. + */ + public function load_textdomain() { + load_plugin_textdomain( + 'wp-security-pack', + false, + dirname( WPSP_PLUGIN_BASENAME ) . '/languages/' + ); + } + + /** + * Initialize plugin components. + * + * Note: Order matters! Components that may exit early (like login_protection + * with custom login URLs) must be initialized AFTER components that need + * to register hooks (like two_factor for 2FA on login). + */ + public function init_components() { + $this->components['activity_log'] = new WPSP_Activity_Log(); + $this->components['ip_control'] = new WPSP_IP_Control(); + $this->components['hardening'] = new WPSP_Hardening(); + $this->components['geo_blocking'] = new WPSP_Geo_Blocking(); + $this->components['two_factor'] = new WPSP_Two_Factor(); + $this->components['file_integrity'] = new WPSP_File_Integrity(); + $this->components['malware_scanner'] = new WPSP_Malware_Scanner(); + + // Login protection must be last - custom login URL handling may exit early. + $this->components['login_protection'] = new WPSP_Login_Protection(); + + if ( is_admin() ) { + $this->components['admin'] = new WPSP_Admin(); + } + } + + /** + * Get a component. + * + * @param string $name Component name. + * @return object|null + */ + public function get_component( $name ) { + return isset( $this->components[ $name ] ) ? $this->components[ $name ] : null; + } + + /** + * Get default settings. + * + * @return array + */ + public static function get_default_settings() { + return array( + // Login Protection. + 'login_limit_enabled' => true, + 'login_max_attempts' => 5, + 'login_lockout_duration' => 15, + 'login_rename_enabled' => false, + 'login_custom_url' => '', + 'hide_wp_admin' => false, + 'honeypot_enabled' => true, + 'honeypot_ban_duration' => 60, // Minutes. + 'hide_login_errors' => true, + 'admin_login_notify' => false, + + // Login Access Restriction. + 'admin_access_restriction' => false, + 'admin_allowed_countries' => array(), + 'admin_allowed_ips' => '', + + // IP Control. + 'ip_whitelist' => '', + 'ip_blacklist' => '', + 'auto_blacklist_enabled' => false, + 'auto_blacklist_threshold' => 3, + + // Geo Blocking. + 'geo_blocking_enabled' => false, + 'geo_blocked_countries' => array(), + 'geo_database_path' => '', + + // Hardening. + 'disable_xmlrpc' => true, + 'disable_file_editing' => true, + 'disable_application_passwords' => false, + 'restrict_rest_api' => true, + 'remove_wp_version' => true, + 'remove_feed_links' => false, + 'add_security_headers' => true, + 'disable_user_enumeration' => true, + 'disable_pingbacks' => true, + + // Two-Factor Authentication. + 'two_factor_enabled' => false, + 'two_factor_enforce_admin' => false, + + // File Integrity. + 'file_integrity_enabled' => true, + + // Malware Scanner. + 'malware_scan_enabled' => true, + + // Email Alerts. + 'email_alerts_enabled' => false, + 'email_alerts_address' => '', + 'email_alert_threshold' => 3, + + // Activity Log. + 'log_retention_days' => 30, + ); + } + + /** + * Get a setting value. + * + * @param string $key Setting key. + * @param mixed $default Default value. + * @return mixed + */ + public static function get_setting( $key, $default = null ) { + $settings = get_option( 'wpsp_settings', array() ); + $defaults = self::get_default_settings(); + + if ( isset( $settings[ $key ] ) ) { + return $settings[ $key ]; + } + + if ( null !== $default ) { + return $default; + } + + return isset( $defaults[ $key ] ) ? $defaults[ $key ] : null; + } + + /** + * Update a setting value. + * + * @param string $key Setting key. + * @param mixed $value Setting value. + * @return bool + */ + public static function update_setting( $key, $value ) { + $settings = get_option( 'wpsp_settings', array() ); + $settings[ $key ] = $value; + return update_option( 'wpsp_settings', $settings ); + } + + /** + * Prevent cloning. + */ + private function __clone() {} + + /** + * Prevent unserialization. + */ + public function __wakeup() { + throw new Exception( 'Cannot unserialize singleton' ); + } +} + +/** + * Get plugin instance. + * + * @return WP_Security_Pack + */ +function wpsp() { + return WP_Security_Pack::instance(); +} + +// Initialize plugin. +wpsp();