├── classes ├── Psr │ └── Log │ │ ├── InvalidArgumentException.php │ │ ├── LoggerAwareInterface.php │ │ ├── LoggerAwareTrait.php │ │ ├── LogLevel.php │ │ ├── AbstractLogger.php │ │ ├── NullLogger.php │ │ ├── LoggerTrait.php │ │ └── LoggerInterface.php └── BlueChip │ └── Security │ ├── Modules │ ├── ExternalBlocklist │ │ ├── WarmUpException.php │ │ ├── Blocklist.php │ │ ├── Sources │ │ │ └── AmazonWebServices.php │ │ ├── Settings.php │ │ ├── Source.php │ │ └── AdminPage.php │ ├── Initializable.php │ ├── Loadable.php │ ├── Checklist │ │ ├── BasicCheck.php │ │ ├── AdvancedCheck.php │ │ ├── AutorunSettings.php │ │ ├── Checks │ │ │ ├── PhpFilesEditationDisabled.php │ │ │ ├── DirectoryListingDisabled.php │ │ │ ├── NoObviousUsernamesCheck.php │ │ │ ├── PhpVersionSupported.php │ │ │ ├── NoMd5HashedPasswords.php │ │ │ ├── ErrorLogNotPubliclyAccessible.php │ │ │ ├── NoAccessToPhpFilesInUploadsDirectory.php │ │ │ ├── DisplayOfPhpErrorsIsOff.php │ │ │ └── SafeBrowsing.php │ │ ├── CheckResult.php │ │ ├── Hooks.php │ │ └── Check.php │ ├── Activable.php │ ├── Installable.php │ ├── InternalBlocklist │ │ ├── BanReason.php │ │ ├── Hooks.php │ │ └── HtaccessSynchronizer.php │ ├── Countable.php │ ├── Cron │ │ ├── Recurrence.php │ │ ├── Settings.php │ │ ├── Jobs.php │ │ ├── Manager.php │ │ └── Job.php │ ├── Hardening │ │ ├── Hooks.php │ │ └── Settings.php │ ├── BadRequestsBanner │ │ ├── Hooks.php │ │ ├── BanRule.php │ │ ├── BuiltInRules.php │ │ ├── Core.php │ │ └── Settings.php │ ├── Notifications │ │ ├── Hooks.php │ │ ├── Message.php │ │ ├── Settings.php │ │ ├── AdminPage.php │ │ └── Mailman.php │ ├── Login │ │ ├── Hooks.php │ │ ├── Settings.php │ │ └── Bookkeeper.php │ ├── Services │ │ └── ReverseDnsLookup │ │ │ ├── Response.php │ │ │ └── Resolver.php │ ├── Log │ │ ├── Events │ │ │ ├── LoginSuccessful.php │ │ │ ├── Query404.php │ │ │ ├── AuthBadCookie.php │ │ │ ├── LoginLockout.php │ │ │ ├── BadRequestBan.php │ │ │ ├── LoginFailure.php │ │ │ └── BlocklistHit.php │ │ ├── Hooks.php │ │ ├── Settings.php │ │ ├── Action.php │ │ ├── EventsManager.php │ │ ├── Event.php │ │ ├── AdminPage.php │ │ └── EventsMonitor.php │ └── Access │ │ ├── Hooks.php │ │ ├── Scope.php │ │ └── Bouncer.php │ ├── Helpers │ ├── Hooks.php │ ├── Utils.php │ ├── IpAddress.php │ ├── MySQLDateTime.php │ ├── AdminNotices.php │ ├── WpRemote.php │ ├── AjaxHelper.php │ ├── Is.php │ ├── HaveIBeenPwned.php │ ├── PhpVersion.php │ └── Transients.php │ ├── Setup │ ├── GoogleAPI.php │ ├── Settings.php │ ├── Core.php │ └── IpAddress.php │ ├── Core │ ├── Admin │ │ ├── ListingPage.php │ │ ├── CountablePage.php │ │ ├── PageWithAssets.php │ │ └── AbstractPage.php │ └── AssetsManager.php │ ├── Settings.php │ ├── Admin.php │ └── Modules.php ├── assets ├── css │ └── checklist.css └── js │ └── checklist.js ├── uninstall.php ├── autoload.php ├── LICENSE ├── composer.json └── bc-security.php /classes/Psr/Log/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | uninstall(); 19 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/InternalBlocklist/BanReason.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /classes/Psr/Log/LogLevel.php: -------------------------------------------------------------------------------- 1 | base === 'dashboard'; 15 | * }, 10, 3); 16 | */ 17 | public const SHOW_PWNED_PASSWORD_WARNING = 'bc-security/filter:show-pwned-password-warning'; 18 | } 19 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | logger) { }` 13 | * blocks. 14 | */ 15 | class NullLogger extends AbstractLogger 16 | { 17 | /** 18 | * Logs with an arbitrary level. 19 | * 20 | * @param mixed[] $context 21 | * 22 | * @throws \Psr\Log\InvalidArgumentException 23 | */ 24 | public function log($level, string|\Stringable $message, array $context = []): void 25 | { 26 | // noop 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Login/Hooks.php: -------------------------------------------------------------------------------- 1 | sources[get_class($source)] = $source; 20 | } 21 | 22 | /** 23 | * Get source that has given $ip_address or null if no such source exists in this blocklist. 24 | */ 25 | public function getSource(string $ip_address): ?Source 26 | { 27 | foreach ($this->sources as $source) { 28 | if ($source->hasIpAddress($ip_address)) { 29 | return $source; 30 | } 31 | } 32 | 33 | return null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/Utils.php: -------------------------------------------------------------------------------- 1 | %s', $ip_address)) 21 | ; 22 | // 23 | wp_die($error_msg, __('Service Temporarily Unavailable', 'bc-security'), 503); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Cron/Settings.php: -------------------------------------------------------------------------------- 1 | Default values for all settings. 16 | */ 17 | protected const DEFAULTS = [ 18 | Jobs::CHECKLIST_CHECK => true, 19 | Jobs::EXTERNAL_BLOCKLIST_REFRESH => false, 20 | Jobs::FAILED_LOGINS_CLEAN_UP => true, 21 | Jobs::INTERNAL_BLOCKLIST_CLEAN_UP => true, 22 | Jobs::LOGS_CLEAN_UP_BY_AGE => true, 23 | Jobs::LOGS_CLEAN_UP_BY_SIZE => true, 24 | Jobs::CORE_INTEGRITY_CHECK => false, 25 | Jobs::PLUGINS_INTEGRITY_CHECK => false, 26 | Jobs::NO_REMOVED_PLUGINS_CHECK => false, 27 | Jobs::SAFE_BROWSING_CHECK => false, 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Setup/GoogleAPI.php: -------------------------------------------------------------------------------- 1 | key = $settings[Settings::GOOGLE_API_KEY]; 21 | } 22 | 23 | 24 | /** 25 | * @return string Google API key as set by `BC_SECURITY_GOOGLE_API_KEY` constant or empty string if constant is not set. 26 | */ 27 | public static function getStaticKey(): string 28 | { 29 | return \defined('BC_SECURITY_GOOGLE_API_KEY') ? BC_SECURITY_GOOGLE_API_KEY : ''; 30 | } 31 | 32 | 33 | /** 34 | * Get API key configured either by constant or backend setting. 35 | * 36 | * @return string 37 | */ 38 | public function getKey(): string 39 | { 40 | return self::getStaticKey() ?: $this->key; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Services/ReverseDnsLookup/Response.php: -------------------------------------------------------------------------------- 1 | $context 16 | */ 17 | public function __construct(private string $ip_address, private string $hostname, private array $context) 18 | { 19 | $this->ip_address = $ip_address; 20 | $this->hostname = $hostname; 21 | $this->context = $context; 22 | } 23 | 24 | 25 | public function getIpAddress(): string 26 | { 27 | return $this->ip_address; 28 | } 29 | 30 | 31 | public function getHostname(): string 32 | { 33 | return $this->hostname; 34 | } 35 | 36 | 37 | /** 38 | * @return array 39 | */ 40 | public function getContext(): array 41 | { 42 | return $this->context; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/BadRequestsBanner/BanRule.php: -------------------------------------------------------------------------------- 1 | description; 22 | } 23 | 24 | /** 25 | * @return string Rule name. 26 | */ 27 | public function getName(): string 28 | { 29 | return $this->name; 30 | } 31 | 32 | /** 33 | * @param string $uri URI to match against rule 34 | * 35 | * @return bool True if rule matches given URI, false otherwise. 36 | */ 37 | public function matches(string $uri): bool 38 | { 39 | $pattern = sprintf('/%s/i', $this->pattern); 40 | 41 | return (bool) preg_match($pattern, $uri); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/IpAddress.php: -------------------------------------------------------------------------------- 1 | username = $username; 44 | return $this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Hooks.php: -------------------------------------------------------------------------------- 1 | request_uri = $request_uri; 44 | return $this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Events/AuthBadCookie.php: -------------------------------------------------------------------------------- 1 | username = $username; 44 | return $this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Core/Admin/ListingPage.php: -------------------------------------------------------------------------------- 1 | per_page_option_name = $option_name; 25 | 26 | add_filter('set-screen-option', fn (mixed $screen_option, string $option, int $value): mixed => ($option === $option_name) ? $value : $screen_option, 10, 3); 27 | } 28 | 29 | 30 | /** 31 | * @link https://developer.wordpress.org/reference/functions/add_screen_option/ 32 | */ 33 | private function addPerPageOption(): void 34 | { 35 | add_screen_option('per_page', [ 36 | 'label' => __('Records', 'bc-security'), 37 | 'default' => 20, 38 | 'option' => $this->per_page_option_name, 39 | ]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/AdvancedCheck.php: -------------------------------------------------------------------------------- 1 | run(); 39 | 40 | if ($result->getStatus() !== true) { 41 | do_action(Hooks::ADVANCED_CHECK_ALERT, $this, $result); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Access/Hooks.php: -------------------------------------------------------------------------------- 1 | prefixes)) { 31 | return false; 32 | } 33 | 34 | $this->ip_prefixes = []; 35 | foreach ($json->prefixes as $aws_instance) { 36 | $this->ip_prefixes[] = IpAddress::sanitizePrefix($aws_instance->ip_prefix); 37 | } 38 | 39 | return true; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/AutorunSettings.php: -------------------------------------------------------------------------------- 1 | Default values for all settings. By default, no checks are monitored. 16 | */ 17 | protected const DEFAULTS = [ 18 | Checks\PhpFilesEditationDisabled::class => false, 19 | Checks\DirectoryListingDisabled::class => false, 20 | Checks\NoAccessToPhpFilesInUploadsDirectory::class => false, 21 | Checks\DisplayOfPhpErrorsIsOff::class => false, 22 | Checks\ErrorLogNotPubliclyAccessible::class => false, 23 | Checks\NoObviousUsernamesCheck::class => false, 24 | Checks\NoMd5HashedPasswords::class => false, 25 | Checks\PhpVersionSupported::class => false, 26 | Checks\NoPluginsRemovedFromDirectory::class => false, 27 | Checks\CoreIntegrity::class => false, 28 | Checks\PluginsIntegrity::class => false, 29 | Checks\SafeBrowsing::class => false, 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Settings.php: -------------------------------------------------------------------------------- 1 | Default values for all settings. 27 | */ 28 | protected const DEFAULTS = [ 29 | self::LOG_MAX_SIZE => 20, 30 | self::LOG_MAX_AGE => 365, 31 | ]; 32 | 33 | 34 | /** 35 | * @return int Maximum age of log records in seconds. 36 | */ 37 | public function getMaxAge(): int 38 | { 39 | return $this[self::LOG_MAX_AGE] * DAY_IN_SECONDS; 40 | } 41 | 42 | 43 | /** 44 | * @return int Maximum size of log table in number of records. 45 | */ 46 | public function getMaxSize(): int 47 | { 48 | return $this[self::LOG_MAX_SIZE] * 1000; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/MySQLDateTime.php: -------------------------------------------------------------------------------- 1 | getTimestamp() : null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/InternalBlocklist/Hooks.php: -------------------------------------------------------------------------------- 1 | Default values for all settings. 27 | */ 28 | protected const DEFAULTS = [ 29 | self::CONNECTION_TYPE => IpAddress::REMOTE_ADDR, 30 | self::GOOGLE_API_KEY => '', 31 | ]; 32 | 33 | /** 34 | * @var array Custom sanitizers. 35 | */ 36 | protected const SANITIZERS = [ 37 | self::CONNECTION_TYPE => [self::class, 'sanitizeConnectionType'], 38 | ]; 39 | 40 | 41 | /** 42 | * Sanitize connection type. Allow only expected values. 43 | */ 44 | public static function sanitizeConnectionType(string $value, string $default): string 45 | { 46 | return \in_array($value, IpAddress::getOptions(), true) ? $value : $default; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/AdminNotices.php: -------------------------------------------------------------------------------- 1 | $type, 'dismissible' => $is_dismissible,]); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/PhpFilesEditationDisabled.php: -------------------------------------------------------------------------------- 1 | ' . esc_html__('disable editation of PHP files', 'bc-security') . '' 17 | ); 18 | } 19 | 20 | 21 | public function getName(): string 22 | { 23 | return __('PHP files editation disabled', 'bc-security'); 24 | } 25 | 26 | 27 | protected function runInternal(): Checklist\CheckResult 28 | { 29 | return \defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT 30 | ? new Checklist\CheckResult(true, esc_html__('Theme and plugin files cannot be edited from backend.', 'bc-security')) 31 | : new Checklist\CheckResult(false, esc_html__('Theme and plugin files can be edited from backend!', 'bc-security')) 32 | ; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Notifications/Message.php: -------------------------------------------------------------------------------- 1 | body = ($text !== '') ? [$text] : []; 18 | } 19 | 20 | 21 | public function __toString(): string 22 | { 23 | return \implode(PHP_EOL, $this->body); 24 | } 25 | 26 | 27 | public function addEmptyLine(): self 28 | { 29 | return $this->addLine(''); 30 | } 31 | 32 | 33 | public function addLine(string $text = ''): self 34 | { 35 | $this->body[] = $text; 36 | return $this; 37 | } 38 | 39 | 40 | /** 41 | * @param string[] $text 42 | * 43 | * @return self 44 | */ 45 | public function addLines(array $text): self 46 | { 47 | $this->body = \array_merge($this->body, \array_is_list($text) ? $text : \array_values($text)); 48 | return $this; 49 | } 50 | 51 | 52 | public function getFingerprint(): string 53 | { 54 | return \sha1((string) $this); 55 | } 56 | 57 | 58 | /** 59 | * @return string[] 60 | */ 61 | public function getRaw(): array 62 | { 63 | return $this->body; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Setup/Core.php: -------------------------------------------------------------------------------- 1 | connection_type = $settings[Settings::CONNECTION_TYPE]; 21 | } 22 | 23 | 24 | /** 25 | * @return string Connection type as set by `BC_SECURITY_CONNECTION_TYPE` constant or empty string if constant is not set. 26 | */ 27 | public static function getConnectionType(): string 28 | { 29 | return \defined('BC_SECURITY_CONNECTION_TYPE') ? BC_SECURITY_CONNECTION_TYPE : ''; 30 | } 31 | 32 | 33 | /** 34 | * Get remote IP address according to connection type configured either by constant or backend setting. 35 | * 36 | * @return string 37 | */ 38 | public function getRemoteAddress(): string 39 | { 40 | return IpAddress::get(self::getConnectionType() ?: $this->connection_type); 41 | } 42 | 43 | 44 | /** 45 | * Get server IP address. In the moment, there is no way to "configure" it. 46 | * 47 | * @return string 48 | */ 49 | public function getServerAddress(): string 50 | { 51 | return IpAddress::getServer(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Action.php: -------------------------------------------------------------------------------- 1 | Default values for all settings. 15 | */ 16 | protected const DEFAULTS = [ 17 | AmazonWebServices::class => 0, // Unfortunately Scope::ANY->value is not constant expression. 18 | ]; 19 | 20 | /** 21 | * @var array Custom sanitizers. 22 | */ 23 | protected const SANITIZERS = [ 24 | AmazonWebServices::class => [self::class, 'sanitizeAccessScope'], 25 | ]; 26 | 27 | /** 28 | * Sanitize lock scope values. Allow only expected values. 29 | */ 30 | public static function sanitizeAccessScope(int $value, int $default): int 31 | { 32 | return Scope::tryFrom((int) $value) ? ((int) $value) : $default; 33 | } 34 | 35 | /** 36 | * @param string $class Source class 37 | * 38 | * @return bool True if source with $class is enabled in settings, false otherwise. 39 | */ 40 | public function isEnabled(string $class): bool 41 | { 42 | $access_scope = $this->getAccessScope($class); 43 | 44 | return ($access_scope instanceof Scope) && ($access_scope !== Scope::ANY); 45 | } 46 | 47 | public function getAccessScope(string $class): ?Scope 48 | { 49 | $value = $this[$class]; 50 | return \is_int($value) ? Scope::tryFrom($value) : null; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/DirectoryListingDisabled.php: -------------------------------------------------------------------------------- 1 | ' . esc_html__('directory listings', 'bc-security') . '' 17 | ); 18 | } 19 | 20 | 21 | public function getName(): string 22 | { 23 | return __('Directory listing disabled', 'bc-security'); 24 | } 25 | 26 | 27 | protected function runInternal(): Checklist\CheckResult 28 | { 29 | $upload_paths = wp_upload_dir(); 30 | if ($upload_paths['error'] !== false) { 31 | return new Checklist\CheckResult(null, esc_html__('BC Security has failed to determine whether directory listing is disabled.', 'bc-security')); 32 | } 33 | 34 | $response = wp_remote_get($upload_paths['baseurl']); 35 | $response_body = wp_remote_retrieve_body($response); 36 | 37 | return (\stripos($response_body, 'Index of') === false) 38 | ? new Checklist\CheckResult(true, esc_html__('It seems that directory listing is disabled.', 'bc-security')) 39 | : new Checklist\CheckResult(false, esc_html__('It seems that directory listing is not disabled!', 'bc-security')) 40 | ; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Core/Admin/CountablePage.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Core\Admin; 6 | 7 | use BlueChip\Security\Modules\Countable; 8 | 9 | /** 10 | * Provide information for counter displayed along page menu item. 11 | */ 12 | trait CountablePage 13 | { 14 | /** 15 | * @var Countable An object that provides the actual counter value to be displayed. 16 | */ 17 | protected Countable $counter; 18 | 19 | 20 | /** 21 | * Set counter that provides count to be displayed along main menu item for this page. 22 | * 23 | * @param Countable $counter 24 | */ 25 | protected function setCounter(Countable $counter): void 26 | { 27 | $this->counter = $counter; 28 | } 29 | 30 | 31 | /** 32 | * Reset count(er). 33 | */ 34 | protected function resetCount(): void 35 | { 36 | $user = wp_get_current_user(); 37 | // Update $user's last view time for this page. 38 | update_user_meta($user->ID, $this->getCounterUserMetaKey(), \time()); 39 | } 40 | 41 | 42 | /** 43 | * Get count to be displayed along with main menu item for this page. 44 | * 45 | * @return int 46 | */ 47 | public function getCount(): int 48 | { 49 | $user = wp_get_current_user(); 50 | 51 | $last_visit_timestamp = absint(get_user_meta($user->ID, $this->getCounterUserMetaKey(), true)); 52 | 53 | return $last_visit_timestamp ? $this->counter->countFrom($last_visit_timestamp) : $this->counter->countAll(); 54 | } 55 | 56 | 57 | /** 58 | * @return string 59 | */ 60 | private function getCounterUserMetaKey(): string 61 | { 62 | return \implode('/', [$this->getSlug(), 'last-visit']); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Access/Scope.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Access; 6 | 7 | /** 8 | * A different ways to restrict access to the website from a remote address. 9 | */ 10 | enum Scope: int 11 | { 12 | /** 13 | * Not a real scope, just a safe value to use whenever scope is undefined. 14 | */ 15 | case ANY = 0; 16 | 17 | /** 18 | * No access to admin (or login attempt) from IP address is allowed. 19 | */ 20 | case ADMIN = 1; 21 | 22 | /** 23 | * No comments from IP address are allowed. 24 | */ 25 | case COMMENTS = 2; 26 | 27 | /** 28 | * No access to website from IP address is allowed. 29 | */ 30 | case WEBSITE = 3; 31 | 32 | /** 33 | * @return string Human-readable description for scope 34 | */ 35 | public function describe(): string 36 | { 37 | return match ($this) { 38 | self::ANY => __('Do not block anything', 'bc-security'), 39 | self::ADMIN => __('Block access to login', 'bc-security'), 40 | self::COMMENTS => __('Block access to comments functionality', 'bc-security'), 41 | self::WEBSITE => __('Block access to entire website', 'bc-security'), 42 | }; 43 | } 44 | 45 | /** 46 | * Get a list of all lock scopes with human readable description. 47 | * 48 | * @return array<int,string> 49 | */ 50 | public static function explain(): array 51 | { 52 | $access_scopes = self::cases(); 53 | 54 | return \array_combine( 55 | \array_map(fn (Scope $access_scope): int => $access_scope->value, $access_scopes), 56 | \array_map(fn (Scope $access_scope): string => $access_scope->describe(), $access_scopes), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/NoObviousUsernamesCheck.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist\Checks; 6 | 7 | use BlueChip\Security\Modules\Checklist; 8 | 9 | class NoObviousUsernamesCheck extends Checklist\BasicCheck 10 | { 11 | public function getDescription(): string 12 | { 13 | return \sprintf( 14 | /* translators: 1: link to article on WordPress hardening */ 15 | esc_html__('Usernames like "admin" and "administrator" are often used in brute force attacks and %1$s.', 'bc-security'), 16 | '<a href="' . esc_url(__('https://wordpress.org/support/article/hardening-wordpress/#security-through-obscurity', 'bc-security')) . '" rel="noreferrer">' . esc_html__('should be avoided', 'bc-security') . '</a>' 17 | ); 18 | } 19 | 20 | 21 | public function getName(): string 22 | { 23 | return __('No obvious usernames exist', 'bc-security'); 24 | } 25 | 26 | 27 | protected function runInternal(): Checklist\CheckResult 28 | { 29 | // Get (filtered) list of obvious usernames to test. 30 | $obvious = apply_filters(Checklist\Hooks::OBVIOUS_USERNAMES, ['admin', 'administrator']); 31 | // Check for existing usernames. 32 | $existing = \array_filter($obvious, fn (string $username): bool => get_user_by('login', $username) instanceof \WP_User); 33 | 34 | return empty($existing) 35 | ? new Checklist\CheckResult(true, esc_html__('None of the following usernames exists on the system:', 'bc-security') . ' <em>' . \implode(', ', $obvious) . '</em>') 36 | : new Checklist\CheckResult(false, esc_html__('The following obvious usernames exists on the system:', 'bc-security') . ' <em>' . \implode(', ', $existing) . '</em>') 37 | ; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Events/LoginLockout.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log\Events; 6 | 7 | use BlueChip\Security\Modules\Log\Event; 8 | 9 | class LoginLockout extends Event 10 | { 11 | /** 12 | * @var string Static event identificator. 13 | */ 14 | public const ID = 'login_lockdown'; 15 | 16 | /** 17 | * @var string Event log level. 18 | */ 19 | protected const LOG_LEVEL = \Psr\Log\LogLevel::WARNING; 20 | 21 | /** 22 | * __('Duration') 23 | * 24 | * @var int Lockout duration (in seconds). 25 | */ 26 | protected int $duration = 0; 27 | 28 | /** 29 | * __('IP Address') 30 | * 31 | * @var string Remote IP address. 32 | */ 33 | protected string $ip_address = ''; 34 | 35 | /** 36 | * __('Username') 37 | * 38 | * @var string Username used in failed login attempt. 39 | */ 40 | protected string $username = ''; 41 | 42 | 43 | public function getName(): string 44 | { 45 | return __('Login lockout', 'bc-security'); 46 | } 47 | 48 | 49 | public function getMessage(): string 50 | { 51 | return __('Remote IP address {ip_address} has been locked out from login for {duration} seconds. Last username used for login was {username}.', 'bc-security'); 52 | } 53 | 54 | 55 | public function setDuration(int $duration): self 56 | { 57 | $this->duration = $duration; 58 | return $this; 59 | } 60 | 61 | 62 | public function setIpAddress(string $ip_address): self 63 | { 64 | $this->ip_address = $ip_address; 65 | return $this; 66 | } 67 | 68 | 69 | public function setUsername(string $username): self 70 | { 71 | $this->username = $username; 72 | return $this; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Core/Admin/PageWithAssets.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Core\Admin; 6 | 7 | use BlueChip\Security\Core\AssetsManager; 8 | 9 | trait PageWithAssets 10 | { 11 | private AssetsManager $assets_manager; 12 | 13 | 14 | protected function useAssetsManager(AssetsManager $assets_manager): void 15 | { 16 | $this->assets_manager = $assets_manager; 17 | } 18 | 19 | 20 | /** 21 | * @param array<string,string> $assets JS assets to enqueue in [ handle => filename ] format. 22 | */ 23 | protected function enqueueJsAssets(array $assets): void 24 | { 25 | add_action('admin_enqueue_scripts', function () use ($assets) { 26 | foreach ($assets as $handle => $filename) { 27 | wp_enqueue_script( 28 | $handle, 29 | $this->assets_manager->getScriptFileUrl($filename), 30 | ['jquery'], 31 | (string) \filemtime($this->assets_manager->getScriptFilePath($filename)), 32 | true 33 | ); 34 | } 35 | }, 10, 0); 36 | } 37 | 38 | 39 | /** 40 | * @param array<string,string> $assets CSS assets to enqueue in [ handle => filename ] format. 41 | */ 42 | protected function enqueueCssAssets(array $assets): void 43 | { 44 | add_action('admin_enqueue_scripts', function () use ($assets) { 45 | foreach ($assets as $handle => $filename) { 46 | wp_enqueue_style( 47 | $handle, 48 | $this->assets_manager->getStyleFileUrl($filename), 49 | [], 50 | (string) \filemtime($this->assets_manager->getStyleFilePath($filename)) 51 | ); 52 | } 53 | }, 10, 0); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/CheckResult.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist; 6 | 7 | class CheckResult 8 | { 9 | private ?bool $status; 10 | 11 | /** 12 | * @var string[] Human readable message explaining the result as list of (hyper)text lines. 13 | */ 14 | private array $message; 15 | 16 | 17 | /** 18 | * @param bool|null $status Check result status: false if check failed; true if check passed; null for undetermined status. 19 | * @param string|string[] $message Human readable message explaining the result - inline HTML tags are allowed/expected. 20 | */ 21 | public function __construct(?bool $status, array|string $message) 22 | { 23 | $this->status = $status; 24 | $this->message = \is_array($message) ? $message : [$message]; 25 | } 26 | 27 | 28 | /** 29 | * @return string[] Human readable message as list of (hyper)text lines. 30 | */ 31 | public function getMessage(): array 32 | { 33 | return $this->message; 34 | } 35 | 36 | 37 | /** 38 | * @return string Human readable message as single string with HTML tags. 39 | */ 40 | public function getMessageAsHtml(): string 41 | { 42 | return \implode('<br>', $this->message); 43 | } 44 | 45 | 46 | /** 47 | * @return string Human readable message as single string without HTML tags. 48 | */ 49 | public function getMessageAsPlainText(): string 50 | { 51 | return \strip_tags(\implode(PHP_EOL, $this->message)); 52 | } 53 | 54 | 55 | /** 56 | * @return bool|null Check result status: false if check failed; true if check passed; null means status is undetermined. 57 | */ 58 | public function getStatus(): ?bool 59 | { 60 | return $this->status; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Events/BadRequestBan.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log\Events; 6 | 7 | use BlueChip\Security\Modules\Log\Event; 8 | 9 | class BadRequestBan extends Event 10 | { 11 | /** 12 | * @var string Static event identificator. 13 | */ 14 | public const ID = 'bad_request_ban'; 15 | 16 | /** 17 | * @var string Event log level. 18 | */ 19 | protected const LOG_LEVEL = \Psr\Log\LogLevel::WARNING; 20 | 21 | /** 22 | * __('Ban rule') 23 | * 24 | * @var string Ban rule name that matched request URI. 25 | */ 26 | protected string $ban_rule_name = ''; 27 | 28 | /** 29 | * __('Request URI') 30 | * 31 | * @var string Request URI that resulted in ban. 32 | */ 33 | protected string $request_uri = ''; 34 | 35 | /** 36 | * __('IP Address') 37 | * 38 | * @var string Remote IP address. 39 | */ 40 | protected string $ip_address = ''; 41 | 42 | 43 | public function getName(): string 44 | { 45 | return __('Bad request ban', 'bc-security'); 46 | } 47 | 48 | 49 | public function getMessage(): string 50 | { 51 | return __('Request {request_uri} from {ip_address} resulted in 404 error and matched bad request rule {ban_rule_name}.', 'bc-security'); 52 | } 53 | 54 | 55 | public function setIpAddress(string $ip_address): self 56 | { 57 | $this->ip_address = $ip_address; 58 | return $this; 59 | } 60 | 61 | 62 | public function setBanRuleName(string $ban_rule_name): self 63 | { 64 | $this->ban_rule_name = $ban_rule_name; 65 | return $this; 66 | } 67 | 68 | 69 | public function setRequestUri(string $request_uri): self 70 | { 71 | $this->request_uri = $request_uri; 72 | return $this; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Events/LoginFailure.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log\Events; 6 | 7 | use BlueChip\Security\Modules\Log\Event; 8 | use WP_Error; 9 | 10 | class LoginFailure extends Event 11 | { 12 | /** 13 | * @var string Static event identificator. 14 | */ 15 | public const ID = 'login_failure'; 16 | 17 | /** 18 | * @var string Event log level. 19 | */ 20 | protected const LOG_LEVEL = \Psr\Log\LogLevel::NOTICE; 21 | 22 | /** 23 | * __('Username') 24 | * 25 | * @var string Username used in failed login attempt. 26 | */ 27 | protected string $username = ''; 28 | 29 | /** 30 | * __('Error code') 31 | * 32 | * @var string Reason why login failed as error code. 33 | */ 34 | protected string $error_code = ''; 35 | 36 | /** 37 | * __('Error message') 38 | * 39 | * @var string Reason why login failed as human-readable message. 40 | */ 41 | protected string $error_message = ''; 42 | 43 | 44 | public function getName(): string 45 | { 46 | return __('Failed login', 'bc-security'); 47 | } 48 | 49 | 50 | public function getMessage(): string 51 | { 52 | return __('Login attempt with username {username} failed.', 'bc-security'); 53 | } 54 | 55 | 56 | /** 57 | * Set reason why login attempt failed. 58 | */ 59 | public function setError(WP_Error $error): self 60 | { 61 | $this->error_code = (string) $error->get_error_code(); 62 | $this->error_message = $error->get_error_message(); 63 | return $this; 64 | } 65 | 66 | 67 | /** 68 | * Set username used in failed login attempt (if any). 69 | */ 70 | public function setUsername(string $username): self 71 | { 72 | $this->username = $username; 73 | return $this; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/EventsManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log; 6 | 7 | /** 8 | * Events manager helps to maintain event ID => event class/instance mapping. 9 | */ 10 | abstract class EventsManager 11 | { 12 | /** 13 | * @var array<string,string> 14 | */ 15 | private static $mapping = [ 16 | Events\AuthBadCookie::ID => Events\AuthBadCookie::class, 17 | Events\BadRequestBan::ID => Events\BadRequestBan::class, 18 | Events\BlocklistHit::ID => Events\BlocklistHit::class, 19 | Events\LoginFailure::ID => Events\LoginFailure::class, 20 | Events\LoginLockout::ID => Events\LoginLockout::class, 21 | Events\LoginSuccessful::ID => Events\LoginSuccessful::class, 22 | Events\Query404::ID => Events\Query404::class, 23 | ]; 24 | 25 | 26 | /** 27 | * Create event object for given $id. 28 | * 29 | * @param string $event_id Valid event ID. 30 | * 31 | * @return \BlueChip\Security\Modules\Log\Event|null 32 | */ 33 | public static function create(string $event_id): ?Event 34 | { 35 | $classname = self::$mapping[$event_id] ?? ''; 36 | return $classname && (is_subclass_of($classname, Event::class)) ? new $classname() : null; 37 | } 38 | 39 | 40 | /** 41 | * Return list of event classes indexed by their IDs. 42 | * 43 | * @return array<string,string> 44 | */ 45 | public static function getMapping(): array 46 | { 47 | return self::$mapping; 48 | } 49 | 50 | 51 | /** 52 | * Return list of event instances indexed by their IDs. 53 | * 54 | * @return array<string,Event> 55 | */ 56 | public static function getInstances(): array 57 | { 58 | return \array_map( 59 | fn (string $classname): Event => new $classname(), 60 | self::$mapping 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/WpRemote.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Helpers; 6 | 7 | /** 8 | * Wrapper on top of \wp_remote_* methods. 9 | */ 10 | abstract class WpRemote 11 | { 12 | /** 13 | * Fetch JSON data from remote $url. 14 | * 15 | * @param string $url 16 | * 17 | * @return mixed 18 | */ 19 | public static function getJson(string $url): mixed 20 | { 21 | // Make request to URL. 22 | $response = wp_remote_get($url); 23 | 24 | // Check response code. 25 | if (wp_remote_retrieve_response_code($response) !== 200) { 26 | return null; 27 | } 28 | 29 | // Read JSON. 30 | $json = \json_decode(wp_remote_retrieve_body($response)); 31 | 32 | // If decoding went fine, return JSON data. 33 | return (\json_last_error() === JSON_ERROR_NONE) ? $json : null; 34 | } 35 | 36 | 37 | /** 38 | * Post given $body data as JSON to remote $url and return decoded response. 39 | * 40 | * @param string $url 41 | * @param mixed $body 42 | * 43 | * @return mixed 44 | */ 45 | public static function postJson(string $url, mixed $body): mixed 46 | { 47 | // Make POST request to remote $url. 48 | $response = wp_remote_post( 49 | $url, 50 | [ 51 | 'headers' => ['content-type' => 'application/json'], 52 | 'body' => \json_encode($body) ?: '', 53 | ] 54 | ); 55 | 56 | // Check response code. 57 | if (wp_remote_retrieve_response_code($response) !== 200) { 58 | return null; 59 | } 60 | 61 | // Read JSON. 62 | $json = \json_decode(wp_remote_retrieve_body($response), true); 63 | 64 | // If decoding went fine, return JSON data. 65 | return (\json_last_error() === JSON_ERROR_NONE) ? $json : null; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/AjaxHelper.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Helpers; 6 | 7 | /** 8 | * @link https://codex.wordpress.org/AJAX 9 | */ 10 | abstract class AjaxHelper 11 | { 12 | /** 13 | * @var string 14 | */ 15 | private const WP_AJAX_PREFIX = 'wp_ajax_'; 16 | 17 | 18 | /** 19 | * Register callback as handler for AJAX action. Handler will be only executed when nonce check passes. 20 | * 21 | * @param string $action 22 | * @param callable $handler 23 | */ 24 | public static function addHandler(string $action, callable $handler): void 25 | { 26 | add_action(self::WP_AJAX_PREFIX . $action, function () use ($action, $handler) { 27 | // Check AJAX referer for given action - will die if invalid. 28 | check_ajax_referer($action); 29 | 30 | \call_user_func($handler); 31 | }, 10, 0); 32 | } 33 | 34 | 35 | /** 36 | * Inject AJAX setup to page. Should be called *after* a script with $handle is registered or enqueued! 37 | * 38 | * @param string $handle 39 | * @param string $object_name 40 | * @param string $action 41 | * @param array<string,mixed> $data 42 | */ 43 | public static function injectSetup(string $handle, string $object_name, string $action, array $data = []): void 44 | { 45 | add_action('admin_enqueue_scripts', function () use ($handle, $object_name, $action, $data) { 46 | // Default localization data for every AJAX request. 47 | $l10n = [ 48 | 'ajaxurl' => admin_url('admin-ajax.php'), 49 | 'nonce' => wp_create_nonce($action), 50 | 'action' => $action, 51 | ]; 52 | 53 | wp_localize_script( 54 | $handle, 55 | $object_name, 56 | \array_merge($data, $l10n) 57 | ); 58 | }, 10, 0); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Hooks.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist; 6 | 7 | /** 8 | * Hooks available in checklist module 9 | */ 10 | interface Hooks 11 | { 12 | /** 13 | * Action: triggers when a single advanced check does not pass during checklist monitoring run. 14 | */ 15 | public const ADVANCED_CHECK_ALERT = 'bc-security/action:checklist-advanced-check-alert'; 16 | 17 | /** 18 | * Action: triggers when any of basic checks does not pass during checklist monitoring run. 19 | */ 20 | public const BASIC_CHECKS_ALERT = 'bc-security/action:checklist-basic-checks-alert'; 21 | 22 | /** 23 | * Filter: allows to add/remove usernames to the list of obvious usernames. 24 | * 25 | * add_filter(\BlueChip\Security\Modules\Checklist\Hooks::OBVIOUS_USERNAMES, function ($usernames) { 26 | * return array_merge(['mr-obvious'], $usernames); 27 | * }, 10, 1); 28 | */ 29 | public const OBVIOUS_USERNAMES = 'bc-security/filter:obvious-usernames'; 30 | 31 | /** 32 | * Filter: filters list of files that should be ignored during check for modified core files. 33 | */ 34 | public const IGNORED_CORE_MODIFIED_FILES = 'bc-security/filter:modified-files-ignored-in-core-integrity-check'; 35 | 36 | /** 37 | * Filter: filters list of files that should be ignored during check for unknown core files. 38 | */ 39 | public const IGNORED_CORE_UNKNOWN_FILES = 'bc-security/filter:unknown-files-ignored-in-core-integrity-check'; 40 | 41 | /** 42 | * Filter: filters list of plugins to check in integrity check. 43 | */ 44 | public const PLUGINS_TO_CHECK_FOR_INTEGRITY = 'bc-security/filter:plugins-to-check-for-integrity'; 45 | 46 | /** 47 | * Filter: allows to filter list of plugins that are checked for removal from Plugins Directory at WordPress.org. 48 | */ 49 | public const PLUGINS_TO_CHECK_FOR_REMOVAL = 'bc-security/filter:plugins-to-check-for-removal'; 50 | } 51 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Cron/Jobs.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Cron; 6 | 7 | abstract class Jobs 8 | { 9 | /** string: Hook name for "Checklist autorun" cron job */ 10 | public const CHECKLIST_CHECK = 'bc-security/checklist-autorun'; 11 | 12 | /** string: Hook name for "External blocklist refresh" cron job */ 13 | public const EXTERNAL_BLOCKLIST_REFRESH = 'bc-security/external-blocklist-refresh'; 14 | 15 | /** string: Hook name for "Failed logins table clean up" cron job */ 16 | public const FAILED_LOGINS_CLEAN_UP = 'bc-security/failed-logins-clean-up'; 17 | 18 | /** string: Hook name for "Automatic internal blocklist purging" cron job */ 19 | public const INTERNAL_BLOCKLIST_CLEAN_UP = 'bc-security/internal-blocklist-clean-up'; 20 | 21 | /** string: Hook name for "Clean logs by age" cron job */ 22 | public const LOGS_CLEAN_UP_BY_AGE = 'bc-security/logs-clean-up-by-age'; 23 | 24 | /** string: Hook name for "Clean logs by size" cron job */ 25 | public const LOGS_CLEAN_UP_BY_SIZE = 'bc-security/logs-clean-up-by-size'; 26 | 27 | /** string: Hook name for "WordPress core files are untouched" check monitor */ 28 | public const CORE_INTEGRITY_CHECK = 'bc-security/core-integrity-check'; 29 | 30 | /** string: Hook name for "Plugin files are untouched" check monitor */ 31 | public const PLUGINS_INTEGRITY_CHECK = 'bc-security/plugin-integrity-check'; 32 | 33 | /** string: Hook name for "No plugins removed from WordPress.org installed" check monitor */ 34 | public const NO_REMOVED_PLUGINS_CHECK = 'bc-security/no-removed-plugins-check'; 35 | 36 | /** string: Hook name for "Site is not blacklisted by Google" check monitor */ 37 | public const SAFE_BROWSING_CHECK = 'bc-security/safe-browsing-check'; 38 | 39 | /** 40 | * @return array<string,string> List of all implemented cron jobs. 41 | */ 42 | public static function enlist(): array 43 | { 44 | $reflection = new \ReflectionClass(self::class); 45 | return $reflection->getConstants(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/ExternalBlocklist/Source.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\ExternalBlocklist; 6 | 7 | use BlueChip\Security\Helpers\IpAddress; 8 | 9 | /** 10 | * Base class for all sources for external blocklist. 11 | */ 12 | abstract class Source implements \Countable 13 | { 14 | /** 15 | * @var string[] List of IP prefixes for this source. 16 | */ 17 | protected array $ip_prefixes; 18 | 19 | /** 20 | * @return int Count of IP prefixes for this source. 21 | */ 22 | public function count(): int 23 | { 24 | return $this->getSize(); 25 | } 26 | 27 | /** 28 | * @return string[] List of IP prefixes for this source. 29 | */ 30 | public function getIpPrefixes(): array 31 | { 32 | return $this->ip_prefixes; 33 | } 34 | 35 | /** 36 | * @param string[] $ip_prefixes List of IP prefixes for this source. 37 | */ 38 | public function setIpPrefixes(array $ip_prefixes): void 39 | { 40 | $this->ip_prefixes = $ip_prefixes; 41 | } 42 | 43 | /** 44 | * @return int Count of IP prefixes for this source. 45 | */ 46 | public function getSize(): int 47 | { 48 | return \count($this->ip_prefixes); 49 | } 50 | 51 | /** 52 | * @return bool True if source contains given IP address in one of its IP ranges, false otherwise. 53 | */ 54 | public function hasIpAddress(string $ip_address): bool 55 | { 56 | foreach ($this->ip_prefixes as $ip_prefix) { 57 | if (IpAddress::matchesPrefix($ip_address, $ip_prefix)) { 58 | return true; 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | 65 | /** 66 | * @return string Human-readable title of this source. 67 | */ 68 | abstract public function getTitle(): string; 69 | 70 | /** 71 | * Fetch IP prefixes from remote origin. 72 | * 73 | * @return bool True if update succeeded, false otherwise. 74 | */ 75 | abstract public function updateIpPrefixes(): bool; 76 | } 77 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Core/AssetsManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Core; 6 | 7 | class AssetsManager 8 | { 9 | /** 10 | * @var string Relative path to directory with CSS assets. 11 | */ 12 | private const CSS_ASSETS_DIRECTORY_PATH = 'assets/css/'; 13 | 14 | /** 15 | * @var string Relative path to directory with JavaScript assets. 16 | */ 17 | private const JS_ASSETS_DIRECTORY_PATH = 'assets/js/'; 18 | 19 | 20 | /** 21 | * @param string $plugin_filename Absolute path to main plugin file. 22 | */ 23 | public function __construct(private string $plugin_filename) 24 | { 25 | } 26 | 27 | 28 | /** 29 | * @param string $filename Asset filename (ie. asset.js). 30 | * 31 | * @return string Absolute path to the asset. 32 | */ 33 | public function getScriptFilePath(string $filename): string 34 | { 35 | return \implode('', [plugin_dir_path($this->plugin_filename), self::JS_ASSETS_DIRECTORY_PATH, $filename]); 36 | } 37 | 38 | 39 | /** 40 | * @param string $filename Asset filename (ie. asset.js). 41 | * 42 | * @return string URL of the asset. 43 | */ 44 | public function getScriptFileUrl(string $filename): string 45 | { 46 | return \implode('', [plugin_dir_url($this->plugin_filename), self::JS_ASSETS_DIRECTORY_PATH, $filename]); 47 | } 48 | 49 | 50 | /** 51 | * @param string $filename Asset filename (ie. asset.css). 52 | * 53 | * @return string Absolute path to the asset. 54 | */ 55 | public function getStyleFilePath(string $filename): string 56 | { 57 | return \implode('', [plugin_dir_path($this->plugin_filename), self::CSS_ASSETS_DIRECTORY_PATH, $filename]); 58 | } 59 | 60 | 61 | /** 62 | * @param string $filename Asset filename (ie. asset.css). 63 | * 64 | * @return string URL of the asset. 65 | */ 66 | public function getStyleFileUrl(string $filename): string 67 | { 68 | return \implode('', [plugin_dir_url($this->plugin_filename), self::CSS_ASSETS_DIRECTORY_PATH, $filename]); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chesio/bc-security", 3 | "type": "wordpress-plugin", 4 | "license": "Unlicense", 5 | "description": "A WordPress plugin that helps keeping WordPress websites secure.", 6 | "homepage": "https://github.com/chesio/bc-security", 7 | "authors": [ 8 | { 9 | "name": "Česlav Przywara", 10 | "homepage": "https://www.chesio.com" 11 | } 12 | ], 13 | "config": { 14 | "sort-packages": true, 15 | "allow-plugins": { 16 | "composer/installers": true, 17 | "dealerdirect/phpcodesniffer-composer-installer": true 18 | } 19 | }, 20 | "keywords": [ 21 | "wordpress", "wordpress-plugin" 22 | ], 23 | "support": { 24 | "issues": "https://github.com/chesio/bc-security/issues" 25 | }, 26 | "require": { 27 | "php": "^8.2", 28 | "composer/installers": "^1.0 || ^2.0" 29 | }, 30 | "require-dev": { 31 | "brain/monkey": "^2.3", 32 | "mockery/mockery": "^1.4", 33 | "php-parallel-lint/php-parallel-lint": "^1.3", 34 | "phpunit/phpunit": "^11.5", 35 | "slevomat/coding-standard": "^8.24", 36 | "squizlabs/php_codesniffer": "^4.0", 37 | "szepeviktor/phpstan-wordpress": "^2.0", 38 | "yoast/phpunit-polyfills": "^3.0" 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "BlueChip\\Security\\Tests\\Integration\\": "tests/integration/src", 43 | "BlueChip\\Security\\Tests\\Unit\\": "tests/unit/src", 44 | "BlueChip\\Security\\": "classes/BlueChip/Security", 45 | "Psr\\Log\\": "classes/Psr/Log" 46 | } 47 | }, 48 | "scripts": { 49 | "phpcs": "phpcs", 50 | "phpstan": "phpstan analyze", 51 | "full-integration-tests": "phpunit --configuration tests/integration/phpunit.xml", 52 | "integration-tests": "phpunit --configuration tests/integration/phpunit.xml --exclude-group external --no-coverage", 53 | "unit-tests": "phpunit --configuration tests/unit/phpunit.xml --no-coverage", 54 | "unit-tests-with-coverage": "phpunit --configuration tests/unit/phpunit.xml", 55 | "ci": [ 56 | "@phpcs", 57 | "@phpstan", 58 | "@unit-tests" 59 | ], 60 | "test": [ 61 | "@phpcs", 62 | "@phpstan", 63 | "@integration-tests", 64 | "@unit-tests" 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/Is.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Helpers; 6 | 7 | use WP_User; 8 | 9 | /** 10 | * Various is::xxx() helpers. 11 | */ 12 | abstract class Is 13 | { 14 | /** 15 | * Return true if current user is an admin. 16 | */ 17 | public static function admin(WP_User $user): bool 18 | { 19 | return apply_filters( 20 | Hooks::IS_ADMIN, 21 | is_multisite() ? user_can($user, 'manage_network') : user_can($user, 'manage_options'), 22 | $user 23 | ); 24 | } 25 | 26 | 27 | /** 28 | * @return bool True if current webserver interface is CLI, false otherwise. 29 | */ 30 | public static function cli(): bool 31 | { 32 | return \PHP_SAPI === 'cli'; 33 | } 34 | 35 | 36 | /** 37 | * @return bool True if the website is running in live environment, false otherwise. 38 | */ 39 | public static function live(): bool 40 | { 41 | // Consider both production and staging environment as live. 42 | return apply_filters( 43 | Hooks::IS_LIVE, 44 | \in_array(wp_get_environment_type(), ['production', 'staging'], true) 45 | ); 46 | } 47 | 48 | 49 | /** 50 | * Return true if current request is of given $type. 51 | * 52 | * @param string $type One of: admin, ajax, cron, frontend or wp-cli. 53 | * 54 | * @return bool True if current request is of given $type, false otherwise. 55 | */ 56 | public static function request(string $type): bool 57 | { 58 | switch ($type) { 59 | case 'admin': 60 | return is_admin(); 61 | case 'ajax': 62 | return wp_doing_ajax(); 63 | case 'cron': 64 | return wp_doing_cron(); 65 | case 'frontend': 66 | return (!is_admin() || wp_doing_ajax()) && !wp_doing_cron(); 67 | case 'wp-cli': 68 | return \defined('WP_CLI') && \constant('WP_CLI'); 69 | default: 70 | _doing_it_wrong(__METHOD__, \sprintf('Unknown request type: %s', $type), '0.1.0'); 71 | return false; 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Hardening/Settings.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Hardening; 6 | 7 | use BlueChip\Security\Core\Settings as CoreSettings; 8 | 9 | class Settings extends CoreSettings 10 | { 11 | /** 12 | * @var string Disable pingbacks? [bool:no] 13 | */ 14 | public const DISABLE_PINGBACKS = 'disable_pingbacks'; 15 | 16 | /** 17 | * @var string Disable XML RPC methods that require authentication? [bool:no] 18 | */ 19 | public const DISABLE_XML_RPC = 'disable_xml_rpc'; 20 | 21 | /** 22 | * @var string Disable application passwords feature? [bool:no] 23 | */ 24 | public const DISABLE_APPLICATION_PASSWORDS = 'disable_application_passwords'; 25 | 26 | /** 27 | * @var string Disable users listings via REST API `/wp/v2/users` endpoint and author scan via author=N query? [bool:no] 28 | */ 29 | public const DISABLE_USERNAMES_DISCOVERY = 'disable_usernames_discovery'; 30 | 31 | /** 32 | * @var string Remove the option to log in with email and password? [bool:no] 33 | */ 34 | public const DISABLE_LOGIN_WITH_EMAIL = 'disable_login_with_email'; 35 | 36 | /** 37 | * @var string Remove the option to log in with username and password? [bool:no] 38 | */ 39 | public const DISABLE_LOGIN_WITH_USERNAME = 'disable_login_with_username'; 40 | 41 | /** 42 | * @var string Check existing passwords against Pwned Passwords database? [bool:no] 43 | */ 44 | public const CHECK_PASSWORDS = 'check_passwords'; 45 | 46 | /** 47 | * @var string Validate new/updated passwords against Pwned Passwords database? [bool:no] 48 | */ 49 | public const VALIDATE_PASSWORDS = 'validate_passwords'; 50 | 51 | /** 52 | * @var array<string,bool> Default values for all settings. 53 | */ 54 | protected const DEFAULTS = [ 55 | self::DISABLE_PINGBACKS => false, 56 | self::DISABLE_XML_RPC => false, 57 | self::DISABLE_APPLICATION_PASSWORDS => false, 58 | self::DISABLE_USERNAMES_DISCOVERY => false, 59 | self::DISABLE_LOGIN_WITH_EMAIL => false, 60 | self::DISABLE_LOGIN_WITH_USERNAME => false, 61 | self::CHECK_PASSWORDS => false, 62 | self::VALIDATE_PASSWORDS => false, 63 | ]; 64 | } 65 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/HaveIBeenPwned.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Helpers; 6 | 7 | /** 8 | * @link https://haveibeenpwned.com/ 9 | */ 10 | abstract class HaveIBeenPwned 11 | { 12 | /** 13 | * @var string URL of Pwned Passwords home page 14 | */ 15 | public const PWNEDPASSWORDS_HOME_URL = 'https://haveibeenpwned.com/Passwords'; 16 | 17 | /** 18 | * @link https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange 19 | * 20 | * @var string URL of Pwned Passwords API range search end-point 21 | */ 22 | public const PWNEDPASSWORDS_API_RANGE_SEARCH_URL = 'https://api.pwnedpasswords.com/range/'; 23 | 24 | 25 | /** 26 | * @link https://haveibeenpwned.com/API/v2#PwnedPasswords 27 | * 28 | * @param string $password Password to check. 29 | * 30 | * @return bool True if $password has been previously exposed in a data breach, false if not, null if check failed. 31 | */ 32 | public static function hasPasswordBeenPwned(string $password): ?bool 33 | { 34 | $sha1 = \sha1($password); 35 | 36 | // Only first 5 characters of the hash are required. 37 | $sha1_prefix = \substr($sha1, 0, 5); 38 | 39 | $response = wp_remote_get(esc_url(self::PWNEDPASSWORDS_API_RANGE_SEARCH_URL . $sha1_prefix)); 40 | 41 | if (wp_remote_retrieve_response_code($response) !== 200) { 42 | // Note: "there is no circumstance in which the API should return HTTP 404", 43 | // but of course remote request can always fail due network issues. 44 | return null; 45 | } 46 | 47 | $body = wp_remote_retrieve_body($response); 48 | if (empty($body)) { 49 | // Note: Should never happen, as there is a non-empty response for every prefix, 50 | // therefore return null (check failed) rather than false (check negative). 51 | return null; 52 | } 53 | 54 | // Every record has "hash_suffix:count" format. 55 | $records = \explode(PHP_EOL, $body); 56 | foreach ($records as $record) { 57 | [$sha1_suffix, $count] = \explode(':', $record); 58 | 59 | if ($sha1 === ($sha1_prefix . \strtolower($sha1_suffix))) { 60 | return true; // Your password been pwned, my friend! 61 | } 62 | } 63 | 64 | return false; // Ok, you're fine. 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/BadRequestsBanner/BuiltInRules.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\BadRequestsBanner; 6 | 7 | abstract class BuiltInRules 8 | { 9 | public const ARCHIVE_FILES = 'archive-files'; 10 | 11 | private const ARCHIVE_FILES_PATTERN = '\.(tgz|zip)$'; 12 | 13 | public const ASP_FILES = 'asp-files'; 14 | 15 | private const ASP_FILES_PATTERN = '\.aspx?$'; 16 | 17 | public const BACKUP_FILES = 'backup-files'; 18 | 19 | private const BACKUP_FILES_PATTERN = 'backup|(\.(back|old|tmp)$)'; 20 | 21 | public const PHP_FILES = 'php-files'; 22 | 23 | private const PHP_FILES_PATTERN = '\.php$'; 24 | 25 | public const README_FILES = 'readme-txt-files'; 26 | 27 | private const README_FILES_PATTERN = '\/readme\.txt$'; 28 | 29 | /** 30 | * @return array<string,BanRule> 31 | */ 32 | public static function enlist(): array 33 | { 34 | return [ 35 | self::ASP_FILES => new BanRule( 36 | __('Non-existent ASP files', 'bc-security'), 37 | self::ASP_FILES_PATTERN, 38 | __('(any URI targeting file with .asp or .aspx extension)', 'bc-security') 39 | ), 40 | self::PHP_FILES => new BanRule( 41 | __('Non-existent PHP files', 'bc-security'), 42 | self::PHP_FILES_PATTERN, 43 | __('(any URI targeting file with .php extension)', 'bc-security') 44 | ), 45 | self::README_FILES => new BanRule( 46 | __('Non-existent readme.txt files', 'bc-security'), 47 | self::README_FILES_PATTERN, 48 | __('(any URI targeting /readme.txt file)', 'bc-security') 49 | ), 50 | self::ARCHIVE_FILES => new BanRule( 51 | __('Non-existent archive files', 'bc-security'), 52 | self::ARCHIVE_FILES_PATTERN, 53 | __('(any URI targeting file with .tgz or .zip extension)', 'bc-security') 54 | ), 55 | self::BACKUP_FILES => new BanRule( 56 | __('Non-existent backup files', 'bc-security'), 57 | self::BACKUP_FILES_PATTERN, 58 | __('(any URI targeting file with backup in basename or with .back, .old or .tmp extension)', 'bc-security') 59 | ), 60 | ]; 61 | } 62 | 63 | public static function get(string $identifier): ?BanRule 64 | { 65 | return self::enlist()[$identifier] ?? null; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Cron/Manager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Cron; 6 | 7 | use BlueChip\Security\Modules; 8 | 9 | /** 10 | * Cron job factory 11 | */ 12 | class Manager implements Modules\Activable 13 | { 14 | /** 15 | * @var Job[] Cron jobs 16 | */ 17 | private array $jobs = []; 18 | 19 | 20 | /** 21 | * @param Settings $settings Module settings 22 | */ 23 | public function __construct(private Settings $settings) 24 | { 25 | // In the moment, all cron jobs can be scheduled in the same way (at night with daily recurrence). 26 | foreach (Jobs::enlist() as $hook) { 27 | $this->jobs[$hook] = new Job($hook, Job::RUN_AT_NIGHT, Recurrence::DAILY); 28 | } 29 | } 30 | 31 | 32 | public function activate(): void 33 | { 34 | // Schedule cron jobs that are active. 35 | foreach ($this->jobs as $hook => $job) { 36 | if ($this->settings[$hook]) { 37 | $job->schedule(); 38 | } 39 | } 40 | } 41 | 42 | 43 | public function deactivate(): void 44 | { 45 | // Unschedule all scheduled cron jobs. 46 | foreach ($this->jobs as $job) { 47 | if ($job->isScheduled()) { 48 | $job->unschedule(); 49 | } 50 | } 51 | } 52 | 53 | 54 | public function getJob(string $hook): Job 55 | { 56 | return $this->jobs[$hook]; 57 | } 58 | 59 | 60 | /** 61 | * Activate cron job: schedule the job and mark it as permanently active if scheduling succeeds. 62 | * 63 | * @param string $hook 64 | * 65 | * @return bool True if cron job has been activated or was active already, false otherwise. 66 | */ 67 | public function activateJob(string $hook): bool 68 | { 69 | if ($this->getJob($hook)->schedule()) { 70 | $this->settings[$hook] = true; 71 | } 72 | 73 | return $this->settings[$hook] === true; 74 | } 75 | 76 | 77 | /** 78 | * Deactivate cron job: unschedule the job and mark it as permanently inactive if unscheduling succeeds. 79 | * 80 | * @param string $hook 81 | * 82 | * @return bool True if cron job has been deactivated or was inactive already, false otherwise. 83 | */ 84 | public function deactivateJob(string $hook): bool 85 | { 86 | if ($this->getJob($hook)->unschedule()) { 87 | $this->settings[$hook] = false; 88 | } 89 | 90 | return $this->settings[$hook] === false; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/PhpVersion.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Helpers; 6 | 7 | abstract class PhpVersion 8 | { 9 | /** 10 | * @var array<string,string> List of supported PHP versions and their end-of-life dates 11 | * 12 | * @link https://www.php.net/supported-versions.php 13 | */ 14 | private const SUPPORTED_PHP_VERSIONS = [ 15 | '8.1' => '2025-12-31', 16 | '8.2' => '2026-12-31', 17 | '8.3' => '2027-12-31', 18 | '8.4' => '2028-12-31', 19 | ]; 20 | 21 | /** 22 | * @return string Active PHP version as "major.minor" string 23 | */ 24 | public static function get(): string 25 | { 26 | return \sprintf("%s.%s", PHP_MAJOR_VERSION, PHP_MINOR_VERSION); 27 | } 28 | 29 | 30 | /** 31 | * @return string HTML tag with PHP version as <major>.<minor> string with full version in title attribute. 32 | */ 33 | public static function getAsHtmlSnippet(): string 34 | { 35 | return \sprintf('<em title="%s">%s.%s</em>', PHP_VERSION, PHP_MAJOR_VERSION, PHP_MINOR_VERSION); 36 | } 37 | 38 | 39 | /** 40 | * @param string $version PHP version in "major.minor" format (eg. "8.4") 41 | * 42 | * @return string|null EOL-date for given PHP $version in YYYY-MM-DD format or null if unknown. 43 | */ 44 | public static function getEndOfLifeDate(?string $version = null): ?string 45 | { 46 | $version ??= self::get(); 47 | 48 | return self::SUPPORTED_PHP_VERSIONS[$version] ?? null; 49 | } 50 | 51 | 52 | /** 53 | * @param string $version PHP version in "major.minor" format (eg. "8.4") 54 | * 55 | * @return bool|null 56 | */ 57 | public static function isSupported(?string $version = null): ?bool 58 | { 59 | $version ??= self::get(); 60 | 61 | $now = \time(); 62 | 63 | foreach (self::SUPPORTED_PHP_VERSIONS as $supportedPhpVersion => $eol_date) { 64 | if (\strtotime($eol_date) >= $now) { 65 | // Oldest PHP version that is still being supported. 66 | return version_compare($version, $supportedPhpVersion, '>='); 67 | } 68 | } 69 | 70 | // We have out-dated data. 71 | $newestKnownUnsupportedPhpVersion = \array_key_last(self::SUPPORTED_PHP_VERSIONS); 72 | 73 | // If the latest PHP version for which we have EOL is not supported anymore and 74 | // PHP version is the same or older than PHP version must be out-dated too. 75 | // Otherwise we cannot say for sure. 76 | return version_compare($newestKnownUnsupportedPhpVersion, $version, '>=') ? false : null; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/PhpVersionSupported.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist\Checks; 6 | 7 | use BlueChip\Security\Helpers\PhpVersion; 8 | use BlueChip\Security\Modules\Checklist; 9 | 10 | class PhpVersionSupported extends Checklist\BasicCheck 11 | { 12 | public function getDescription(): string 13 | { 14 | return \sprintf( 15 | /* translators: 1: link to official page on supported PHP versions */ 16 | esc_html__('Running an %1$s may pose a security risk.', 'bc-security'), 17 | '<a href="' . esc_url(__('https://www.php.net/supported-versions.php', 'bc-security')) . '" rel="noreferrer">' . esc_html__('unsupported PHP version', 'bc-security') . '</a>' 18 | ); 19 | } 20 | 21 | 22 | public function getName(): string 23 | { 24 | return __('PHP version is supported', 'bc-security'); 25 | } 26 | 27 | 28 | protected function runInternal(): Checklist\CheckResult 29 | { 30 | $phpVersionAsHtml = PhpVersion::getAsHtmlSnippet(); 31 | 32 | $isSupported = PhpVersion::isSupported(); 33 | 34 | if ($isSupported === null) { 35 | $message = \sprintf( 36 | esc_html__('List of supported PHP versions is out-dated. Consider updating the plugin. Btw. you are running PHP %1$s.', 'bc-security'), 37 | $phpVersionAsHtml 38 | ); 39 | return new Checklist\CheckResult(null, $message); 40 | } 41 | 42 | if ($isSupported) { 43 | // PHP version is supported, but do we have end-of-life date? 44 | $eol_date = PhpVersion::getEndOfLifeDate(); 45 | // Format message accordingly. 46 | $message = ($eol_date_timestamp = \strtotime($eol_date)) 47 | ? \sprintf( 48 | esc_html__('You are running PHP %1$s, which is supported until %2$s.', 'bc-security'), 49 | $phpVersionAsHtml, 50 | wp_date(get_option('date_format'), $eol_date_timestamp) 51 | ) 52 | : \sprintf( 53 | esc_html__('You are running PHP %1$s, which is still supported.', 'bc-security'), 54 | $phpVersionAsHtml 55 | ) 56 | ; 57 | return new Checklist\CheckResult(true, $message); 58 | } else { 59 | $message = \sprintf( 60 | esc_html__('You are running PHP %1$s, which is no longer supported! Consider upgrading your PHP version.', 'bc-security'), 61 | $phpVersionAsHtml 62 | ); 63 | return new Checklist\CheckResult(false, $message); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Helpers/Transients.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Helpers; 6 | 7 | /** 8 | * Slightly more flexible API for transients. 9 | */ 10 | abstract class Transients 11 | { 12 | /** 13 | * @var string Prefix common to all transients set by plugin. 14 | */ 15 | private const NAME_PREFIX = 'bc-security_'; 16 | 17 | 18 | /** 19 | * Delete transient. 20 | * 21 | * @param string ...$key 22 | * 23 | * @return bool 24 | */ 25 | public static function deleteFromSite(string ...$key): bool 26 | { 27 | return delete_site_transient(self::name($key)); 28 | } 29 | 30 | 31 | /** 32 | * Remove all stored transients from database. Entire object cache is flushed as well, so use with caution. 33 | * 34 | * @link https://css-tricks.com/the-deal-with-wordpress-transients/ 35 | * 36 | * @param \wpdb $wpdb WordPress database access abstraction object 37 | */ 38 | public static function flush(\wpdb $wpdb): void 39 | { 40 | $table_name = is_multisite() ? $wpdb->sitemeta : $wpdb->options; 41 | 42 | // First, delete all transients from database... 43 | $wpdb->query( 44 | \sprintf( 45 | "DELETE FROM {$table_name} WHERE (option_name LIKE '%s' OR option_name LIKE '%s')", 46 | '_site_transient_' . self::NAME_PREFIX . '%', 47 | '_site_transient_timeout_' . self::NAME_PREFIX . '%' 48 | ) 49 | ); 50 | 51 | // ...then flush object cache, because transients may be stored there as well. 52 | wp_cache_flush(); 53 | } 54 | 55 | 56 | /** 57 | * Get transient. 58 | * 59 | * @param string ...$key 60 | * 61 | * @return mixed 62 | */ 63 | public static function getForSite(string ...$key) 64 | { 65 | return get_site_transient(self::name($key)); 66 | } 67 | 68 | 69 | /** 70 | * Set transient. 71 | * 72 | * @param mixed $value 73 | * @param mixed ...$args 74 | * 75 | * @return bool 76 | */ 77 | public static function setForSite($value, ...$args): bool 78 | { 79 | // If the first from variable arguments is plain integer, take it as expiration value. 80 | $expiration = \is_int($args[0]) ? \array_shift($args) : 0; 81 | 82 | return set_site_transient(self::name($args), $value, $expiration); 83 | } 84 | 85 | 86 | /** 87 | * Create transient name from $key. 88 | * 89 | * @param string[] $key 90 | * 91 | * @return string 92 | */ 93 | private static function name(array $key): string 94 | { 95 | return self::NAME_PREFIX . \md5(\implode(':', $key)); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /bc-security.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /** 4 | * Plugin Name: BC Security 5 | * Plugin URI: https://github.com/chesio/bc-security 6 | * Description: Helps keeping WordPress websites secure. 7 | * Version: 0.27.0-dev 8 | * Author: Česlav Przywara <ceslav@przywara.cz> 9 | * Author URI: https://www.chesio.com 10 | * Requires PHP: 8.2 11 | * Requires at least: 6.4 12 | * Tested up to: 6.9 13 | * Text Domain: bc-security 14 | * GitHub Plugin URI: https://github.com/chesio/bc-security 15 | * Update URI: https://github.com/chesio/bc-security 16 | */ 17 | 18 | declare(strict_types=1); 19 | 20 | if (version_compare(PHP_VERSION, '8.2', '<')) { 21 | // Warn user that his/her PHP version is too low for this plugin to function. 22 | add_action('admin_notices', function () { 23 | echo '<div class="notice notice-error"><p>'; 24 | echo esc_html( 25 | sprintf( 26 | __('BC Security plugin requires PHP 8.2 to function properly, but you have version %s installed. The plugin has been auto-deactivated.', 'bc-security'), 27 | PHP_VERSION 28 | ) 29 | ); 30 | echo '</p></div>'; 31 | // Warn user if his/her PHP version is no longer supported. 32 | if (\BlueChip\Security\Helpers\PhpVersion::isSupported() === false) { 33 | echo '<div class="notice notice-warning"><p>'; 34 | echo sprintf( 35 | __('PHP version %1$s is <a href="%2$s">no longer supported</a>. You should consider upgrading PHP on your webhost.', 'bc-security'), 36 | PHP_VERSION, 37 | 'https://www.php.net/supported-versions.php' 38 | ); 39 | echo '</p></div>'; 40 | } 41 | // https://make.wordpress.org/plugins/2015/06/05/policy-on-php-versions/ 42 | if (isset($_GET['activate'])) { 43 | unset($_GET['activate']); 44 | } 45 | }, 10, 0); 46 | 47 | // Self deactivate. 48 | add_action('admin_init', function () { 49 | deactivate_plugins(plugin_basename(__FILE__)); 50 | }, 10, 0); 51 | 52 | // Bail. 53 | return; 54 | } 55 | 56 | 57 | // Register autoloader for this plugin. 58 | require_once __DIR__ . '/autoload.php'; 59 | 60 | return call_user_func(function () { 61 | // Construct plugin instance. 62 | $bc_security = new \BlueChip\Security\Plugin(__FILE__, $GLOBALS['wpdb']); 63 | 64 | // Register activation hook. 65 | register_activation_hook(__FILE__, [$bc_security, 'activate']); 66 | // Register deactivation hook. 67 | register_deactivation_hook(__FILE__, [$bc_security, 'deactivate']); 68 | 69 | // Boot up the plugin immediately after all plugins are loaded. 70 | add_action('plugins_loaded', [$bc_security, 'load'], 0, 0); 71 | 72 | // Return the instance. 73 | return $bc_security; 74 | }); 75 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/NoMd5HashedPasswords.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist\Checks; 6 | 7 | use BlueChip\Security\Modules\Checklist; 8 | use wpdb; 9 | 10 | class NoMd5HashedPasswords extends Checklist\BasicCheck 11 | { 12 | /** 13 | * @var string Prefix of default, MD5-based hashes 14 | */ 15 | private const WP_OLD_HASH_PREFIX = '$P$'; 16 | 17 | 18 | public function getDescription(): string 19 | { 20 | return \sprintf( 21 | /* translators: 1: link to plugin with alternative implementation of password hashing scheme */ 22 | esc_html__('WordPress by default uses an MD5 based password hashing scheme that is too cheap and fast to generate cryptographically secure hashes. For modern PHP versions, there are %1$s available.', 'bc-security'), 23 | '<a href="https://github.com/roots/wp-password-bcrypt" rel="noreferrer">' . esc_html__('more secure alternatives', 'bc-security') . '</a>' 24 | ); 25 | } 26 | 27 | 28 | public function getName(): string 29 | { 30 | return __('No default MD5 password hashes', 'bc-security'); 31 | } 32 | 33 | 34 | public function __construct(private wpdb $wpdb) 35 | { 36 | } 37 | 38 | 39 | /** 40 | * Check does not make sense anymore starting with WordPress 6.8 41 | * 42 | * @link https://make.wordpress.org/core/2025/02/17/wordpress-6-8-will-use-bcrypt-for-password-hashing/ 43 | * 44 | * @return bool 45 | */ 46 | public function isMeaningful(): bool 47 | { 48 | return !is_wp_version_compatible('6.8'); 49 | } 50 | 51 | 52 | protected function runInternal(): Checklist\CheckResult 53 | { 54 | // Get all users with old hash prefix 55 | /** @var array<int,array<string,string>>|null $result */ 56 | $result = $this->wpdb->get_results( 57 | \sprintf("SELECT `user_login` FROM {$this->wpdb->users} WHERE `user_pass` LIKE '%s%%';", self::WP_OLD_HASH_PREFIX), 58 | ARRAY_A 59 | ); 60 | 61 | if ($result === null) { 62 | return new Checklist\CheckResult(null, esc_html__('BC Security has failed to determine whether there are any users with password hashed with default MD5-based algorithm.', 'bc-security')); 63 | } else { 64 | return ($result === []) 65 | ? new Checklist\CheckResult(true, esc_html__('No users have password hashed with default MD5-based algorithm.', 'bc-security')) 66 | : new Checklist\CheckResult(false, esc_html__('The following users have their password hashed with default MD5-based algorithm:', 'bc-security') . ' <em>' . \implode(', ', \array_column($result, 'user_login')) . '</em>') 67 | ; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Core/Admin/AbstractPage.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Core\Admin; 6 | 7 | /** 8 | * Basis (abstract) class for every admin page. 9 | */ 10 | abstract class AbstractPage 11 | { 12 | /** 13 | * @var string Page slug (each inheriting class must define its own) 14 | */ 15 | public const SLUG = 'bc-security'; 16 | 17 | /** 18 | * @var string Name of nonce used for any custom actions on admin pages 19 | */ 20 | protected const NONCE_NAME = '_wpnonce'; 21 | 22 | /** 23 | * @var string Page title for menu 24 | */ 25 | protected string $menu_title; 26 | 27 | /** 28 | * @var string Page title for browser window 29 | */ 30 | protected string $page_title; 31 | 32 | 33 | /** 34 | * Output page contents. 35 | */ 36 | abstract public function printContents(): void; 37 | 38 | 39 | /** 40 | * @return string Menu title of page. 41 | */ 42 | public function getMenuTitle(): string 43 | { 44 | return $this->menu_title; 45 | } 46 | 47 | 48 | /** 49 | * @return string Browser title of page. 50 | */ 51 | public function getPageTitle(): string 52 | { 53 | return $this->page_title; 54 | } 55 | 56 | 57 | /** 58 | * @return string Page slug. 59 | */ 60 | public function getSlug(): string 61 | { 62 | return static::SLUG; 63 | } 64 | 65 | 66 | /** 67 | * @return string URL of admin page. 68 | */ 69 | public function getUrl(): string 70 | { 71 | return static::getPageUrl(); 72 | } 73 | 74 | 75 | /** 76 | * @return string URL of admin page. 77 | */ 78 | public static function getPageUrl(): string 79 | { 80 | // Why static and not self? See: http://php.net/manual/en/language.oop5.late-static-bindings.php 81 | return add_query_arg('page', static::SLUG, admin_url('admin.php')); 82 | } 83 | 84 | 85 | /** 86 | * Register method to be run on page load. 87 | * 88 | * @link https://developer.wordpress.org/reference/hooks/load-page_hook/ 89 | * 90 | * @param string $page_hook 91 | */ 92 | public function setPageHook(string $page_hook): void 93 | { 94 | add_action('load-' . $page_hook, $this->loadPage(...)); 95 | } 96 | 97 | 98 | /** 99 | * Run on admin initialization (in `admin_init` hook). 100 | */ 101 | public function initPage(): void 102 | { 103 | // By default do nothing. 104 | } 105 | 106 | 107 | /** 108 | * Run on page load. 109 | * 110 | * @action https://developer.wordpress.org/reference/hooks/load-page_hook/ 111 | */ 112 | protected function loadPage(): void 113 | { 114 | // By default do nothing. 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/BadRequestsBanner/Core.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\BadRequestsBanner; 6 | 7 | use BlueChip\Security\Modules\Access\Scope; 8 | use BlueChip\Security\Modules\Initializable; 9 | use BlueChip\Security\Modules\InternalBlocklist\BanReason; 10 | use BlueChip\Security\Modules\InternalBlocklist\Manager; 11 | use WP; 12 | 13 | /** 14 | * Listen to 404 events and ban remote address if the URI matches any of registered fail2ban patterns. 15 | */ 16 | class Core implements Initializable 17 | { 18 | public function __construct( 19 | private string $remote_address, 20 | private string $server_address, 21 | private Settings $settings, 22 | private Manager $ib_manager, 23 | ) { 24 | } 25 | 26 | 27 | public function init(): void 28 | { 29 | // Run only if request did not originate from the webserver itself. 30 | if ($this->remote_address !== $this->server_address) { 31 | add_action('wp', $this->check404Queries(...), 100, 1); // Run late, allow others to interfere (do their stuff). 32 | } 33 | } 34 | 35 | 36 | /** 37 | * Catch 404 events (main queries that returned no results). 38 | * 39 | * Note: `parse_query` action cannot be used for 404 detection, because 404 state can be set as late as in WP::main(). 40 | * 41 | * @see WP::main() 42 | */ 43 | private function check404Queries(WP $wp): void 44 | { 45 | /** @var \WP_Query $wp_query */ 46 | global $wp_query; 47 | 48 | if (!$wp_query->is_404()) { 49 | // Nothing to do here. 50 | return; 51 | } 52 | 53 | $request = $wp->request; 54 | 55 | if (($ban_rule = $this->isBadRequest($request)) && $this->banRemoteAddress($request)) { 56 | // If ban succeeded, trigger related event. 57 | do_action(Hooks::BAD_REQUEST_EVENT, $this->remote_address, $request, $ban_rule); 58 | } 59 | } 60 | 61 | 62 | /** 63 | * @return BanRule|null Ban rule that matched $uri or null if no such rule has been found. 64 | */ 65 | private function isBadRequest(string $uri): ?BanRule 66 | { 67 | foreach ($this->settings->getActiveBanRules() as $ban_rule) { 68 | if ($ban_rule->matches($uri)) { 69 | return $ban_rule; 70 | } 71 | } 72 | 73 | return null; 74 | } 75 | 76 | 77 | /** 78 | * Ban remote address for access $request URI. 79 | */ 80 | private function banRemoteAddress(string $request): bool 81 | { 82 | return $this->ib_manager->lock( 83 | $this->remote_address, 84 | $this->settings->getBanDuration(), 85 | Scope::WEBSITE, 86 | BanReason::BAD_REQUEST_BAN, 87 | sprintf(__('Banned due to bad request: %s', 'bc-security'), $request), 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/ErrorLogNotPubliclyAccessible.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist\Checks; 6 | 7 | use BlueChip\Security\Modules\Checklist; 8 | 9 | class ErrorLogNotPubliclyAccessible extends Checklist\BasicCheck 10 | { 11 | public function getDescription(): string 12 | { 13 | return \sprintf( 14 | /* translators: 1: link to article on debugging, 2: WP_DEBUG constant, 3: WP_DEBUG_LOG constant, 4: debug.log file, 5: /wp-content path */ 15 | esc_html__('Both %2$s and %3$s constants are set to true, therefore %1$s to a %4$s log file inside the %5$s directory. This file can contain sensitive information and therefore should not be publicly accessible.', 'bc-security'), 16 | '<a href="' . esc_url(__('https://wordpress.org/support/article/debugging-in-wordpress/', 'bc-security')) . '" rel="noreferrer">' . esc_html__('WordPress saves all errors', 'bc-security') . '</a>', 17 | '<code>WP_DEBUG</code>', 18 | '<code>WP_DEBUG_LOG</code>', 19 | '<code>debug.log</code>', 20 | '<code>/wp-content/</code>' 21 | ); 22 | } 23 | 24 | 25 | public function getName(): string 26 | { 27 | return __('Error log not publicly accessible', 'bc-security'); 28 | } 29 | 30 | 31 | /** 32 | * Check makes sense, only when debug logging is active. 33 | * 34 | * @return bool 35 | */ 36 | public function isMeaningful(): bool 37 | { 38 | return WP_DEBUG && WP_DEBUG_LOG; 39 | } 40 | 41 | 42 | protected function runInternal(): Checklist\CheckResult 43 | { 44 | if (\in_array(\strtolower((string) \constant('WP_DEBUG_LOG')), ['true', '1'], true)) { 45 | // `WP_DEBUG_LOG` is set truthy value. 46 | // Path to debug.log and filename is hardcoded in `wp-includes/load.php`. 47 | $url = content_url('debug.log'); 48 | 49 | // Report status. 50 | $status = Checklist\Helper::isAccessToUrlForbidden($url); 51 | 52 | if (\is_bool($status)) { 53 | return $status 54 | ? new Checklist\CheckResult(true, esc_html__('It seems that error log is not publicly accessible.', 'bc-security')) 55 | : new Checklist\CheckResult(false, esc_html__('It seems that error log is publicly accessible!', 'bc-security')) 56 | ; 57 | } else { 58 | return new Checklist\CheckResult(null, esc_html__('BC Security has failed to determine whether error log is publicly accessible.', 'bc-security')); 59 | } 60 | } else { 61 | // `WP_DEBUG_LOG` has been set to custom path (= assume it is outside document root). 62 | return new Checklist\CheckResult(true, esc_html__('Error log is saved in custom location, presumably outside of document root.', 'bc-security')); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Notifications/Settings.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Notifications; 6 | 7 | use BlueChip\Security\Core\Settings as CoreSettings; 8 | 9 | /** 10 | * Notifications settings 11 | */ 12 | class Settings extends CoreSettings 13 | { 14 | /** 15 | * @var string Notify when user with admin privileges logs in [bool:yes] 16 | */ 17 | public const ADMIN_USER_LOGIN = 'admin_user_login'; 18 | 19 | /** 20 | * @var string Notify when known IP (IP for which there is a successful login in logs) is locked out [bool:yes] 21 | */ 22 | public const KNOWN_IP_LOCKOUT = 'known_ip_lockout'; 23 | 24 | /** 25 | * @var string Notify when there is an update for WordPress available [bool:yes] 26 | */ 27 | public const CORE_UPDATE_AVAILABLE = 'core_update_available'; 28 | 29 | /** 30 | * @var string Notify when there is a plugin update available [bool:yes] 31 | */ 32 | public const PLUGIN_UPDATE_AVAILABLE = 'plugin_update_available'; 33 | 34 | /** 35 | * @var string Notify when there is a theme update available [bool:yes] 36 | */ 37 | public const THEME_UPDATE_AVAILABLE = 'theme_update_available'; 38 | 39 | /** 40 | * @var string Notify when automatic checklist check triggers an alert [bool:yes] 41 | */ 42 | public const CHECKLIST_ALERT = 'checklist_alert'; 43 | 44 | /** 45 | * @var string Notify when BC Security is deactivated [bool:yes] 46 | */ 47 | public const PLUGIN_DEACTIVATED = 'plugin_deactivated'; 48 | 49 | /** 50 | * @var string Send notification to email address of site administrator [bool:no] 51 | */ 52 | public const NOTIFY_SITE_ADMIN = 'notify_site_admin'; 53 | 54 | /** 55 | * @var string List of email addresses of any additional notifications [array:empty] 56 | */ 57 | public const NOTIFICATION_RECIPIENTS = 'notification_recipients'; 58 | 59 | /** 60 | * @var array<string,mixed> Default values for all settings. 61 | */ 62 | protected const DEFAULTS = [ 63 | self::ADMIN_USER_LOGIN => true, 64 | self::KNOWN_IP_LOCKOUT => true, 65 | self::CORE_UPDATE_AVAILABLE => true, 66 | self::PLUGIN_UPDATE_AVAILABLE => true, 67 | self::THEME_UPDATE_AVAILABLE => true, 68 | self::CHECKLIST_ALERT => true, 69 | self::PLUGIN_DEACTIVATED => true, 70 | self::NOTIFY_SITE_ADMIN => false, 71 | self::NOTIFICATION_RECIPIENTS => [], 72 | ]; 73 | 74 | /** 75 | * @var array<string,callable> Custom sanitizers. 76 | */ 77 | protected const SANITIZERS = [ 78 | self::NOTIFICATION_RECIPIENTS => [self::class, 'sanitizeNotificationRecipient'], 79 | ]; 80 | 81 | 82 | /** 83 | * Sanitize "notification recipients" setting. Must be list of emails. 84 | * 85 | * @param string[] $value 86 | * 87 | * @return string[] 88 | */ 89 | public static function sanitizeNotificationRecipient(array $value): array 90 | { 91 | return \array_filter($value, '\is_email'); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Event.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log; 6 | 7 | /** 8 | * Base class for events. Implement event types as subclasses of this class. 9 | */ 10 | abstract class Event 11 | { 12 | /** 13 | * @var string Static event identificator. 14 | */ 15 | public const ID = ''; 16 | 17 | /** 18 | * @var string Log level. 19 | */ 20 | protected const LOG_LEVEL = ''; 21 | 22 | /** 23 | * @internal Static identifier is used for event type identification wherever use of classname is a bit cumbersome 24 | * like in database data or GET requests. 25 | * 26 | * @return string Static identifier for event type (class). 27 | */ 28 | public function getId(): string 29 | { 30 | return static::ID; 31 | } 32 | 33 | 34 | /** 35 | * @return string Log level for event type (class). 36 | */ 37 | public function getLogLevel(): string 38 | { 39 | return static::LOG_LEVEL; 40 | } 41 | 42 | 43 | /** 44 | * @return string Human readable name unique for event type (class). 45 | */ 46 | abstract public function getName(): string; 47 | 48 | 49 | /** 50 | * @return string Log message providing context to the event. 51 | */ 52 | abstract public function getMessage(): string; 53 | 54 | 55 | /** 56 | * @return array<string,string> Context data for this event. 57 | */ 58 | public function getContext(): array 59 | { 60 | $reflection = new \ReflectionClass(static::class); 61 | 62 | $output = []; 63 | foreach ($reflection->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) { 64 | $output[$property->getName()] = $this->{$property->getName()}; 65 | } 66 | 67 | return $output; 68 | } 69 | 70 | 71 | /** 72 | * @return array<string,string> Context columns with human readable descriptions (labels). 73 | */ 74 | public function explainContext(): array 75 | { 76 | $reflection = new \ReflectionClass(static::class); 77 | 78 | $output = []; 79 | foreach ($reflection->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) { 80 | $output[$property->getName()] = self::getPropertyLabel($property); 81 | } 82 | 83 | return $output; 84 | } 85 | 86 | 87 | /** 88 | * Extract context property label. 89 | * 90 | * @internal Property label must be enclosed in pseudo-call to translation function __('I am the label') placed in 91 | * property PHPDoc comment. 92 | * 93 | * @param \ReflectionProperty $property 94 | * 95 | * @return string 96 | */ 97 | private static function getPropertyLabel(\ReflectionProperty $property): string 98 | { 99 | $matches = []; 100 | if (\preg_match("/__\('(.+)'\)/i", $property->getDocComment() ?: '', $matches)) { 101 | return __($matches[1], 'bc-security'); 102 | } else { 103 | return ''; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /classes/Psr/Log/LoggerTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Psr\Log; 6 | 7 | /** 8 | * This is a simple Logger trait that classes unable to extend AbstractLogger 9 | * (because they extend another class, etc) can include. 10 | * 11 | * It simply delegates all log-level-specific methods to the `log` method to 12 | * reduce boilerplate code that a simple Logger that does the same thing with 13 | * messages regardless of the error level has to implement. 14 | */ 15 | trait LoggerTrait 16 | { 17 | /** 18 | * System is unusable. 19 | */ 20 | public function emergency(string|\Stringable $message, array $context = []): void 21 | { 22 | $this->log(LogLevel::EMERGENCY, $message, $context); 23 | } 24 | 25 | /** 26 | * Action must be taken immediately. 27 | * 28 | * Example: Entire website down, database unavailable, etc. This should 29 | * trigger the SMS alerts and wake you up. 30 | */ 31 | public function alert(string|\Stringable $message, array $context = []): void 32 | { 33 | $this->log(LogLevel::ALERT, $message, $context); 34 | } 35 | 36 | /** 37 | * Critical conditions. 38 | * 39 | * Example: Application component unavailable, unexpected exception. 40 | */ 41 | public function critical(string|\Stringable $message, array $context = []): void 42 | { 43 | $this->log(LogLevel::CRITICAL, $message, $context); 44 | } 45 | 46 | /** 47 | * Runtime errors that do not require immediate action but should typically 48 | * be logged and monitored. 49 | */ 50 | public function error(string|\Stringable $message, array $context = []): void 51 | { 52 | $this->log(LogLevel::ERROR, $message, $context); 53 | } 54 | 55 | /** 56 | * Exceptional occurrences that are not errors. 57 | * 58 | * Example: Use of deprecated APIs, poor use of an API, undesirable things 59 | * that are not necessarily wrong. 60 | */ 61 | public function warning(string|\Stringable $message, array $context = []): void 62 | { 63 | $this->log(LogLevel::WARNING, $message, $context); 64 | } 65 | 66 | /** 67 | * Normal but significant events. 68 | */ 69 | public function notice(string|\Stringable $message, array $context = []): void 70 | { 71 | $this->log(LogLevel::NOTICE, $message, $context); 72 | } 73 | 74 | /** 75 | * Interesting events. 76 | * 77 | * Example: User logs in, SQL logs. 78 | */ 79 | public function info(string|\Stringable $message, array $context = []): void 80 | { 81 | $this->log(LogLevel::INFO, $message, $context); 82 | } 83 | 84 | /** 85 | * Detailed debug information. 86 | */ 87 | public function debug(string|\Stringable $message, array $context = []): void 88 | { 89 | $this->log(LogLevel::DEBUG, $message, $context); 90 | } 91 | 92 | /** 93 | * Logs with an arbitrary level. 94 | * 95 | * @param mixed $level 96 | * 97 | * @throws \Psr\Log\InvalidArgumentException 98 | */ 99 | abstract public function log($level, string|\Stringable $message, array $context = []): void; 100 | } 101 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/NoAccessToPhpFilesInUploadsDirectory.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist\Checks; 6 | 7 | use BlueChip\Security\Modules\Checklist; 8 | 9 | class NoAccessToPhpFilesInUploadsDirectory extends Checklist\BasicCheck 10 | { 11 | public function getDescription(): string 12 | { 13 | return \sprintf( 14 | /* translators: 1: link to gist with .htaccess configuration that disables access to PHP files */ 15 | esc_html__('Vulnerable plugins may allow upload of arbitrary files into uploads directory. %1$s within uploads directory may help prevent successful exploitation of such vulnerabilities.', 'bc-security'), 16 | '<a href="https://gist.github.com/chesio/8f83224840eccc1e80a17fc29babadf2" rel="noreferrer">' . esc_html__('Disabling access to PHP files', 'bc-security') . '</a>' 17 | ); 18 | } 19 | 20 | 21 | public function getName(): string 22 | { 23 | return __('No access to PHP files in uploads directory', 'bc-security'); 24 | } 25 | 26 | 27 | protected function runInternal(): Checklist\CheckResult 28 | { 29 | $php_file_message = 'It is more secure to not allow PHP files to be accessed from within WordPress uploads directory.'; 30 | 31 | // Prepare temporary file name and contents. 32 | $name = \sprintf('bc-security-checklist-test-%s.txt', \md5((string) \rand())); // .txt extension to avoid upload file MIME check killing our test 33 | $bits = \sprintf('<?php echo "%s";', $php_file_message); 34 | 35 | // Create temporary PHP file in uploads directory. 36 | $result = wp_upload_bits($name, null, $bits); 37 | 38 | if ($result['error'] !== false) { 39 | return new Checklist\CheckResult(null, esc_html__('BC Security has failed to determine whether PHP files can be executed from uploads directory.', 'bc-security')); 40 | } 41 | 42 | // Change file extension to php. 43 | $file = \substr($result['file'], 0, -3) . 'php'; 44 | if (!\rename($result['file'], $file)) { 45 | \unlink($result['file']); 46 | return new Checklist\CheckResult(null, esc_html__('BC Security has failed to determine whether PHP files can be executed from uploads directory.', 'bc-security')); 47 | } 48 | 49 | $url = \substr($result['url'], 0, -3) . 'php'; 50 | 51 | // Check if access to PHP file is forbidden. 52 | $status = Checklist\Helper::isAccessToUrlForbidden($url, $php_file_message); 53 | 54 | // Remove temporary PHP file from uploads directory 55 | \unlink($file); 56 | 57 | // Report status 58 | if (\is_bool($status)) { 59 | return $status 60 | ? new Checklist\CheckResult(true, esc_html__('It seems that PHP files cannot be executed from uploads directory.', 'bc-security')) 61 | : new Checklist\CheckResult(false, esc_html__('It seems that PHP files can be executed from uploads directory!', 'bc-security')) 62 | ; 63 | } else { 64 | return new Checklist\CheckResult(null, esc_html__('BC Security has failed to determine whether PHP files can be executed from uploads directory.', 'bc-security')); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/DisplayOfPhpErrorsIsOff.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist\Checks; 6 | 7 | use BlueChip\Security\Helpers\Is; 8 | use BlueChip\Security\Modules\Checklist; 9 | 10 | class DisplayOfPhpErrorsIsOff extends Checklist\BasicCheck 11 | { 12 | public function getDescription(): string 13 | { 14 | return \sprintf( 15 | /* translators: 1: link to PHP manual documentation on display-errors php.ini setting, 2: link to WordPress Handbook article */ 16 | esc_html__('%1$s to the screen as part of the output on live system. In WordPress environment, %2$s when directly loading certain files.', 'bc-security'), 17 | '<a href="' . esc_url(__('https://www.php.net/manual/en/errorfunc.configuration.php#ini.display-errors', 'bc-security')) . '" rel="noreferrer">' . esc_html__('Errors should never be printed', 'bc-security') . '</a>', 18 | '<a href="' . esc_url(__('https://make.wordpress.org/core/handbook/testing/reporting-security-vulnerabilities/#why-are-there-path-disclosures-when-directly-loading-certain-files', 'bc-security')) . '" rel="noreferrer">' . esc_html__('display of errors can lead to path disclosures', 'bc-security') . '</a>' 19 | ); 20 | } 21 | 22 | 23 | public function getName(): string 24 | { 25 | return __('Display of PHP errors is off', 'bc-security'); 26 | } 27 | 28 | 29 | /** 30 | * Check makes sense only in live environment. 31 | * 32 | * @return bool 33 | */ 34 | public function isMeaningful(): bool 35 | { 36 | return Is::live(); 37 | } 38 | 39 | 40 | protected function runInternal(): Checklist\CheckResult 41 | { 42 | // Craft temporary file name. 43 | $name = \sprintf('bc-security-checklist-test-error-display-%s.php', \md5((string) \rand())); 44 | 45 | // The file is going to be created in wp-content directory. 46 | $path = content_url($name); 47 | $url = content_url($name); 48 | 49 | // Note: we rely on the fact that empty('0') is true here. 50 | $php_snippet = "<?php echo empty(ini_get('display_errors')) ? 'OK' : 'KO';"; 51 | 52 | $status = new Checklist\CheckResult(null, esc_html__('BC Security has failed to determine whether display of errors is turned off by default.', 'bc-security')); 53 | 54 | // Write temporary file... 55 | if (\file_put_contents($path, $php_snippet) === false) { 56 | // ...bail on failure. 57 | return $status; 58 | } 59 | 60 | // Attempt to fetch the temporary PHP file and retrieve the body. 61 | switch (wp_remote_retrieve_body(wp_remote_get($url))) { 62 | case 'OK': 63 | $status = new Checklist\CheckResult(true, esc_html__('It seems that display of errors is turned off by default.', 'bc-security')); 64 | break; 65 | case 'KO': 66 | $status = new Checklist\CheckResult(false, esc_html__('It seems that display of errors is turned on by default!', 'bc-security')); 67 | break; 68 | } 69 | 70 | // Remove temporary PHP file. 71 | \unlink($path); 72 | 73 | // Report on status. 74 | return $status; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /classes/Psr/Log/LoggerInterface.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Psr\Log; 6 | 7 | /** 8 | * Describes a logger instance. 9 | * 10 | * The message MUST be a string or object implementing __toString(). 11 | * 12 | * The message MAY contain placeholders in the form: {foo} where foo 13 | * will be replaced by the context data in key "foo". 14 | * 15 | * The context array can contain arbitrary data. The only assumption that 16 | * can be made by implementors is that if an Exception instance is given 17 | * to produce a stack trace, it MUST be in a key named "exception". 18 | * 19 | * See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md 20 | * for the full interface specification. 21 | */ 22 | interface LoggerInterface 23 | { 24 | /** 25 | * System is unusable. 26 | * 27 | * @param mixed[] $context 28 | */ 29 | public function emergency(string|\Stringable $message, array $context = []): void; 30 | 31 | /** 32 | * Action must be taken immediately. 33 | * 34 | * Example: Entire website down, database unavailable, etc. This should 35 | * trigger the SMS alerts and wake you up. 36 | * 37 | * @param mixed[] $context 38 | */ 39 | public function alert(string|\Stringable $message, array $context = []): void; 40 | 41 | /** 42 | * Critical conditions. 43 | * 44 | * Example: Application component unavailable, unexpected exception. 45 | * 46 | * @param mixed[] $context 47 | */ 48 | public function critical(string|\Stringable $message, array $context = []): void; 49 | 50 | /** 51 | * Runtime errors that do not require immediate action but should typically 52 | * be logged and monitored. 53 | * 54 | * @param mixed[] $context 55 | */ 56 | public function error(string|\Stringable $message, array $context = []): void; 57 | 58 | /** 59 | * Exceptional occurrences that are not errors. 60 | * 61 | * Example: Use of deprecated APIs, poor use of an API, undesirable things 62 | * that are not necessarily wrong. 63 | * 64 | * @param mixed[] $context 65 | */ 66 | public function warning(string|\Stringable $message, array $context = []): void; 67 | 68 | /** 69 | * Normal but significant events. 70 | * 71 | * @param mixed[] $context 72 | */ 73 | public function notice(string|\Stringable $message, array $context = []): void; 74 | 75 | /** 76 | * Interesting events. 77 | * 78 | * Example: User logs in, SQL logs. 79 | * 80 | * @param mixed[] $context 81 | */ 82 | public function info(string|\Stringable $message, array $context = []): void; 83 | 84 | /** 85 | * Detailed debug information. 86 | * 87 | * @param mixed[] $context 88 | */ 89 | public function debug(string|\Stringable $message, array $context = []): void; 90 | 91 | /** 92 | * Logs with an arbitrary level. 93 | * 94 | * @param mixed $level 95 | * @param mixed[] $context 96 | * 97 | * @throws \Psr\Log\InvalidArgumentException 98 | */ 99 | public function log($level, string|\Stringable $message, array $context = []): void; 100 | } 101 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Check.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist; 6 | 7 | use BlueChip\Security\Helpers\Transients; 8 | 9 | abstract class Check 10 | { 11 | /** 12 | * @var string 13 | */ 14 | private const LAST_RUN_TRANSIENT_ID = 'check-last-run'; 15 | 16 | /** 17 | * @var string 18 | */ 19 | private const RESULT_TRANSIENT_ID = 'check-result'; 20 | 21 | /** 22 | * @var int|null Timestamp of last run if lazy-loaded already, null otherwise. 23 | */ 24 | private ?int $last_run = null; 25 | 26 | /** 27 | * @var CheckResult|null Result of last run if lazy-loaded already, null otherwise. 28 | */ 29 | private ?CheckResult $result = null; 30 | 31 | 32 | /** 33 | * @return string Check unique ID. Basically name of class implementing the check. 34 | */ 35 | public static function getId(): string 36 | { 37 | return static::class; 38 | } 39 | 40 | 41 | /** 42 | * @return string Check description. 43 | */ 44 | abstract public function getDescription(): string; 45 | 46 | 47 | /** 48 | * @return string Check name (title). 49 | */ 50 | abstract public function getName(): string; 51 | 52 | 53 | /** 54 | * @return int Timestamp of last run or 0 if no info about last run is available. 55 | */ 56 | public function getTimeOfLastRun(): int 57 | { 58 | if ($this->last_run === null) { 59 | $this->last_run = (int) (Transients::getForSite(self::LAST_RUN_TRANSIENT_ID, self::getId()) ?: 0); 60 | } 61 | 62 | return $this->last_run; 63 | } 64 | 65 | 66 | /** 67 | * @return \BlueChip\Security\Modules\Checklist\CheckResult Result of the most recent check (possibly cached). 68 | */ 69 | public function getResult(): CheckResult 70 | { 71 | if ($this->result === null) { 72 | $this->result = Transients::getForSite(self::RESULT_TRANSIENT_ID, self::getId()) ?: new CheckResult(null, '<em>' . esc_html__('Check has not been run yet or the bookkeeping data has been lost.', 'bc-security') . '</em>'); 73 | } 74 | 75 | return $this->result; 76 | } 77 | 78 | 79 | /** 80 | * By default, every check is meaningful. 81 | * 82 | * @return bool 83 | */ 84 | public function isMeaningful(): bool 85 | { 86 | return true; 87 | } 88 | 89 | 90 | /** 91 | * Perform the check. 92 | * 93 | * @internal Method is a wrapper around runInternal() method - it stores the result internally and as transient. 94 | * 95 | * @return \BlueChip\Security\Modules\Checklist\CheckResult 96 | */ 97 | public function run(): CheckResult 98 | { 99 | // Run the check... 100 | $this->last_run = \time(); 101 | $this->result = $this->runInternal(); 102 | // ... cache the time and result... 103 | Transients::setForSite($this->last_run, self::LAST_RUN_TRANSIENT_ID, self::getId()); 104 | Transients::setForSite($this->result, self::RESULT_TRANSIENT_ID, self::getId()); 105 | // ...and return it. 106 | return $this->result; 107 | } 108 | 109 | 110 | /** 111 | * Perform the check. 112 | */ 113 | abstract protected function runInternal(): CheckResult; 114 | } 115 | -------------------------------------------------------------------------------- /assets/js/checklist.js: -------------------------------------------------------------------------------- 1 | (function($, bc_security_checklist) { 2 | $(function() { 3 | var $checks = $('.bcs-check'); 4 | // Grab all "Rerun" check buttons + "Run basic/advanced checks" buttons 5 | var $run_checks_buttons = $('button.bcs-run-check, button.bcs-run-checks'); 6 | 7 | // Activate "select all" button. 8 | $('#bcs-mark-all-checks').prop('disabled', false).on('click', function() { 9 | $checks.find('input[type="checkbox"]').prop('checked', true); 10 | }); 11 | 12 | // Activate "select none" button. 13 | $('#bcs-mark-no-checks').prop('disabled', false).on('click', function() { 14 | $checks.find('input[type="checkbox"]').prop('checked', false); 15 | }); 16 | 17 | // Activate "select only passing" button. 18 | $('#bcs-mark-passing-checks').prop('disabled', false).on('click', function() { 19 | $checks.find('input[type="checkbox"]').prop('checked', function() { return $(this).closest('.bcs-check').hasClass('bcs-check--ok'); }); 20 | }); 21 | 22 | // Activate "Run checks" buttons. 23 | $run_checks_buttons.on('click', function() { 24 | // Disable all "Run checks" buttons. 25 | $run_checks_buttons.prop('disabled', true); 26 | 27 | var $button = $(this); 28 | var requests = []; 29 | // Checks to be run are defined either by class or by ID. 30 | var $selector 31 | = ($button.data('check-class') ? ('.' + $button.data('check-class')) : '') 32 | + ($button.data('check-id') ? ('#' + $button.data('check-id')) : '') 33 | ; 34 | 35 | $checks.filter($selector).each(function() { 36 | var $check = $(this).removeClass('bcs-check--ok').removeClass('bcs-check--ko').addClass('bcs-check--running'); 37 | var $last_run = $('.bcs-check__last-run', $check); 38 | var $message = $('.bcs-check__message', $check).html(bc_security_checklist.messages.check_is_running); 39 | 40 | // https://api.jquery.com/jQuery.ajax/ 41 | var request = $.ajax({ 42 | url : bc_security_checklist.ajaxurl, 43 | method : 'POST', 44 | data : {action: bc_security_checklist.action, _ajax_nonce: bc_security_checklist.nonce, check_id: $check.data('check-id')}, 45 | dataType: 'json', 46 | cache : false, 47 | timeout : 0, // no timeout 48 | error : function() { 49 | $message.html(bc_security_checklist.messages.check_failed); 50 | }, 51 | success : function(response) { 52 | if (response.success) { 53 | $last_run.text(response.data.timestamp); 54 | if (response.data.status !== null) { 55 | $check.addClass('bcs-check--' + (response.data.status ? 'ok' : 'ko')); 56 | } 57 | } 58 | $message.html(response.data.message); 59 | }, 60 | complete : function() { 61 | $check.removeClass('bcs-check--running').addClass('bcs-check--done'); 62 | } 63 | }); 64 | 65 | requests.push(request); 66 | }); 67 | 68 | $.when.apply($, requests).always(function() { 69 | $run_checks_buttons.prop('disabled', false); 70 | }); 71 | }); 72 | }); 73 | })(jQuery, bc_security_checklist); 74 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Cron/Job.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Cron; 6 | 7 | /** 8 | * Simple wrapper for cron job handling 9 | */ 10 | class Job 11 | { 12 | /** 13 | * @var string Indicate that cron job should be scheduled at random time between 00:00:00 and 05:59:59 local time. 14 | */ 15 | public const RUN_AT_NIGHT = 'run_at_night'; 16 | 17 | /** 18 | * @var string Indicate that cron job should be scheduled at random time during entire day. 19 | */ 20 | public const RUN_RANDOMLY = 'run_randomly'; 21 | 22 | 23 | /** 24 | * @param string $hook Action hook to execute when cron job is run. 25 | * @param int|string $time Unix timestamp or time string indicating when to run the cron job. 26 | * @param string $recurrence How often the cron job should recur. 27 | */ 28 | public function __construct(private string $hook, private int|string $time, private string $recurrence) 29 | { 30 | } 31 | 32 | 33 | /** 34 | * Schedule this cron job if not scheduled yet. 35 | * 36 | * @return bool True if cron job has been activated or was already active, false otherwise. 37 | */ 38 | public function schedule(): bool 39 | { 40 | if ($this->isScheduled()) { 41 | // Ok, job done - that was easy! 42 | return true; 43 | } 44 | 45 | // Compute Unix timestamp (UTC) for when to run the cron job based on $time value. 46 | $timestamp = \is_int($this->time) ? $this->time : self::getTimestamp($this->time); 47 | 48 | return wp_schedule_event($timestamp, $this->recurrence, $this->hook); 49 | } 50 | 51 | 52 | /** 53 | * Unschedule this cron job. 54 | * 55 | * @return bool True in case of success, false on error. 56 | */ 57 | public function unschedule(): bool 58 | { 59 | return wp_clear_scheduled_hook($this->hook) !== false; 60 | } 61 | 62 | 63 | /** 64 | * Check whether cron job is currently scheduled. 65 | * 66 | * @return bool True if cron job is currently scheduled, false otherwise. 67 | */ 68 | public function isScheduled(): bool 69 | { 70 | return \is_int(wp_next_scheduled($this->hook)); 71 | } 72 | 73 | 74 | /** 75 | * Return timestamp for given $time string offset for current WP time zone. 76 | * 77 | * Note: $time can be also one of self::RUN_AT_NIGHT or self::RUN_RANDOMLY constants. 78 | * 79 | * @link http://www.php.net/manual/en/datetime.formats.relative.php 80 | * @link https://wordpress.stackexchange.com/a/223341 81 | * 82 | * @param string $time_string 83 | * 84 | * @return int 85 | */ 86 | public static function getTimestamp(string $time_string): int 87 | { 88 | if ($time_string === self::RUN_AT_NIGHT || $time_string === self::RUN_RANDOMLY) { 89 | $hour = \mt_rand(0, ($time_string === self::RUN_AT_NIGHT) ? 5 : 23); 90 | $minute = \mt_rand(0, 59); 91 | $second = \mt_rand(0, 59); 92 | $time = \sprintf("%02d:%02d:%02d", $hour, $minute, $second); 93 | } else { 94 | // Assume $time_string denotes actual time like '01:02:03'. 95 | $time = $time_string; 96 | } 97 | // Get time zone from settings. Fall back to UTC if option is empty. 98 | $time_zone = new \DateTimeZone(get_option('timezone_string') ?: 'UTC'); 99 | // Get DateTime object. 100 | $date = new \DateTime($time, $time_zone); 101 | // Get timestamp. 102 | return $date->getTimestamp(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Login/Settings.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Login; 6 | 7 | use BlueChip\Security\Core\Settings as CoreSettings; 8 | 9 | /** 10 | * Login security settings 11 | */ 12 | class Settings extends CoreSettings 13 | { 14 | /** 15 | * @var string Lock out for a short time after every N tries [int:5] 16 | */ 17 | public const SHORT_LOCKOUT_AFTER = 'short_lockout_after'; 18 | 19 | /** 20 | * @var string Lock out for a short time for this many minutes [int:10] 21 | */ 22 | public const SHORT_LOCKOUT_DURATION = 'short_lockout_duration'; 23 | 24 | /** 25 | * @var string Lock out for a long time after every N tries [int:20] 26 | */ 27 | public const LONG_LOCKOUT_AFTER = 'long_lockout_after'; 28 | 29 | /** 30 | * @var string Lock out for a long time for this many hours [int:24] 31 | */ 32 | public const LONG_LOCKOUT_DURATION = 'long_lockout_duration'; 33 | 34 | /** 35 | * @var string Reset failed attempts after this many days [int:3] 36 | */ 37 | public const RESET_TIMEOUT = 'reset_timeout'; 38 | 39 | /** 40 | * @var string List of usernames that trigger long lockout immediately when used to log in [array:empty] 41 | */ 42 | public const USERNAME_BLACKLIST = 'username_blacklist'; 43 | 44 | /** 45 | * @var string Display generic login error message? [bool:no] 46 | */ 47 | public const GENERIC_LOGIN_ERROR_MESSAGE = 'display_generic_error_message'; 48 | 49 | 50 | /** 51 | * @var array<string,mixed> Default values for all settings. 52 | */ 53 | protected const DEFAULTS = [ 54 | self::SHORT_LOCKOUT_AFTER => 5, 55 | self::SHORT_LOCKOUT_DURATION => 10, 56 | self::LONG_LOCKOUT_AFTER => 20, 57 | self::LONG_LOCKOUT_DURATION => 24, 58 | self::RESET_TIMEOUT => 3, 59 | self::USERNAME_BLACKLIST => [], 60 | self::GENERIC_LOGIN_ERROR_MESSAGE => false, 61 | ]; 62 | 63 | /** 64 | * @var array<string,callable> Custom sanitizers. 65 | */ 66 | protected const SANITIZERS = [ 67 | self::USERNAME_BLACKLIST => [self::class, 'sanitizeUsernameBlacklist'], 68 | ]; 69 | 70 | 71 | /** 72 | * Sanitize "username blacklist" setting. Must be list of valid usernames. 73 | * 74 | * @param string[] $value 75 | * 76 | * @return string[] 77 | */ 78 | public static function sanitizeUsernameBlacklist(array $value): array 79 | { 80 | return \array_filter($value, '\validate_username'); 81 | } 82 | 83 | 84 | /** 85 | * @return int Long lockout duration in seconds 86 | */ 87 | public function getLongLockoutDuration(): int 88 | { 89 | return $this[self::LONG_LOCKOUT_DURATION] * HOUR_IN_SECONDS; 90 | } 91 | 92 | 93 | /** 94 | * @return int Reset timeout duration in seconds. 95 | */ 96 | public function getResetTimeoutDuration(): int 97 | { 98 | return $this[self::RESET_TIMEOUT] * DAY_IN_SECONDS; 99 | } 100 | 101 | 102 | /** 103 | * @return int Short lockout duration in seconds 104 | */ 105 | public function getShortLockoutDuration(): int 106 | { 107 | return $this[self::SHORT_LOCKOUT_DURATION] * MINUTE_IN_SECONDS; 108 | } 109 | 110 | 111 | /** 112 | * Get filtered list of usernames to be immediately locked out during login. 113 | * 114 | * @hook \BlueChip\Security\Modules\Login\Hooks::USERNAME_BLACKLIST 115 | * 116 | * @return string[] 117 | */ 118 | public function getUsernameBlacklist(): array 119 | { 120 | return apply_filters(Hooks::USERNAME_BLACKLIST, $this[self::USERNAME_BLACKLIST]); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Settings.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security; 6 | 7 | use BlueChip\Security\Core\Settings as CoreSettings; 8 | use BlueChip\Security\Modules\Checklist\AutorunSettings as ChecklistAutorunSettings; 9 | use BlueChip\Security\Modules\Cron\Settings as CronSettings; 10 | use BlueChip\Security\Modules\ExternalBlocklist\Settings as ExternalBlocklistSettings; 11 | use BlueChip\Security\Modules\Hardening\Settings as HardeningSettings; 12 | use BlueChip\Security\Modules\Log\Settings as LogSettings; 13 | use BlueChip\Security\Modules\Login\Settings as LoginSettings; 14 | use BlueChip\Security\Modules\Notifications\Settings as NotificationsSettings; 15 | use BlueChip\Security\Modules\BadRequestsBanner\Settings as BadRequestsBannerSettings; 16 | use BlueChip\Security\Setup\Settings as SetupSettings; 17 | use IteratorAggregate; 18 | use Traversable; 19 | 20 | /** 21 | * Object that provides access to all plugin settings 22 | * 23 | * @implements IteratorAggregate<int, CoreSettings> 24 | */ 25 | class Settings implements IteratorAggregate 26 | { 27 | private ChecklistAutorunSettings $checklist_autorun; 28 | 29 | private CronSettings $cron_jobs; 30 | 31 | private ExternalBlocklistSettings $external_blocklist; 32 | 33 | private HardeningSettings $hardening; 34 | 35 | private LogSettings $log; 36 | 37 | private LoginSettings $login; 38 | 39 | private NotificationsSettings $notifications; 40 | 41 | private BadRequestsBannerSettings $bad_requests_banner; 42 | 43 | private SetupSettings $setup; 44 | 45 | 46 | public function __construct() 47 | { 48 | $this->checklist_autorun = new ChecklistAutorunSettings('bc-security-checklist-autorun'); 49 | $this->cron_jobs = new CronSettings('bc-security-cron-jobs'); 50 | $this->external_blocklist = new ExternalBlocklistSettings('bc-security-external-blocklist'); 51 | $this->hardening = new HardeningSettings('bc-security-hardening'); 52 | $this->log = new LogSettings('bc-security-log'); 53 | $this->login = new LoginSettings('bc-security-login'); 54 | $this->notifications = new NotificationsSettings('bc-security-notifications'); 55 | $this->bad_requests_banner = new BadRequestsBannerSettings('bc-security-bad-requests-banner'); 56 | $this->setup = new SetupSettings('bc-security-setup'); 57 | } 58 | 59 | public function getIterator(): Traversable 60 | { 61 | foreach ((array) $this as $settings) { 62 | yield $settings; 63 | } 64 | } 65 | 66 | public function forChecklistAutorun(): ChecklistAutorunSettings 67 | { 68 | return $this->checklist_autorun; 69 | } 70 | 71 | public function forCronJobs(): CronSettings 72 | { 73 | return $this->cron_jobs; 74 | } 75 | 76 | public function forExternalBlocklist(): ExternalBlocklistSettings 77 | { 78 | return $this->external_blocklist; 79 | } 80 | 81 | public function forHardening(): HardeningSettings 82 | { 83 | return $this->hardening; 84 | } 85 | 86 | public function forLog(): LogSettings 87 | { 88 | return $this->log; 89 | } 90 | 91 | public function forLogin(): LoginSettings 92 | { 93 | return $this->login; 94 | } 95 | 96 | public function forNotifications(): NotificationsSettings 97 | { 98 | return $this->notifications; 99 | } 100 | 101 | public function forBadRequestsBanner(): BadRequestsBannerSettings 102 | { 103 | return $this->bad_requests_banner; 104 | } 105 | 106 | public function forSetup(): SetupSettings 107 | { 108 | return $this->setup; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/BadRequestsBanner/Settings.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\BadRequestsBanner; 6 | 7 | use BlueChip\Security\Core\Settings as CoreSettings; 8 | 9 | class Settings extends CoreSettings 10 | { 11 | /** 12 | * @var string Is built-in rule "Archive files" active? [bool:no] 13 | */ 14 | public const BUILT_IN_RULE_ARCHIVE_FILES = BuiltInRules::ARCHIVE_FILES; 15 | 16 | /** 17 | * @var string Is built-in rule "ASP files" active? [bool:no] 18 | */ 19 | public const BUILT_IN_RULE_ASP_FILES = BuiltInRules::ASP_FILES; 20 | 21 | /** 22 | * @var string Is built-in rule "Backup files" active? [bool:no] 23 | */ 24 | public const BUILT_IN_RULE_BACKUP_FILES = BuiltInRules::BACKUP_FILES; 25 | 26 | /** 27 | * @var string Is built-in rule "PHP files" active? [bool:no] 28 | */ 29 | public const BUILT_IN_RULE_PHP_FILES = BuiltInRules::PHP_FILES; 30 | 31 | /** 32 | * @var string Is built-in rule "Readme files" active? [bool:no] 33 | */ 34 | public const BUILT_IN_RULE_README_FILES = BuiltInRules::README_FILES; 35 | 36 | /** 37 | * @var string List of custom rules (bad request patterns) that trigger ban [array:empty] 38 | */ 39 | public const BAD_REQUEST_PATTERNS = 'bad_request_patterns'; 40 | 41 | /** 42 | * @var string Duration of ban in minutes [int:60] 43 | */ 44 | public const BAN_DURATION = 'ban_duration'; 45 | 46 | 47 | /** 48 | * @var string Character that signals comment line. 49 | */ 50 | public const BAD_REQUEST_PATTERN_COMMENT_PREFIX = '#'; 51 | 52 | 53 | /** 54 | * @var array<string,mixed> Default values for all settings. 55 | */ 56 | protected const DEFAULTS = [ 57 | self::BUILT_IN_RULE_ARCHIVE_FILES => false, 58 | self::BUILT_IN_RULE_ASP_FILES => false, 59 | self::BUILT_IN_RULE_BACKUP_FILES => false, 60 | self::BUILT_IN_RULE_PHP_FILES => false, 61 | self::BUILT_IN_RULE_README_FILES => false, 62 | self::BAD_REQUEST_PATTERNS => [], 63 | self::BAN_DURATION => 60, 64 | ]; 65 | 66 | 67 | /** 68 | * @return BanRule[] List of active ban rules. 69 | */ 70 | public function getActiveBanRules(): array 71 | { 72 | $ban_rules = []; 73 | 74 | // Fill built in rules first. 75 | foreach (BuiltInRules::enlist() as $identifier => $ban_rule) { 76 | if ($this[$identifier]) { 77 | $ban_rules[] = $ban_rule; 78 | } 79 | } 80 | 81 | // Fill custom rules second. 82 | foreach ($this->getBadRequestPatterns() as $pattern) { 83 | $ban_rules[] = new BanRule(sprintf(__('Custom rule: %s', 'bc-security'), $pattern), $pattern); 84 | } 85 | 86 | return $ban_rules; 87 | } 88 | 89 | 90 | /** 91 | * @return int Ban duration in seconds. 92 | */ 93 | public function getBanDuration(): int 94 | { 95 | return $this[self::BAN_DURATION] * MINUTE_IN_SECONDS; 96 | } 97 | 98 | 99 | /** 100 | * @return string[] 101 | */ 102 | private function getBadRequestPatterns(): array 103 | { 104 | return apply_filters( 105 | Hooks::BAD_REQUEST_CUSTOM_PATTERNS, 106 | $this->removeComments($this[self::BAD_REQUEST_PATTERNS]) 107 | ); 108 | } 109 | 110 | 111 | /** 112 | * @param string[] $bad_request_patterns 113 | * 114 | * @return string[] 115 | */ 116 | private function removeComments(array $bad_request_patterns): array 117 | { 118 | return \array_filter( 119 | $bad_request_patterns, 120 | fn (string $pattern): bool => !\str_starts_with($pattern, self::BAD_REQUEST_PATTERN_COMMENT_PREFIX) 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/ExternalBlocklist/AdminPage.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\ExternalBlocklist; 6 | 7 | use BlueChip\Security\Core\Admin\AbstractPage; 8 | use BlueChip\Security\Core\Admin\SettingsPage; 9 | use BlueChip\Security\Helpers\FormHelper; 10 | use BlueChip\Security\Modules\Access\Scope; 11 | use BlueChip\Security\Modules\ExternalBlocklist\Sources\AmazonWebServices; 12 | 13 | class AdminPage extends AbstractPage 14 | { 15 | /** Page has settings section */ 16 | use SettingsPage; 17 | 18 | 19 | /** 20 | * @var string Page slug 21 | */ 22 | public const SLUG = 'bc-security-external-blocklist'; 23 | 24 | private Manager $eb_manager; 25 | 26 | 27 | /** 28 | * @param Settings $settings Settings for external blocklist 29 | * @param Manager $eb_manager 30 | */ 31 | public function __construct(Settings $settings, Manager $eb_manager) 32 | { 33 | $this->page_title = _x('External Blocklist', 'Dashboard page title', 'bc-security'); 34 | $this->menu_title = _x('External Blocklist', 'Dashboard menu item name', 'bc-security'); 35 | 36 | $this->useSettings($settings); 37 | 38 | $this->eb_manager = $eb_manager; 39 | } 40 | 41 | 42 | protected function loadPage(): void 43 | { 44 | $this->displaySettingsErrors(); 45 | } 46 | 47 | 48 | /** 49 | * Output page contents. 50 | */ 51 | public function printContents(): void 52 | { 53 | echo '<div class="wrap">'; 54 | echo '<h1>' . esc_html($this->page_title) . '</h1>'; 55 | $this->printSettingsForm(); 56 | echo '</div>'; 57 | } 58 | 59 | 60 | /** 61 | * Initialize settings page: add sections and fields. 62 | */ 63 | public function initPage(): void 64 | { 65 | // Register settings. 66 | $this->registerSettings(); 67 | 68 | // Set page as current. 69 | $this->setSettingsPage(self::SLUG); 70 | 71 | // Section: Block requests from Amazon Web Services. 72 | $this->addSettingsSection( 73 | 'block-requests-from-aws', 74 | __('Block requests from Amazon Web Services', 'bc-security'), 75 | function () { 76 | echo '<p>' . \sprintf( 77 | /* translators: 1: link to Wikipedia page about Amazon Web Services, 2: link to Wordfence Blog article on brute force attacks originating from AWS */ 78 | esc_html__('%1$s can be misused to perform automated attacks against WordPress websites - for example %2$s. Blocking requests from AWS might help reduce amount of such attacks.', 'bc-security'), 79 | '<a href="https://en.wikipedia.org/wiki/Amazon_Web_Services" rel="noreferrer">' . esc_html__('Amazon Web Services', 'bc-security') . '</a>', 80 | '<a href="https://www.wordfence.com/blog/2021/11/aws-attacks-targeting-wordpress-increase-5x/" rel="noreferrer">' . esc_html__('brute force logging attacks', 'bc-security') . '</a>' 81 | ) . '</p>'; 82 | 83 | $ip_prefixes_count = $this->eb_manager->getSource(AmazonWebServices::class)->getSize(); 84 | if ($ip_prefixes_count > 0) { 85 | echo '<p>' . \sprintf( 86 | esc_html__('There are currently %s IP prefixes cached from this source.', 'bc-security'), 87 | \sprintf('<strong>%d</strong>', $ip_prefixes_count) 88 | ); 89 | } 90 | } 91 | ); 92 | $this->addSettingsField( 93 | AmazonWebServices::class, 94 | __('Block requests from AWS', 'bc-security'), 95 | [FormHelper::class, 'printSelect'], 96 | ['options' => Scope::explain()] 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/Events/BlocklistHit.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log\Events; 6 | 7 | use BlueChip\Security\Modules\Access\Scope; 8 | use BlueChip\Security\Modules\ExternalBlocklist\Source; 9 | use BlueChip\Security\Modules\Log\Event; 10 | 11 | /** 12 | * Event triggered when external or internal blocklist is hit. 13 | */ 14 | class BlocklistHit extends Event 15 | { 16 | /** 17 | * @var string Static event identificator. 18 | */ 19 | public const ID = 'blocklist_hit'; 20 | 21 | /** 22 | * @var string Human-readable label for external blocklist type. 23 | */ 24 | private const BLOCKLIST_TYPE_EXTERNAL = 'external blocklist'; 25 | 26 | /** 27 | * @var string Human-readable label for internal blocklist type. 28 | */ 29 | private const BLOCKLIST_TYPE_INTERNAL = 'internal blocklist'; 30 | 31 | /** 32 | * @var string Event log level. 33 | */ 34 | protected const LOG_LEVEL = \Psr\Log\LogLevel::NOTICE; 35 | 36 | /** 37 | * __('Blocklist type') 38 | * 39 | * @var string Type of blocklist (internal or external) that has the blocked IP address in it. 40 | */ 41 | protected string $blocklist_type = self::BLOCKLIST_TYPE_INTERNAL; 42 | 43 | /** 44 | * __('Request type') 45 | * 46 | * @var string Type of request that resulted in blocklist hit. 47 | */ 48 | protected string $request_type = ''; 49 | 50 | /** 51 | * __('IP address') 52 | * 53 | * @var string IP address the blocked request originated at. 54 | */ 55 | protected string $ip_address = ''; 56 | 57 | /** 58 | * __('Blocklist source') 59 | * 60 | * @var string Blocklist source 61 | */ 62 | protected string $source = ''; 63 | 64 | 65 | public function getName(): string 66 | { 67 | return __('Blocklist hit', 'bc-security'); 68 | } 69 | 70 | 71 | public function getMessage(): string 72 | { 73 | return ($this->blocklist_type === self::BLOCKLIST_TYPE_EXTERNAL) 74 | ? __('{request_type} from IP address {ip_address} has been blocked by external blocklist based on {source}.', 'bc-security') 75 | : __('{request_type} from IP address {ip_address} has been blocked by internal blocklist.', 'bc-security') 76 | ; 77 | } 78 | 79 | 80 | /** 81 | * Set request type based on given blocklist access scope. 82 | */ 83 | public function setRequestType(Scope $access_scope): self 84 | { 85 | $this->request_type = ucfirst($this->explainAccessScope($access_scope)); 86 | return $this; 87 | } 88 | 89 | 90 | /** 91 | * Set IP address the blocked request originated at. 92 | */ 93 | public function setIpAddress(string $ip_address): self 94 | { 95 | $this->ip_address = $ip_address; 96 | return $this; 97 | } 98 | 99 | 100 | /** 101 | * Set source behind blocklist entry that resulted in blocklist hit. Also mark hit as originating from external blocklist. 102 | */ 103 | public function setSource(Source $source): self 104 | { 105 | $this->blocklist_type = self::BLOCKLIST_TYPE_EXTERNAL; 106 | $this->source = $source->getTitle(); 107 | return $this; 108 | } 109 | 110 | 111 | /** 112 | * Translate $access_scope value into human-readable request type. 113 | */ 114 | private function explainAccessScope(Scope $access_scope): string 115 | { 116 | return match ($access_scope) { 117 | Scope::ADMIN => 'login request', 118 | Scope::COMMENTS => 'comment request', 119 | Scope::WEBSITE => 'website request', 120 | Scope::ANY => 'unspecific request', // This explanation is for static analysis only, it should not appear anywhere. 121 | }; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Access/Bouncer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Access; 6 | 7 | use BlueChip\Security\Modules\Initializable; 8 | use BlueChip\Security\Modules\InternalBlocklist\Manager as InternalBlocklistManager; 9 | use BlueChip\Security\Modules\Loadable; 10 | use BlueChip\Security\Helpers\Utils; 11 | use BlueChip\Security\Modules\ExternalBlocklist\Manager as ExternalBlocklistManager; 12 | use WP_Error; 13 | use WP_User; 14 | 15 | /** 16 | * Bouncer takes care of bouncing uninvited guests by: 17 | * 1) Blocking access to website when remote IP address cannot be determined. 18 | * 2) Blocking access to website when remote IP address is on external or internal blocklist. 19 | */ 20 | class Bouncer implements Initializable, Loadable 21 | { 22 | /** 23 | * @param string $remote_address Remote IP address. 24 | * @param InternalBlocklistManager $ib_manager 25 | * @param ExternalBlocklistManager $eb_manager 26 | */ 27 | public function __construct( 28 | private string $remote_address, 29 | private InternalBlocklistManager $ib_manager, 30 | private ExternalBlocklistManager $eb_manager 31 | ) { 32 | } 33 | 34 | 35 | /** 36 | * Load module. 37 | */ 38 | public function load(): void 39 | { 40 | // As much as I hate to add callbacks to hooks that are already being executed, 41 | // I have to balance two requirements here: 42 | // 1) Run the access check as early as possible (I consider `init` hook too late). 43 | // 2) Allow myself and others to hook stuff (ie. events logger) in a clean way before access check executes. 44 | add_action('plugins_loaded', $this->checkAccess(...), 1, 0); 45 | } 46 | 47 | 48 | /** 49 | * Initialize module. 50 | */ 51 | public function init(): void 52 | { 53 | add_filter('authenticate', $this->checkLoginAttempt(...), 1, 1); // Leave priority 0 for site maintainers. 54 | } 55 | 56 | 57 | /** 58 | * Should the request from current remote address be blocked for given access $access_scope? 59 | * 60 | * @param Scope $access_scope 61 | * 62 | * @return bool 63 | */ 64 | public function isBlocked(Scope $access_scope): bool 65 | { 66 | // Check external blocklist. 67 | $source = $this->eb_manager->getBlocklist($access_scope)->getSource($this->remote_address); 68 | $eb_result = $source !== null; 69 | $ib_result = $this->ib_manager->isLocked($this->remote_address, $access_scope); 70 | 71 | if ($eb_result) { 72 | do_action(Hooks::EXTERNAL_BLOCKLIST_HIT_EVENT, $this->remote_address, $access_scope, $source); 73 | } 74 | 75 | if ($ib_result) { 76 | do_action(Hooks::INTERNAL_BLOCKLIST_HIT_EVENT, $this->remote_address, $access_scope); 77 | } 78 | 79 | return apply_filters(Hooks::IS_IP_ADDRESS_BLOCKED, $eb_result || $ib_result, $this->remote_address, $access_scope); 80 | } 81 | 82 | 83 | /** 84 | * Check if access to website is allowed from given remote address. 85 | * 86 | * @action https://developer.wordpress.org/reference/hooks/plugins_loaded/ 87 | */ 88 | private function checkAccess(): void 89 | { 90 | if ($this->isBlocked(Scope::WEBSITE)) { 91 | Utils::blockAccessTemporarily($this->remote_address); 92 | } 93 | } 94 | 95 | 96 | /** 97 | * Check if access to login is allowed from given remote address. 98 | * 99 | * @filter https://developer.wordpress.org/reference/hooks/authenticate/ 100 | */ 101 | private function checkLoginAttempt(WP_Error|WP_User|null $user): WP_Error|WP_User|null 102 | { 103 | if ($this->isBlocked(Scope::ADMIN)) { 104 | Utils::blockAccessTemporarily($this->remote_address); 105 | } 106 | 107 | return $user; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Services/ReverseDnsLookup/Resolver.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Services\ReverseDnsLookup; 6 | 7 | use BlueChip\Security\Helpers\Transients; 8 | use BlueChip\Security\Modules; 9 | 10 | /** 11 | * Helper class that performs hostname resolution. 12 | */ 13 | class Resolver implements Modules\Activable, Modules\Initializable 14 | { 15 | /** 16 | * @var string Name of cron job action used for non-blocking remote hostname resolution. 17 | */ 18 | private const RESOLVE_REMOTE_ADDRESS = 'bc-security/resolve-remote-address'; 19 | 20 | /** 21 | * @var string Cache key under which to remote hostnames are cached. 22 | */ 23 | private const TRANSIENT_KEY = 'remote-hostname'; 24 | 25 | /** 26 | * @var int Number of seconds to cache remote hostname resolution results. 27 | */ 28 | private const CACHE_TTL = DAY_IN_SECONDS; 29 | 30 | 31 | public function activate(): void 32 | { 33 | // Do nothing. 34 | } 35 | 36 | 37 | public function deactivate(): void 38 | { 39 | // Unschedule all cron jobs consumed by this module. 40 | wp_unschedule_hook(self::RESOLVE_REMOTE_ADDRESS); 41 | } 42 | 43 | 44 | public function init(): void 45 | { 46 | // Register action for non-blocking hostname resolution. 47 | add_action(self::RESOLVE_REMOTE_ADDRESS, $this->resolveHostname(...), 10, 3); 48 | } 49 | 50 | 51 | /** 52 | * Resolve remote hostname of given IP address and run given action. 53 | * 54 | * The action receives IP address as first argument, hostname as second and then all values from context. 55 | * 56 | * @param string $ip_address Remote IP address to resolve. 57 | * @param string $action Name of action to invoke with resolved hostname. 58 | * @param array<string,mixed> $context Additional parameters that are passed to the action. 59 | */ 60 | private function resolveHostname(string $ip_address, string $action, array $context): void 61 | { 62 | if (!empty($hostname = $this->resolveHostnameInForeground($ip_address))) { 63 | do_action($action, new Response($ip_address, $hostname, $context)); 64 | } 65 | } 66 | 67 | 68 | /** 69 | * Get hostname of given IP address in non-blocking way. When the hostname is resolved, given action is run. 70 | * 71 | * @internal Schedules remote hostname resolution to run via WP-Cron. 72 | * 73 | * @param string $ip_address Remote IP address to resolve. 74 | * @param string $action Name of action to call when remote hostname is resolved. 75 | * @param array<string,mixed> $context [optional] Additional parameters to pass to the action. 76 | */ 77 | public function resolveHostnameInBackground(string $ip_address, string $action, array $context = []): void 78 | { 79 | wp_schedule_single_event(\time(), self::RESOLVE_REMOTE_ADDRESS, [$ip_address, $action, $context]); 80 | } 81 | 82 | 83 | /** 84 | * Get hostname for remote IP address in blocking way. 85 | * 86 | * @param string $ip_address Remote IP address to resolve. 87 | * 88 | * @return string Remote hostname on success, IP address if hostname could not be resolved, empty string on failure. 89 | */ 90 | public function resolveHostnameInForeground(string $ip_address): string 91 | { 92 | // Check the cache first. 93 | if (empty($hostname = Transients::getForSite(self::TRANSIENT_KEY, $ip_address))) { 94 | // Cache empty, resolve the hostname. 95 | if (empty($hostname = \gethostbyaddr($ip_address) ?: '')) { 96 | // This should only happen on malformed IP address, but bail nevertheless. 97 | return ''; 98 | } 99 | 100 | // Cache the hostname for one day. 101 | Transients::setForSite($hostname, self::CACHE_TTL, self::TRANSIENT_KEY, $ip_address); 102 | } 103 | 104 | // Return the hostname. 105 | return $hostname; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Checklist/Checks/SafeBrowsing.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Checklist\Checks; 6 | 7 | use BlueChip\Security\Helpers\Is; 8 | use BlueChip\Security\Helpers\SafeBrowsingClient; 9 | use BlueChip\Security\Modules\Cron\Jobs; 10 | use BlueChip\Security\Modules\Checklist; 11 | use BlueChip\Security\Setup; 12 | 13 | /** 14 | * Safe Browsing check using Lookup API 15 | */ 16 | class SafeBrowsing extends Checklist\AdvancedCheck 17 | { 18 | /** 19 | * @var string 20 | */ 21 | protected const CRON_JOB_HOOK = Jobs::SAFE_BROWSING_CHECK; 22 | 23 | 24 | public function getDescription(): string 25 | { 26 | $description = [ 27 | sprintf( 28 | /* translators: 1: link to Google Safe Browsing page */ 29 | esc_html__('Google maintains an updated %1$s like social engineering sites and sites that host malware or unwanted software. Unless you host such a site on purpose, finding website URL on Google Safe Browsing list is a good indicator of compromise.', 'bc-security'), 30 | '<a href="' . esc_url('https://developers.google.com/safe-browsing/') . '" rel="noreferrer">' . esc_html__('list of unsafe web resources', 'bc-security') . '</a>' 31 | ), 32 | ]; 33 | 34 | if ($this->google_api_key === '') { 35 | $description[] = sprintf( 36 | /* translators: 1: link to Google Safe Browsing "Get Started" page, 2: (internal) link to plugin setup page */ 37 | esc_html__('Please note that this check requires an %1$s to be configured in %2$s!', 'bc-security'), 38 | '<a href="' . esc_url('https://developers.google.com/safe-browsing/v4/get-started') . '" rel="noreferrer">' . esc_html__('API key', 'bc-security') . '</a>', 39 | '<a href="' . Setup\AdminPage::getPageUrl() . '">' . esc_html__('plugin setup', 'bc-security') . '</a>' 40 | ); 41 | } 42 | 43 | return implode(' ', $description); 44 | } 45 | 46 | 47 | public function getName(): string 48 | { 49 | return __('Site is not blacklisted by Google', 'bc-security'); 50 | } 51 | 52 | 53 | /** 54 | * @param string $google_api_key Google API key for project with Safe Browsing API enabled. 55 | */ 56 | public function __construct(private string $google_api_key) 57 | { 58 | } 59 | 60 | 61 | /** 62 | * Check makes sense only in live environment. 63 | * 64 | * @return bool 65 | */ 66 | public function isMeaningful(): bool 67 | { 68 | return Is::live(); 69 | } 70 | 71 | 72 | protected function runInternal(): Checklist\CheckResult 73 | { 74 | if ($this->google_api_key === '') { 75 | return new Checklist\CheckResult(null, sprintf(__('Google API key is not configured. Please, <a href="%1$s">configure the API key</a>.', 'bc-security'), Setup\AdminPage::getPageUrl())); 76 | } 77 | 78 | // Initialize the client. 79 | $client = new SafeBrowsingClient($this->google_api_key); 80 | 81 | // Get URL to check. 82 | $url = home_url(); 83 | 84 | // Get check result: false means "not on blacklist". 85 | $result = $client->check($url); 86 | 87 | if ($result === null) { 88 | return new Checklist\CheckResult(null, __('Request to Safe Browsing API failed.', 'bc-security')); 89 | } elseif ($result === false) { 90 | return new Checklist\CheckResult(true, __('Site is not on the Safe Browsing blacklist.', 'bc-security')); 91 | } else { 92 | $message = sprintf( 93 | __('Site is on the Safe Browsing blacklist. You may find more information here: <a href="%1$s" rel="noreferrer">%1$s</a>', 'bc-security'), 94 | // Strip region from locale to get language code only. 95 | SafeBrowsingClient::getReportUrl($url, substr(get_locale(), 0, 2)) 96 | ); 97 | 98 | return new Checklist\CheckResult(false, $message); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/AdminPage.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log; 6 | 7 | use BlueChip\Security\Core\Admin\AbstractPage; 8 | use BlueChip\Security\Core\Admin\CountablePage; 9 | use BlueChip\Security\Core\Admin\ListingPage; 10 | use BlueChip\Security\Core\Admin\SettingsPage; 11 | use BlueChip\Security\Helpers\FormHelper; 12 | 13 | /** 14 | * Admin page that displays log records. 15 | */ 16 | class AdminPage extends AbstractPage 17 | { 18 | /** Page has counter indicator */ 19 | use CountablePage; 20 | 21 | /** Page has settings section */ 22 | use SettingsPage; 23 | 24 | /** Page has list table */ 25 | use ListingPage; 26 | 27 | 28 | /** 29 | * @var string Page slug 30 | */ 31 | public const SLUG = 'bc-security-logs'; 32 | 33 | 34 | private Logger $logger; 35 | 36 | 37 | public function __construct(Settings $settings, Logger $logger) 38 | { 39 | $this->page_title = _x('Log records', 'Dashboard page title', 'bc-security'); 40 | $this->menu_title = _x('Logs', 'Dashboard menu item name', 'bc-security'); 41 | 42 | $this->logger = $logger; 43 | 44 | $this->setCounter($logger); 45 | $this->useSettings($settings); 46 | $this->setPerPageOption('bc_security_log_records_per_page'); 47 | } 48 | 49 | 50 | /** 51 | * @param string|null $event_id [optional] If provided, URL to list table view for given event is returned. 52 | * 53 | * @return string URL of admin page. 54 | */ 55 | public static function getPageUrl(?string $event_id = null): string 56 | { 57 | return ($event_id === null) ? parent::getPageUrl() : ListTable::getViewUrl(parent::getPageUrl(), $event_id); 58 | } 59 | 60 | 61 | /** 62 | * Initialize settings page: add sections and fields. 63 | */ 64 | public function initPage(): void 65 | { 66 | // Register settings. 67 | $this->registerSettings(); 68 | 69 | // Set page as current. 70 | $this->setSettingsPage(self::SLUG); 71 | 72 | // Section: Automatic clean-up configuration 73 | $this->addSettingsSection( 74 | 'log-cleanup-configuration', 75 | _x('Automatic clean-up', 'Settings section title', 'bc-security'), 76 | function () { 77 | echo '<p>'; 78 | echo esc_html__('Logs are cleaned automatically once a day based on the configuration below.', 'bc-security'); 79 | echo '</p>'; 80 | } 81 | ); 82 | $this->addSettingsField( 83 | Settings::LOG_MAX_AGE, 84 | __('Maximum age', 'bc-security'), 85 | [FormHelper::class, 'printNumberInput'], 86 | [ 'append' => __('days', 'bc-security'), ] 87 | ); 88 | $this->addSettingsField( 89 | Settings::LOG_MAX_SIZE, 90 | __('Maximum size', 'bc-security'), 91 | [FormHelper::class, 'printNumberInput'], 92 | [ 'append' => __('thousands', 'bc-security'), ] 93 | ); 94 | } 95 | 96 | 97 | protected function loadPage(): void 98 | { 99 | $this->resetCount(); 100 | $this->displaySettingsErrors(); 101 | $this->addPerPageOption(); 102 | $this->initListTable(); 103 | } 104 | 105 | 106 | /** 107 | * Output page contents. 108 | */ 109 | public function printContents(): void 110 | { 111 | echo '<div class="wrap">'; 112 | 113 | // Page heading 114 | echo '<h1>' . esc_html($this->page_title) . '</h1>'; 115 | 116 | // Logs table 117 | $this->list_table->views(); 118 | echo '<form method="post">'; 119 | $this->list_table->display(); 120 | echo '</form>'; 121 | 122 | // Pruning configuration form 123 | $this->printSettingsForm(); 124 | 125 | echo '</div>'; 126 | } 127 | 128 | 129 | /** 130 | * Initialize list table instance. 131 | */ 132 | private function initListTable(): void 133 | { 134 | $this->list_table = new ListTable($this->getUrl(), $this->per_page_option_name, $this->logger); 135 | $this->list_table->prepare_items(); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Admin.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security; 6 | 7 | /** 8 | * Integration with WordPress admin area 9 | */ 10 | class Admin 11 | { 12 | /** 13 | * @var string To use Settings API, user has to have manage_options capability. 14 | */ 15 | private const CAPABILITY = 'manage_options'; 16 | 17 | /** 18 | * @var string Plugin dashboard menu icon 19 | */ 20 | private const ICON = 'dashicons-shield-alt'; 21 | 22 | 23 | /** 24 | * @var \BlueChip\Security\Core\Admin\AbstractPage[] 25 | */ 26 | private array $pages = []; 27 | 28 | 29 | /** 30 | * Initialize admin area of the plugin. 31 | * 32 | * @param string $plugin_filename 33 | * 34 | * @return self 35 | */ 36 | public function init(string $plugin_filename): self 37 | { 38 | add_action('admin_menu', $this->makeAdminMenu(...)); 39 | add_action('admin_init', $this->initAdminPages(...)); 40 | add_filter('plugin_action_links_' . plugin_basename($plugin_filename), $this->filterActionLinks(...)); 41 | return $this; 42 | } 43 | 44 | 45 | /** 46 | * Add a page to plugin dashboard menu. 47 | * 48 | * @param \BlueChip\Security\Core\Admin\AbstractPage $page 49 | * 50 | * @return self 51 | */ 52 | public function addPage(Core\Admin\AbstractPage $page): self 53 | { 54 | $this->pages[$page->getSlug()] = $page; 55 | return $this; 56 | } 57 | 58 | 59 | /** 60 | * @action https://developer.wordpress.org/reference/hooks/admin_init/ 61 | */ 62 | private function initAdminPages(): void 63 | { 64 | foreach ($this->pages as $page) { 65 | $page->initPage(); 66 | } 67 | } 68 | 69 | 70 | /** 71 | * Make plugin menu. 72 | * 73 | * @action https://developer.wordpress.org/reference/hooks/admin_menu/ 74 | */ 75 | private function makeAdminMenu(): void 76 | { 77 | if (empty($this->pages)) { 78 | // No pages registered = no pages (no menu) to show. 79 | return; 80 | } 81 | 82 | // First registered page acts as main page: 83 | $main_page = \reset($this->pages); 84 | 85 | // Add (main) menu page 86 | add_menu_page( 87 | '', // Page title is obsolete as soon as page has subpages. 88 | _x('BC Security', 'Dashboard menu item name', 'bc-security'), 89 | self::CAPABILITY, 90 | $main_page->getSlug(), 91 | '__return_empty_string', // Page content is obsolete as soon as page has subpages. Passing an empty string would prevent the callback being registered at all, but it breaks static analysis - see: https://core.trac.wordpress.org/ticket/52539 92 | self::ICON 93 | ); 94 | 95 | // Add subpages 96 | foreach ($this->pages as $page) { 97 | $page_hook = add_submenu_page( 98 | $main_page->getSlug(), 99 | $page->getPageTitle(), 100 | $page->getMenuTitle() . $this->renderCounter($page), 101 | self::CAPABILITY, 102 | $page->getSlug(), 103 | $page->printContents(...), 104 | ); 105 | if ($page_hook) { 106 | $page->setPageHook($page_hook); 107 | } 108 | } 109 | } 110 | 111 | 112 | /** 113 | * Filter plugin action links: append link to setup page only. 114 | * 115 | * @filter https://developer.wordpress.org/reference/hooks/plugin_action_links_plugin_file/ 116 | * 117 | * @param string[] $links 118 | * 119 | * @return string[] 120 | */ 121 | private function filterActionLinks(array $links): array 122 | { 123 | if (current_user_can(self::CAPABILITY) && isset($this->pages['bc-security-setup'])) { 124 | $links[] = \sprintf( 125 | '<a href="%s">%s</a>', 126 | $this->pages['bc-security-setup']->getUrl(), 127 | esc_html($this->pages['bc-security-setup']->getMenuTitle()) 128 | ); 129 | } 130 | return $links; 131 | } 132 | 133 | 134 | /** 135 | * Format counter indicator for menu title for given $page. 136 | * 137 | * @param \BlueChip\Security\Core\Admin\AbstractPage $page 138 | * 139 | * @return string 140 | */ 141 | private function renderCounter(Core\Admin\AbstractPage $page): string 142 | { 143 | // Counter is optional. 144 | return \method_exists($page, 'getCount') && !empty($count = $page->getCount()) 145 | ? \sprintf(' <span class="awaiting-mod"><span>%d</span></span>', number_format_i18n($count)) 146 | : '' 147 | ; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Notifications/AdminPage.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Notifications; 6 | 7 | use BlueChip\Security\Core\Admin\AbstractPage; 8 | use BlueChip\Security\Core\Admin\SettingsPage; 9 | use BlueChip\Security\Helpers\AdminNotices; 10 | use BlueChip\Security\Helpers\FormHelper; 11 | 12 | class AdminPage extends AbstractPage 13 | { 14 | /** Page has settings section */ 15 | use SettingsPage; 16 | 17 | 18 | /** 19 | * @var string Page slug 20 | */ 21 | public const SLUG = 'bc-security-notifications'; 22 | 23 | 24 | /** 25 | * @param Settings $settings Notifications settings 26 | */ 27 | public function __construct(Settings $settings) 28 | { 29 | $this->page_title = _x('Notifications Settings', 'Dashboard page title', 'bc-security'); 30 | $this->menu_title = _x('Notifications', 'Dashboard menu item name', 'bc-security'); 31 | 32 | $this->useSettings($settings); 33 | } 34 | 35 | 36 | protected function loadPage(): void 37 | { 38 | $this->displaySettingsErrors(); 39 | 40 | if (Watchman::isMuted()) { 41 | AdminNotices::add( 42 | __('You have set <code>BC_SECURITY_MUTE_NOTIFICATIONS</code> to true, therefore all notifications are muted.', 'bc-security'), 43 | AdminNotices::INFO, 44 | false, // ~ not dismissible 45 | ); 46 | } 47 | } 48 | 49 | 50 | /** 51 | * Output page contents. 52 | */ 53 | public function printContents(): void 54 | { 55 | echo '<div class="wrap">'; 56 | echo '<h1>' . esc_html($this->page_title) . '</h1>'; 57 | $this->printSettingsForm(); 58 | echo '</div>'; 59 | } 60 | 61 | 62 | /** 63 | * Initialize settings page: add sections and fields. 64 | */ 65 | public function initPage(): void 66 | { 67 | // Register settings. 68 | $this->registerSettings(); 69 | 70 | // Set page as current. 71 | $this->setSettingsPage(self::SLUG); 72 | 73 | // Section: When to notify? 74 | $this->addSettingsSection( 75 | 'when-to-notify', 76 | _x('When to send notification?', 'Settings section title', 'bc-security'), 77 | function () { 78 | echo '<p>' . esc_html__('Immediately send email notification when:', 'bc-security') . '</p>'; 79 | } 80 | ); 81 | $this->addSettingsField( 82 | Settings::ADMIN_USER_LOGIN, 83 | __('User with admin privileges logs in', 'bc-security'), 84 | [FormHelper::class, 'printCheckbox'] 85 | ); 86 | $this->addSettingsField( 87 | Settings::KNOWN_IP_LOCKOUT, 88 | __('Known IP address is locked out', 'bc-security'), 89 | [FormHelper::class, 'printCheckbox'] 90 | ); 91 | $this->addSettingsField( 92 | Settings::CORE_UPDATE_AVAILABLE, 93 | __('WordPress update is available', 'bc-security'), 94 | [FormHelper::class, 'printCheckbox'] 95 | ); 96 | $this->addSettingsField( 97 | Settings::PLUGIN_UPDATE_AVAILABLE, 98 | __('Plugin update is available', 'bc-security'), 99 | [FormHelper::class, 'printCheckbox'] 100 | ); 101 | $this->addSettingsField( 102 | Settings::THEME_UPDATE_AVAILABLE, 103 | __('Theme update is available', 'bc-security'), 104 | [FormHelper::class, 'printCheckbox'] 105 | ); 106 | $this->addSettingsField( 107 | Settings::CHECKLIST_ALERT, 108 | __('Checklist monitoring triggers an alert', 'bc-security'), 109 | [FormHelper::class, 'printCheckbox'] 110 | ); 111 | $this->addSettingsField( 112 | Settings::PLUGIN_DEACTIVATED, 113 | __('BC Security is deactivated', 'bc-security'), 114 | [FormHelper::class, 'printCheckbox'] 115 | ); 116 | 117 | // Section: Who to notify? 118 | $this->addSettingsSection( 119 | 'who-to-notify', 120 | _x('Whom to send notification?', 'Settings section title', 'bc-security') 121 | ); 122 | $this->addSettingsField( 123 | Settings::NOTIFY_SITE_ADMIN, 124 | __('Notify site admin', 'bc-security'), 125 | [FormHelper::class, 'printCheckbox'], 126 | [ 'description' => \sprintf(__('Currently: %s', 'bc-security'), get_option('admin_email')), ] 127 | ); 128 | $this->addSettingsField( 129 | Settings::NOTIFICATION_RECIPIENTS, 130 | __('Send notifications to:', 'bc-security'), 131 | [FormHelper::class, 'printTextArea'], 132 | [ 'description' => __('Enter one email per line.', 'bc-security'), ] 133 | ); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/InternalBlocklist/HtaccessSynchronizer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\InternalBlocklist; 6 | 7 | use BlueChip\Security\Helpers\Is; 8 | 9 | class HtaccessSynchronizer 10 | { 11 | /** 12 | * @var string Marker for insertion to .htaccess file. 13 | */ 14 | private const MARKER = 'BC Security'; 15 | 16 | /** 17 | * @var string Header line for insertion to .htaccess file. 18 | */ 19 | public const HEADER_LINE = '# BEGIN ' . self::MARKER; 20 | 21 | /** 22 | * @var string Footer line for insertion to .htaccess file. 23 | */ 24 | public const FOOTER_LINE = '# END ' . self::MARKER; 25 | 26 | 27 | private string $htaccess_file; 28 | 29 | 30 | public function __construct() 31 | { 32 | $this->htaccess_file = $this->getPathToRootHtaccessFile(); 33 | } 34 | 35 | 36 | public function isAvailable(): bool 37 | { 38 | return $this->htaccess_file !== ''; 39 | } 40 | 41 | 42 | public function isEnabled(): bool 43 | { 44 | return Is::live(); 45 | } 46 | 47 | 48 | /** 49 | * @return string[] List of IP addresses blocked via .htaccess file. 50 | */ 51 | public function extract(): array 52 | { 53 | if (!$this->isEnabled() || !$this->isAvailable()) { 54 | return []; 55 | } 56 | 57 | if (!\function_exists('extract_from_markers')) { 58 | require_once ABSPATH . 'wp-admin/includes/misc.php'; 59 | } 60 | 61 | $lines = extract_from_markers($this->htaccess_file, self::MARKER); 62 | 63 | $ip_addresses = []; 64 | foreach ($lines as $line) { 65 | if (\str_starts_with($line, '#')) { 66 | continue; 67 | } 68 | $matches = []; 69 | if (\preg_match('/^Require not ip (\S+)$/', $line, $matches)) { 70 | $ip_addresses[] = $matches[1]; 71 | } 72 | } 73 | 74 | return \array_unique($ip_addresses); 75 | } 76 | 77 | 78 | /** 79 | * @param string[] $blocked_ip_addresses List of IP addresses to block via .htaccess file. 80 | * 81 | * @return bool True if $blocked_ip_addresses has been written successfully, false otherwise. 82 | */ 83 | public function insert(array $blocked_ip_addresses): bool 84 | { 85 | if (!$this->isEnabled() || !$this->isAvailable()) { 86 | return false; 87 | } 88 | 89 | if (!\function_exists('insert_with_markers')) { 90 | require_once ABSPATH . 'wp-admin/includes/misc.php'; 91 | } 92 | 93 | // Prepare rules for given IP addresses. 94 | $rules = $this->prepareHtaccessRules($blocked_ip_addresses); 95 | 96 | // Write the rules to .htaccess file. 97 | return insert_with_markers($this->htaccess_file, self::MARKER, $rules); 98 | } 99 | 100 | 101 | private function getPathToRootHtaccessFile(): string 102 | { 103 | // Check ABSPATH first - this should work for any regular installation. 104 | $htaccess_file = ABSPATH . '.htaccess'; 105 | if ($this->isRootHtaccessFile($htaccess_file)) { 106 | return $htaccess_file; 107 | } 108 | 109 | // Check one folder above ABSPATH second - this should work for any subdirectory installations. 110 | $htaccess_file = \dirname(ABSPATH) . DIRECTORY_SEPARATOR . '.htaccess'; 111 | if ($this->isRootHtaccessFile($htaccess_file)) { 112 | return $htaccess_file; 113 | } 114 | 115 | return ''; 116 | } 117 | 118 | 119 | /** 120 | * @param string $filename Path to .htaccess file to test. 121 | * 122 | * @return bool True if $filename seems to be root .htaccess file, false otherwise. 123 | */ 124 | private function isRootHtaccessFile(string $filename): bool 125 | { 126 | if (!\file_exists($filename) || !\is_readable($filename) || !\is_writable($filename)) { 127 | return false; 128 | } 129 | 130 | $contents = \file_get_contents($filename) ?: ''; 131 | 132 | return \str_contains($contents, self::HEADER_LINE) && \str_contains($contents, self::FOOTER_LINE); 133 | } 134 | 135 | 136 | /** 137 | * @link https://help.ovhcloud.com/csm/en-web-hosting-htaccess-ip-restriction?id=kb_article_view&sysparm_article=KB0052844 138 | * 139 | * @param string[] $blocked_ip_addresses 140 | * 141 | * @return string[] 142 | */ 143 | private function prepareHtaccessRules(array $blocked_ip_addresses): array 144 | { 145 | $rules = []; 146 | 147 | $rules[] = '<IfModule mod_authz_core.c>'; 148 | $rules[] = '<RequireAll>'; 149 | $rules[] = 'Require all granted'; 150 | foreach ($blocked_ip_addresses as $blocked_ip_address) { 151 | $rules[] = sprintf("Require not ip %s", $blocked_ip_address); 152 | } 153 | $rules[] = '</RequireAll>'; 154 | $rules[] = '</IfModule>'; 155 | 156 | return $rules; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Setup/IpAddress.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Setup; 6 | 7 | /** 8 | * IP address retrieval (both remote and server) 9 | * 10 | * @link https://distinctplace.com/2014/04/23/story-behind-x-forwarded-for-and-x-real-ip-headers/ 11 | */ 12 | abstract class IpAddress 13 | { 14 | // Direct connection 15 | public const REMOTE_ADDR = 'REMOTE_ADDR'; 16 | 17 | // Reverse proxy (or load balancer) - may contain multiple IP addresses. 18 | public const HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR'; 19 | 20 | // Presumably real IP of the client - set by some proxies. 21 | public const HTTP_X_REAL_IP = 'HTTP_X_REAL_IP'; 22 | 23 | // CloudFlare CDN (~ reverse proxy) 24 | public const HTTP_CF_CONNECTING_IP = 'HTTP_CF_CONNECTING_IP'; 25 | 26 | 27 | /** 28 | * Get a list of all connection types supported by the plugin. 29 | * 30 | * @return string[] List of known (valid) connection types represented by their internal key values. 31 | */ 32 | public static function getOptions(): array 33 | { 34 | return [ 35 | self::REMOTE_ADDR, 36 | self::HTTP_CF_CONNECTING_IP, 37 | self::HTTP_X_FORWARDED_FOR, 38 | self::HTTP_X_REAL_IP, 39 | ]; 40 | } 41 | 42 | 43 | /** 44 | * Get a list of all connection types supported by the plugin. 45 | * 46 | * @return array<string,string> Array of supported connection types in form <internal key value> => <human-readable label>. 47 | */ 48 | public static function listOptions(): array 49 | { 50 | return [ 51 | self::REMOTE_ADDR => __('Direct connection to the Internet', 'bc-security'), 52 | self::HTTP_CF_CONNECTING_IP => __('Behind CloudFlare CDN and reverse proxy', 'bc-security'), 53 | self::HTTP_X_FORWARDED_FOR => __('Behind a reverse proxy or load balancer', 'bc-security'), 54 | self::HTTP_X_REAL_IP => __('Behind a reverse proxy or load balancer', 'bc-security'), 55 | ]; 56 | } 57 | 58 | 59 | /** 60 | * Get remote address according to provided $type (with fallback to REMOTE_ADDR). 61 | * 62 | * @param string $type 63 | * 64 | * @return string Remote IP or empty string if remote IP could not been determined. 65 | */ 66 | public static function get(string $type): string 67 | { 68 | if (!\in_array($type, self::getOptions(), true)) { 69 | // Invalid type, fall back to direct address. 70 | $type = self::REMOTE_ADDR; 71 | } 72 | 73 | if (isset($_SERVER[$type])) { 74 | return self::parseFrom($_SERVER[$type]); 75 | } 76 | 77 | // Not found: try to fall back to direct address if proxy has been requested. 78 | if (($type !== self::REMOTE_ADDR) && isset($_SERVER[self::REMOTE_ADDR])) { 79 | // NOTE: Even though we fall back to direct address -- meaning you 80 | // can get a mostly working plugin when connection type is not set 81 | // properly -- it is not safe! 82 | // 83 | // Client can itself send HTTP_X_FORWARDED_FOR header fooling us 84 | // regarding which IP should be banned. 85 | return self::parseFrom($_SERVER[self::REMOTE_ADDR]); 86 | } 87 | 88 | return ''; 89 | } 90 | 91 | 92 | /** 93 | * Get raw $_SERVER value for connection $type. 94 | * 95 | * @param string $type 96 | * 97 | * @return string 98 | */ 99 | public static function getRaw(string $type): string 100 | { 101 | return \in_array($type, self::getOptions(), true) ? ($_SERVER[$type] ?? '') : ''; 102 | } 103 | 104 | 105 | /** 106 | * Get IP address of webserver. 107 | * 108 | * @return string IP address of webserver or empty string if none provided (typically when running via PHP-CLI). 109 | */ 110 | public static function getServer(): string 111 | { 112 | return array_key_exists('SERVER_ADDR', $_SERVER) ? self::parseFrom($_SERVER['SERVER_ADDR']) : ''; 113 | } 114 | 115 | 116 | /** 117 | * Attempt to get a valid IP address from potentially insecure (user-provided) data. 118 | */ 119 | private static function parseFrom(string $maybe_list_of_ip_addresses): string 120 | { 121 | return self::validate(self::getFirst($maybe_list_of_ip_addresses)) ?? ''; 122 | } 123 | 124 | 125 | /** 126 | * Get the first from possibly multiple $ip_addresses. 127 | */ 128 | private static function getFirst(string $ip_addresses): string 129 | { 130 | // Note: explode always return an array with at least one item. 131 | $ips = \array_map('trim', \explode(',', $ip_addresses)); 132 | return $ips[0]; 133 | } 134 | 135 | 136 | /** 137 | * Validate given $ip_address - return null if invalid. 138 | */ 139 | private static function validate(string $ip_address): ?string 140 | { 141 | return \filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Login/Bookkeeper.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Login; 6 | 7 | use BlueChip\Security\Helpers\MySQLDateTime; 8 | use BlueChip\Security\Modules\Cron\Jobs; 9 | use BlueChip\Security\Modules\Initializable; 10 | use BlueChip\Security\Modules\Installable; 11 | use wpdb; 12 | 13 | /** 14 | * Storage and retrieval of lockout book-keeping data 15 | */ 16 | class Bookkeeper implements Initializable, Installable 17 | { 18 | /** 19 | * @var string Name of DB table where failed logins are stored 20 | */ 21 | private const FAILED_LOGINS_TABLE = 'bc_security_failed_logins'; 22 | 23 | 24 | /** 25 | * @var string Name of DB table where failed logins are stored (including table prefix) 26 | */ 27 | private string $failed_logins_table; 28 | 29 | 30 | /** 31 | * @param Settings $settings 32 | * @param wpdb $wpdb WordPress database access abstraction object 33 | */ 34 | public function __construct(private Settings $settings, private wpdb $wpdb) 35 | { 36 | $this->failed_logins_table = $wpdb->prefix . self::FAILED_LOGINS_TABLE; 37 | $this->settings = $settings; 38 | $this->wpdb = $wpdb; 39 | } 40 | 41 | 42 | /** 43 | * @link https://codex.wordpress.org/Creating_Tables_with_Plugins#Creating_or_Updating_the_Table 44 | */ 45 | public function install(): void 46 | { 47 | // To have dbDelta() 48 | require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 49 | 50 | $charset_collate = $this->wpdb->get_charset_collate(); 51 | 52 | dbDelta(\implode(PHP_EOL, [ 53 | "CREATE TABLE {$this->failed_logins_table} (", 54 | "id int unsigned NOT NULL AUTO_INCREMENT,", 55 | "ip_address char(128) NOT NULL,", 56 | "date_and_time datetime NOT NULL,", 57 | "username char(128) NOT NULL,", 58 | "user_id bigint unsigned NULL,", 59 | "PRIMARY KEY (id),", // 2 spaces seems to be necessary 60 | "INDEX ip_address (ip_address, date_and_time)", 61 | ") $charset_collate;", 62 | ])); 63 | } 64 | 65 | 66 | public function uninstall(): void 67 | { 68 | $this->wpdb->query(\sprintf('DROP TABLE IF EXISTS %s', $this->failed_logins_table)); 69 | } 70 | 71 | 72 | public function init(): void 73 | { 74 | // Hook into cron job execution. 75 | add_action(Jobs::FAILED_LOGINS_CLEAN_UP, $this->pruneInCron(...), 10, 0); 76 | } 77 | 78 | 79 | /** 80 | * Add failed login attempt from $ip_address using $username. 81 | * 82 | * @param string $ip_address 83 | * @param string $username 84 | * 85 | * @return int Number of non-expired failed login attempts for $ip_address. 86 | */ 87 | public function recordFailedLoginAttempt(string $ip_address, string $username): int 88 | { 89 | $now = \time(); 90 | $user = get_user_by(is_email($username) ? 'email' : 'login', $username); 91 | 92 | // Insert new failed login attempt for given IP address. 93 | $data = [ 94 | 'ip_address' => $ip_address, 95 | 'date_and_time' => MySQLDateTime::formatDateTime($now), 96 | 'username' => $username, 97 | 'user_id' => ($user === false) ? null : $user->ID, 98 | ]; 99 | 100 | $this->wpdb->insert($this->failed_logins_table, $data, ['%s', '%s', '%s', '%d']); 101 | 102 | // Get count of all unexpired failed login attempts for given IP address. 103 | /** @var string $query */ 104 | $query = $this->wpdb->prepare( 105 | 'SELECT COUNT(*) AS retries_count FROM %i WHERE ip_address = %s AND date_and_time > %s', 106 | $this->failed_logins_table, 107 | $ip_address, 108 | MySQLDateTime::formatDateTime($now - $this->settings->getResetTimeoutDuration()) 109 | ); 110 | 111 | return (int) $this->wpdb->get_var($query); 112 | } 113 | 114 | 115 | /** 116 | * Remove all expired entries from table. 117 | */ 118 | public function prune(): bool 119 | { 120 | // Remove all expired entries (older than threshold). 121 | $threshold = \time() - $this->settings->getResetTimeoutDuration(); 122 | // Prepare query. 123 | // Note: $wpdb->delete cannot be used as it does not support "<" comparison) 124 | /** @var string $query */ 125 | $query = $this->wpdb->prepare( 126 | 'DELETE FROM %i WHERE date_and_time <= %s', 127 | $this->failed_logins_table, 128 | MySQLDateTime::formatDateTime($threshold) 129 | ); 130 | // Execute query 131 | $result = $this->wpdb->query($query); 132 | // Return result 133 | return $result !== false; 134 | } 135 | 136 | 137 | /** 138 | * @hook \BlueChip\Security\Modules\Cron\Jobs::FAILED_LOGINS_CLEAN_UP 139 | * 140 | * @internal Runs `prune` method and discards its return value. 141 | */ 142 | private function pruneInCron(): void 143 | { 144 | $this->prune(); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Log/EventsMonitor.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Log; 6 | 7 | use BlueChip\Security\Modules\Access\Hooks as AccessHooks; 8 | use BlueChip\Security\Modules\Access\Scope; 9 | use BlueChip\Security\Modules\ExternalBlocklist\Source; 10 | use BlueChip\Security\Modules\BadRequestsBanner\Hooks as BadRequestsBannerHooks; 11 | use BlueChip\Security\Modules\Initializable; 12 | use BlueChip\Security\Modules\Loadable; 13 | use BlueChip\Security\Modules\Login\Hooks as LoginHooks; 14 | use BlueChip\Security\Modules\BadRequestsBanner\BanRule; 15 | use WP; 16 | use WP_Error; 17 | 18 | class EventsMonitor implements Initializable, Loadable 19 | { 20 | /** 21 | * @param string $remote_address Remote IP address. 22 | * @param string $server_address Server IP address. 23 | */ 24 | public function __construct(private string $remote_address, private string $server_address) 25 | { 26 | } 27 | 28 | 29 | public function load(): void 30 | { 31 | // Depending on access scope, blocklist can be checked very early, so add these monitors early. 32 | add_action(AccessHooks::EXTERNAL_BLOCKLIST_HIT_EVENT, $this->logExternalBlocklistHit(...), 10, 3); 33 | add_action(AccessHooks::INTERNAL_BLOCKLIST_HIT_EVENT, $this->logInternalBlocklistHit(...), 10, 2); 34 | } 35 | 36 | 37 | public function init(): void 38 | { 39 | // Log the following WordPress events: 40 | // - bad authentication cookie 41 | add_action('auth_cookie_bad_username', $this->logBadCookie(...), 5, 1); 42 | add_action('auth_cookie_bad_hash', $this->logBadCookie(...), 5, 1); 43 | // - failed login 44 | add_action('wp_login_failed', $this->logFailedLogin(...), 5, 2); 45 | // - successful login 46 | add_action('wp_login', $this->logSuccessfulLogin(...), 5, 1); 47 | // - 404 query (only if request did not originate from the webserver itself) 48 | if ($this->remote_address !== $this->server_address) { 49 | add_action('wp', $this->log404Queries(...), 20, 1); 50 | } 51 | 52 | // Log the following BC Security events: 53 | // - lockout event 54 | add_action(LoginHooks::LOCKOUT_EVENT, $this->logLockoutEvent(...), 10, 3); 55 | // - bad request event 56 | add_action(BadRequestsBannerHooks::BAD_REQUEST_EVENT, $this->logBadRequestEvent(...), 10, 3); 57 | } 58 | 59 | 60 | /** 61 | * Log external blocklist hit. 62 | */ 63 | private function logExternalBlocklistHit(string $remote_address, Scope $access_scope, Source $source): void 64 | { 65 | do_action(Action::EVENT, (new Events\BlocklistHit())->setIpAddress($remote_address)->setRequestType($access_scope)->setSource($source)); 66 | } 67 | 68 | 69 | /** 70 | * Log internal blocklist hit. 71 | */ 72 | private function logInternalBlocklistHit(string $remote_address, Scope $access_scope): void 73 | { 74 | do_action(Action::EVENT, (new Events\BlocklistHit())->setIpAddress($remote_address)->setRequestType($access_scope)); 75 | } 76 | 77 | 78 | /** 79 | * Log 404 event (main queries that returned no results). 80 | * 81 | * Note: `parse_query` action cannot be used for 404 detection, because 404 state can be set as late as in WP::main(). 82 | * 83 | * @see WP::main() 84 | */ 85 | private function log404Queries(WP $wp): void 86 | { 87 | /** @var \WP_Query $wp_query */ 88 | global $wp_query; 89 | 90 | if ($wp_query->is_404() && apply_filters(Hooks::LOG_404_EVENT, true, $wp->request)) { 91 | do_action(Action::EVENT, (new Events\Query404())->setRequestUri($wp->request)); 92 | } 93 | } 94 | 95 | 96 | /** 97 | * Log when bad cookie is used for authentication. 98 | * 99 | * @param array<string,string> $cookie_elements 100 | */ 101 | private function logBadCookie(array $cookie_elements): void 102 | { 103 | do_action(Action::EVENT, (new Events\AuthBadCookie())->setUsername($cookie_elements['username'])); 104 | } 105 | 106 | 107 | /** 108 | * Log failed login. 109 | */ 110 | private function logFailedLogin(string $username, WP_Error $error): void 111 | { 112 | do_action(Action::EVENT, (new Events\LoginFailure())->setUsername($username)->setError($error)); 113 | } 114 | 115 | 116 | /** 117 | * Log successful login. 118 | */ 119 | private function logSuccessfulLogin(string $username): void 120 | { 121 | do_action(Action::EVENT, (new Events\LoginSuccessful())->setUsername($username)); 122 | } 123 | 124 | 125 | /** 126 | * Log lockout event. 127 | */ 128 | private function logLockoutEvent(string $remote_address, string $username, int $duration): void 129 | { 130 | do_action(Action::EVENT, (new Events\LoginLockout())->setDuration($duration)->setIpAddress($remote_address)->setUsername($username)); 131 | } 132 | 133 | 134 | /** 135 | * Log bad request event. 136 | */ 137 | private function logBadRequestEvent(string $remote_address, string $request, BanRule $ban_rule): void 138 | { 139 | do_action(Action::EVENT, (new Events\BadRequestBan())->setBanRuleName($ban_rule->getName())->setIpAddress($remote_address)->setRequestUri($request)); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules/Notifications/Mailman.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security\Modules\Notifications; 6 | 7 | use BlueChip\Security\Modules\Notifications\AdminPage; 8 | 9 | abstract class Mailman 10 | { 11 | /** 12 | * @var string End-of-line character for email body. 13 | */ 14 | private const EOL = "\r\n"; 15 | 16 | /** 17 | * @var string Regular expression to match link elements. 18 | * 19 | * @internal Does not have to be bullet-proof - it is only used to parse HTML generated by the plugin itself. 20 | */ 21 | private const LINK_REGEX = '#<a\s+[^>]*href="([^"]+)"[^>]*>(.+)<\/a>#iU'; 22 | 23 | 24 | /** 25 | * Add some boilerplate to $subject and $message and send notification via wp_mail(). 26 | * 27 | * @see wp_mail() 28 | * 29 | * @param string|string[] $to Email address(es) of notification recipient(s). 30 | * @param string $subject Subject of notification. 31 | * @param Message $message Body of notification. 32 | * 33 | * @return bool True if notification has been sent successfully, false otherwise. 34 | */ 35 | public static function send(array|string $to, string $subject, Message $message): bool 36 | { 37 | return wp_mail( 38 | $to, 39 | self::formatSubject($subject), 40 | self::formatMessage($message) 41 | ); 42 | } 43 | 44 | 45 | /** 46 | * Strip any HTML tags from $message and add plugin boilerplate to it. 47 | */ 48 | private static function formatMessage(Message $message): string 49 | { 50 | $boilerplate_intro = [ 51 | \sprintf( 52 | __('This email was sent from your website "%1$s" (%2$s) by BC Security plugin on %3$s at %4$s.'), 53 | // Blog name must be decoded, see: https://github.com/chesio/bc-security/issues/86 54 | wp_specialchars_decode(get_option('blogname'), ENT_QUOTES), 55 | get_home_url(), 56 | wp_date(get_option('date_format')), 57 | wp_date(get_option('time_format')) 58 | ), 59 | '', 60 | ]; 61 | 62 | $boilerplate_outro = [ 63 | '', 64 | \sprintf( 65 | __('To change your notification settings, visit: %s', 'bc-security'), 66 | AdminPage::getPageUrl() 67 | ), 68 | ]; 69 | 70 | return \implode(self::EOL, \array_merge($boilerplate_intro, self::stripTags($message->getRaw()), $boilerplate_outro)); 71 | } 72 | 73 | 74 | /** 75 | * Prepare subject for email (prepend site name and "BC Security Alert"). 76 | * 77 | * @param string $subject 78 | * 79 | * @return string 80 | */ 81 | private static function formatSubject(string $subject): string 82 | { 83 | // Blog name must be decoded, see: https://github.com/chesio/bc-security/issues/86 84 | return \sprintf('[%s | %s] %s', wp_specialchars_decode(get_option('blogname'), ENT_QUOTES), __('BC Security Alert', 'bc-security'), $subject); 85 | } 86 | 87 | 88 | /** 89 | * Convert HTML message into plain text message. 90 | * 91 | * For any matched link element keep its URL and print list of all matched URLs after the message. 92 | * 93 | * Example input: 94 | * This is <strong>HTML text</strong> with a <a href="https://www.example.com">dummy link</a>. 95 | * And <a href="https://www.one-more-example.com/">one more <em>dummy link</em></a>. 96 | * 97 | * Example output: 98 | * This is HTML text with a dummy link [1]. 99 | * And one more dummy link [2]. 100 | * 101 | * [1] https://www.example.com 102 | * [2] https://www.one-more-example.com/ 103 | * 104 | * @param string[] $message Message as list of strings with HTML tags. 105 | * 106 | * @return string[] Message as list of strings without HTML tags with optional URL index appended. 107 | */ 108 | private static function stripTags(array $message): array 109 | { 110 | // List of URLs extracted from $message. 111 | $urls = []; 112 | 113 | // Strip all HTML elements from message: 114 | // - match any link elements: push their href attributes into $urls list, replace element with text and index number 115 | // - strip any remaining tags 116 | $message_without_html_elements = \array_map( 117 | function (string $line) use (&$urls): string { 118 | return $line === '' ? '' : \strip_tags( 119 | \preg_replace_callback( 120 | self::LINK_REGEX, 121 | function (array $matches) use (&$urls): string { 122 | \array_push($urls, $matches[1]); 123 | // Link text followed by link index. 124 | return \sprintf('%s [%d]', $matches[2], \count($urls)); 125 | }, 126 | $line 127 | ) 128 | ); 129 | }, 130 | $message 131 | ); 132 | 133 | if ($urls === []) { 134 | return $message_without_html_elements; 135 | } 136 | 137 | // Build links index... 138 | $links_index = \array_map( 139 | fn (int $index, string $url): string => \sprintf('[%d] %s', $index + 1, $url), 140 | \array_keys($urls), 141 | $urls 142 | ); 143 | 144 | // ...and add it to the message. 145 | return \array_merge($message_without_html_elements, [''], $links_index); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /classes/BlueChip/Security/Modules.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace BlueChip\Security; 6 | 7 | use BlueChip\Security\Modules\Access\Bouncer as AccessBouncer; 8 | use BlueChip\Security\Modules\BadRequestsBanner\Core as BadRequestsBanner; 9 | use BlueChip\Security\Modules\Checklist\Manager as ChecklistManager; 10 | use BlueChip\Security\Modules\Cron\Manager as CronJobManager; 11 | use BlueChip\Security\Modules\ExternalBlocklist\Manager as ExternalBlocklistManager; 12 | use BlueChip\Security\Modules\Hardening\Core as Hardening; 13 | use BlueChip\Security\Modules\InternalBlocklist\HtaccessSynchronizer; 14 | use BlueChip\Security\Modules\InternalBlocklist\Manager as InternalBlocklistManager; 15 | use BlueChip\Security\Modules\Log\EventsMonitor; 16 | use BlueChip\Security\Modules\Log\Logger; 17 | use BlueChip\Security\Modules\Login\Bookkeeper; 18 | use BlueChip\Security\Modules\Login\Gatekeeper; 19 | use BlueChip\Security\Modules\Notifications\Watchman; 20 | use BlueChip\Security\Modules\Services\ReverseDnsLookup\Resolver as HostnameResolver; 21 | use BlueChip\Security\Setup\GoogleAPI; 22 | use IteratorAggregate; 23 | use Traversable; 24 | use wpdb; 25 | 26 | /** 27 | * Object that provides access to all plugin modules 28 | * 29 | * @implements IteratorAggregate<int, object> 30 | */ 31 | class Modules implements IteratorAggregate 32 | { 33 | private AccessBouncer $access_bouncer; 34 | 35 | private BadRequestsBanner $bad_requests_banner; 36 | 37 | private Bookkeeper $bookkeeper; 38 | 39 | private ChecklistManager $checklist_manager; 40 | 41 | private CronJobManager $cron_job_manager; 42 | 43 | private EventsMonitor $events_monitor; 44 | 45 | private ExternalBlocklistManager $external_blocklist_manager; 46 | 47 | private Gatekeeper $gatekeeper; 48 | 49 | private Hardening $hardening; 50 | 51 | private HtaccessSynchronizer $htaccess_synchronizer; 52 | 53 | private HostnameResolver $hostname_resolver; 54 | 55 | private InternalBlocklistManager $internal_blocklist_manager; 56 | 57 | private Logger $logger; 58 | 59 | private Watchman $notifier; 60 | 61 | 62 | public function __construct(wpdb $wpdb, string $remote_address, string $server_address, Settings $settings) 63 | { 64 | $google_api = new GoogleAPI($settings->forSetup()); 65 | 66 | $this->hostname_resolver = new HostnameResolver(); 67 | $this->cron_job_manager = new CronJobManager($settings->forCronJobs()); 68 | $this->logger = new Logger($wpdb, $remote_address, $settings->forLog(), $this->hostname_resolver); 69 | $this->checklist_manager = new ChecklistManager($settings->forChecklistAutorun(), $this->cron_job_manager, $wpdb, $google_api->getKey()); 70 | $this->events_monitor = new EventsMonitor($remote_address, $server_address); 71 | $this->notifier = new Watchman($settings->forNotifications(), $remote_address, $this->logger); 72 | $this->hardening = new Hardening($settings->forHardening()); 73 | $this->htaccess_synchronizer = new HtaccessSynchronizer(); 74 | $this->internal_blocklist_manager = new InternalBlocklistManager($wpdb, $this->htaccess_synchronizer); 75 | $this->external_blocklist_manager = new ExternalBlocklistManager($settings->forExternalBlocklist(), $this->cron_job_manager); 76 | $this->bad_requests_banner = new BadRequestsBanner($remote_address, $server_address, $settings->forBadRequestsBanner(), $this->internal_blocklist_manager); 77 | $this->access_bouncer = new AccessBouncer($remote_address, $this->internal_blocklist_manager, $this->external_blocklist_manager); 78 | $this->bookkeeper = new Bookkeeper($settings->forLogin(), $wpdb); 79 | $this->gatekeeper = new Gatekeeper($settings->forLogin(), $remote_address, $this->bookkeeper, $this->internal_blocklist_manager, $this->access_bouncer); 80 | } 81 | 82 | public function getIterator(): Traversable 83 | { 84 | foreach ((array) $this as $module) { 85 | yield $module; 86 | } 87 | } 88 | 89 | public function getBadRequestsBanner(): BadRequestsBanner 90 | { 91 | return $this->bad_requests_banner; 92 | } 93 | 94 | public function getChecklistManager(): ChecklistManager 95 | { 96 | return $this->checklist_manager; 97 | } 98 | 99 | public function getCronJobManager(): CronJobManager 100 | { 101 | return $this->cron_job_manager; 102 | } 103 | 104 | public function getEventsMonitor(): EventsMonitor 105 | { 106 | return $this->events_monitor; 107 | } 108 | 109 | public function getExternalBlocklistManager(): ExternalBlocklistManager 110 | { 111 | return $this->external_blocklist_manager; 112 | } 113 | 114 | public function getGatekeeper(): Gatekeeper 115 | { 116 | return $this->gatekeeper; 117 | } 118 | 119 | public function getHardening(): Hardening 120 | { 121 | return $this->hardening; 122 | } 123 | 124 | public function getHtaccessSynchronizer(): HtaccessSynchronizer 125 | { 126 | return $this->htaccess_synchronizer; 127 | } 128 | 129 | public function getInternalBlocklistManager(): InternalBlocklistManager 130 | { 131 | return $this->internal_blocklist_manager; 132 | } 133 | 134 | public function getLogger(): Logger 135 | { 136 | return $this->logger; 137 | } 138 | 139 | public function getNotifier(): Watchman 140 | { 141 | return $this->notifier; 142 | } 143 | } 144 | --------------------------------------------------------------------------------