Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
wp-content-20241221212636
/
plugins
/
wordfence
/
lib
:
wfAuditLog.php
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
<?php require_once(__DIR__ . '/audit-log/wfAuditLogObserversWordPressCoreUser.php'); require_once(__DIR__ . '/audit-log/wfAuditLogObserversWordPressCoreSite.php'); require_once(__DIR__ . '/audit-log/wfAuditLogObserversWordPressCoreMultisite.php'); require_once(__DIR__ . '/audit-log/wfAuditLogObserversWordPressCoreContent.php'); require_once(__DIR__ . '/audit-log/wfAuditLogObserversWordfence.php'); require_once(__DIR__ . '/audit-log/wfAuditLogObserversPreview.php'); /** * Class wfAuditLog * * Hooks into a variety of actions/filters to collect relevant data that can be recorded in an audit log. The data * collected is focused around attack surfaces such as user registration and content insertion, but all attempts are * made to exclude potentially sensitive values from being recorded (e.g., for user profile changes, only the field * names are recorded). * * Data is recorded into an intermediate table on the site itself, and a send action is scheduled. When this action * triggers, a send payload up to the maximum transmit count is generated. The payload is then automatically expanded so * that no partial request is sent, only full requests. Once sent, these are removed from the intermediate table, and * we check to see if there are more remaining to be sent, scheduling another send if so. * * Because of how some of the hooks are called, there are three different points at which data may be recorded: * * 1. At the moment the hook is called. This is most common and used for one-off actions where the recording should be * performed at that time. * 2. Pre-filters/actions. For these, an earlier hook in the flow is listened for, and we record state data for later * use by the desired hook. This is typically used for deletions where we want some value from the record before it * gets deleted. * 3. At the end of the request. For actions that may reasonably called multiple times in the same request (e.g., adding * multiple capabilities to a role), we only need to record a single record of that action so this is done via a * coalescer at the end just prior to the request ending. * * Some hooks do record for multiple events due to how overloaded some data structures are in WP. For example, many * types are ultimately stored in `wp_posts` despite not being posts so the hooks surrounding that must check for the * context to determine which event to actually record. */ class wfAuditLog { const AUDIT_LOG_MODE_DEFAULT = 'default'; //Resolves to one of the below based on license type const AUDIT_LOG_MODE_DISABLED = 'disabled'; const AUDIT_LOG_MODE_PREVIEW = 'preview'; const AUDIT_LOG_MODE_SIGNIFICANT = 'significant'; const AUDIT_LOG_MODE_ALL = 'all'; //These category constants are used to divide events into the groupings in the event listing, one per event even if the event could fit under multiple const AUDIT_LOG_CATEGORY_AUTHENTICATION = 'authentication'; const AUDIT_LOG_CATEGORY_USER_PERMISSIONS = 'user-permissions'; const AUDIT_LOG_CATEGORY_PLUGINS_THEMES_UPDATES = 'plugins-themes-updates'; const AUDIT_LOG_CATEGORY_SITE_SETTINGS = 'site-settings'; const AUDIT_LOG_CATEGORY_MULTISITE = 'multisite'; const AUDIT_LOG_CATEGORY_CONTENT = 'content'; const AUDIT_LOG_CATEGORY_FIREWALL = 'firewall'; const AUDIT_LOG_MAX_SAMPLES = 20; //Max number of requests to store in the local summary, each of which may have one or more events const AUDIT_LOG_HEARTBEAT = 'heartbeat'; //A unique event that is sent to signal the audit log is functioning even if no other events have triggered, not displayed on the front end private $_pending = array(); private $_coalescers = array(); private $_destructRegistered = false; private $_state = array(); private $_performingFinalization = false; protected static $initialCoreVersion; protected static $initialMode; public static function shared() { static $_shared = null; if ($_shared === null) { $_shared = new wfAuditLog(); } return $_shared; } /** * Returns the events that will cause an immediate send rather than waiting for the cron event to execute. * Individual observer grouping subclasses must override this and return their subset of the event categories. The * primary audit log class will return an array of all observer groupings merged together. * * @return array */ public static function immediateSendEvents() { static $eventCache = null; if ($eventCache === null) { $eventCache = array(); $observers = self::_observers(); foreach ($observers as $o) { $merging = call_user_func(array($o, 'immediateSendEvents')); $eventCache = array_merge($eventCache, $merging); } } return $eventCache; } /** * Returns the event categories for use in the Audit Log page's UI. Individual observer grouping subclasses * must override this and return their subset of the event categories. The primary audit log class will return an * array of all observer groupings merged together. * * * @return array */ public static function eventCategories() { static $categoryCache = null; if ($categoryCache === null) { $categoryCache = array(); $observers = self::_observers(); foreach ($observers as $o) { $merging = call_user_func(array($o, 'eventCategories')); foreach ($merging as $category => $events) { if (isset($categoryCache[$category])) { $categoryCache[$category] = array_merge($categoryCache[$category], $events); } else { $categoryCache[$category] = $events; } } } } return $categoryCache; } /** * Returns the category for $event, null if not found. * * @param string $event * @return string|null */ public static function eventCategory($event) { static $reverseCategoryMapCache = null; if ($reverseCategoryMapCache === null) { $reverseCategoryMapCache = array(); $categories = self::eventCategories(); foreach ($categories as $category => $events) { $reverseCategoryMapCache = array_merge($reverseCategoryMapCache, array_fill_keys($events, $category)); } } if (isset($reverseCategoryMapCache[$event])) { return $reverseCategoryMapCache[$event]; } return null; } /** * Returns the event names suitable for display in the Audit Log page's UI. Individual observer grouping subclasses * must override this and return their subset of the event names. The primary audit log class will return an array * of all observer groupings merged together. * * * @return array */ public static function eventNames() { static $nameCache = null; if ($nameCache === null) { $nameCache = array(); $observers = self::_observers(); foreach ($observers as $o) { $nameCache = array_merge($nameCache, call_user_func(array($o, 'eventNames'))); } } return $nameCache; } /** * Returns the display name for the given event identifier. * * @param string $event * @return string */ public static function eventName($event) { $map = self::eventNames(); if (isset($map[$event])) { return $map[$event]; } return __('Unknown Events', 'wordfence'); } /** * Returns the event rate limiters for use in preprocessing events that occur. A rate limiter for an event type * should use the passed $auditLog and $payload values to determine whether the proposed event should be recorded. * The primary audit log class will return an array of all observer groupings merged together. * * * @return array */ public static function eventRateLimiters() { static $rateLimiterCache = null; if ($rateLimiterCache === null) { $rateLimiterCache = array(); $observers = self::_observers(); foreach ($observers as $o) { $rateLimiterCache = array_merge($rateLimiterCache, call_user_func(array($o, 'eventRateLimiters'))); } } return $rateLimiterCache; } /** * Consumes the rate limiter by setting a transient for the given $ttl. Currently this just allows a bucket of one, * but this could be refactored in the future to allow variable rate limits. * * @param string $event * @param string $payloadSignature * @param int $ttl Default is 10 minutes */ protected static function _rateLimiterConsume($event, $payloadSignature, $ttl = 600) { $key = 'wordfenceAuditEvent:' . $event . ':' . $payloadSignature; set_transient($key, time(), $ttl); } /** * Returns whether or not the rate limiter is available. The return value is `true` if it is, otherwise `false`. * * @param string $event * @param string $payloadSignature * @return bool */ protected static function _rateLimiterCheck($event, $payloadSignature) { $key = 'wordfenceAuditEvent:' . $event . ':' . $payloadSignature; return !get_transient($key); } /** * Recursively computes a hash for the given payload in a deterministic way. This may be used in rate limiter * implementations for deduplication checks. * * @param mixed $payload * @param null|HashContext $hasher * @return bool|string */ protected static function _normalizedPayloadHash($payload, $hasher = null) { $first = is_null($hasher); if ($first) { $hasher = hash_init('sha256'); } if (is_array($payload) || is_object($payload)) { $payload = (array) $payload; $keys = array_keys($payload); sort($keys, SORT_REGULAR); foreach ($keys as $k) { $v = $payload[$k]; hash_update($hasher, $k); self::_normalizedPayloadHash($v, $hasher); } } else if (is_scalar($payload)) { hash_update($hasher, $payload); } if ($first) { return hash_final($hasher); } return true; } /** * Returns an array of all observer groupings. * * @return array */ private static function _observers() { return array( wfAuditLogObserversWordPressCoreUser::class, wfAuditLogObserversWordPressCoreSite::class, wfAuditLogObserversWordPressCoreMultisite::class, wfAuditLogObserversWordPressCoreContent::class, wfAuditLogObserversWordfence::class, ); } /** * Registers the observers for this class's chunk of functionality that should run regardless of other settings. * These observers are expected to do their own check and application of settings like the audit log's mode or * the `Participate in the Wordfence Security Network` setting. * * @param wfAuditLog $auditLog */ protected static function _registerForcedObservers($auditLog) { //Individual forced observer groupings may override this } /** * Registers the observers for this class's chunk of functionality. * * @param wfAuditLog $auditLog */ protected static function _registerObservers($auditLog) { //Individual observer groupings will override this } /** * Registers the data gatherers for this class's chunk of functionality. These are secondary hooks to support * intermediate data gathering (e.g., grabbing the user attempting to authenticate even if it fails) * * @param wfAuditLog $auditLog */ protected static function _registerDataGatherers($auditLog) { //Individual data gatherer groupings will override this } /** * Registers the coalescers for this class's chunk of functionality. * * @param wfAuditLog $auditLog */ protected static function _registerCoalescers($auditLog) { //Individual coalescer groupings will override this } public static function heartbeat() { if (wfAuditLog::shared()->mode() != wfAuditLog::AUDIT_LOG_MODE_DISABLED && wfAuditLog::shared()->mode() != wfAuditLog::AUDIT_LOG_MODE_PREVIEW) { wfAuditLog::shared()->_recordAction(self::AUDIT_LOG_HEARTBEAT); } } /** * Returns the effective audit log mode after factoring in the active license type and resolving the default based * on that type. Will be one of the wfAuditLog::AUDIT_LOG_MODE_* constants that is not AUDIT_LOG_MODE_DEFAULT. * * @return string */ public function mode() { require(__DIR__ . '/wfVersionSupport.php'); /** @var $wfFeatureWPVersionAuditLog */ require(ABSPATH . WPINC . '/version.php'); /** @var string $wp_version */ if (version_compare($wp_version, $wfFeatureWPVersionAuditLog, '<')) { return self::AUDIT_LOG_MODE_DISABLED; } $mode = wfConfig::get('auditLogMode', self::AUDIT_LOG_MODE_DEFAULT); $license = wfLicense::current(); if (!$license->isPaidAndCurrent() || !$license->isAtLeastPremium()) { if ($mode == self::AUDIT_LOG_MODE_DISABLED) { return $mode; } return self::AUDIT_LOG_MODE_PREVIEW; } if ($mode == self::AUDIT_LOG_MODE_DEFAULT) { if (!$license->isAtLeastCare()) { return self::AUDIT_LOG_MODE_PREVIEW; } return self::AUDIT_LOG_MODE_SIGNIFICANT; } return $mode; } public function registerHooks() { self::$initialMode = $this->mode(); require(ABSPATH . WPINC . '/version.php'); /** @var string $wp_version */ self::$initialCoreVersion = $wp_version; $observers = self::_observers(); foreach ($observers as $o) { call_user_func(array($o, '_registerForcedObservers'), $this); } if ($this->mode() == self::AUDIT_LOG_MODE_DISABLED) { return; } if ($this->mode() == self::AUDIT_LOG_MODE_PREVIEW) { //When in preview mode, we register the local-only observers to keep the preview data fresh locally wfAuditLogObserversPreview::_registerObservers($this); wfAuditLogObserversPreview::_registerDataGatherers($this); wfAuditLogObserversPreview::_registerCoalescers($this); return; } foreach ($observers as $o) { call_user_func(array($o, '_registerObservers'), $this); call_user_func(array($o, '_registerDataGatherers'), $this); call_user_func(array($o, '_registerCoalescers'), $this); } } /** * Convenience method to add a listener for one or more WordPress hooks. This simplifies the normal flow of adding * a listener by using introspection on the passed callable to pass the correct arguments. * * @param array|string $hooks * @param callable $closure * @param string $type */ protected function _addObserver($hooks, $closure, $type = 'action') { if (!is_array($hooks)) { $hooks = array($hooks); } try { $introspection = new ReflectionFunction($closure); if ($type == 'action') { foreach ($hooks as $hook) { add_action($hook, $closure, 1, $introspection->getNumberOfParameters()); } } else if ($type == 'filter') { foreach ($hooks as $hook) { add_filter($hook, $closure, 1, $introspection->getNumberOfParameters()); } } } catch (Exception $e) { //Ignore } } protected function _addCoalescer($closure) { $this->_coalescers[] = $closure; } /** * Returns whether or not a state value exists for the given key/blog pair. * * @param string $key * @param int $id An ID when tracking multiple potential states. May be the blog ID if multisite or a user ID. * @return bool */ protected function _hasState($key, $id = 1) { if ($id < 0) { $id = 0; } if (!isset($this->_state[$id])) { return false; } return isset($this->_state[$id][$key]); } /** * Stores a state value under the key/blog pair for later use in this request. * * @param string $key * @param mixed $value * @param int $id An ID when tracking multiple potential states. May be the blog ID if multisite or a user ID. */ protected function _trackState($key, $value, $id = 1) { if ($id < 0) { $id = 0; } if (!isset($this->_state[$id])) { $this->_state[$id] = array(); } $this->_state[$id][$key] = $value; } /** * Returns the state value for the key/blog pair if present, otherwise null. * * @param string $key * @param int $id An ID when tracking multiple potential states. May be the blog ID if multisite or a user ID. * @return mixed|null */ protected function _getState($key, $id = 1) { if ($id < 0) { $id = 0; } if (!isset($this->_state[$id]) || !isset($this->_state[$id][$key])) { return null; } return $this->_state[$id][$key]; } /** * Returns all site(s)' state values for $key if present. They keys in the returned array are the blog ID. * * @param string $key * @return array Will have at most 1 entry for single-site, potentially many for multisite when applicable. */ protected function _getAllStates($key) { $result = array(); foreach ($this->_state as $id => $state) { if (isset($state[$key])) { $result[$id] = $state[$key]; } } return $result; } /** * Record the action and metadata for later sending to the audit log. * * @param string $action * @param array $metadata * @param bool $appendToExisting When true, does not create a new entry and instead only appends to entries of the same $action */ protected function _recordAction($action, $metadata = array(), $appendToExisting = false) { $rateLimiters = self::eventRateLimiters(); if (isset($rateLimiters[$action])) { if (!$rateLimiters[$action]($this, $metadata)) { return; } } if ($appendToExisting) { foreach ($this->_pending as &$entry) { if ($entry['action'] == $action) { $entry['metadata'] = array_merge($entry['metadata'], $metadata); } } return; } $path = null; $body = null; if (@php_sapi_name() === 'cli' || !array_key_exists('REQUEST_METHOD', $_SERVER)) { if (isset($_SERVER['argv']) && is_array($_SERVER['argv']) && count($_SERVER['argv']) > 0) { $path = $_SERVER['argv'][0] . ' ' . implode(' ', array_map(function($p) { return '\'' . addcslashes($p, '\'') . '\''; }, array_slice($_SERVER['argv'], 1))); $body = array('type' => 'cli', 'files' => array(), 'parameters' => array('argv' => $_SERVER['argv'])); } $method = 'CLI'; } else { $path = $_SERVER['REQUEST_URI']; $method = $_SERVER['REQUEST_METHOD']; if ($_SERVER['REQUEST_METHOD'] != 'GET') { $body = $this->_sanitizeRequestBody(); } } $user = wp_get_current_user(); $entry = array( 'action' => $action, 'time' => wfUtils::normalizedTime(), 'metadata' => $metadata, 'context' => array( 'ip' => wfUtils::getIP(), 'path' => $path, 'method' => $method, 'body' => $body, 'user_id' => $user ? $user->ID : 0, 'userdata' => $this->_sanitizeUserdata($user), ), ); if (is_multisite()) { $network = get_network(); $blog = get_blog_details(); $entry['multisite'] = $this->_sanitizeMultisiteData($network, $blog); } $this->_pending[] = $entry; $this->_needsDestruct(); } /** * Finalizes the pending actions. If cron is disabled or one of the types is on the immedate send list, they are * finalized by immediately sending to the audit log. Otherwise, they are saved to the intermediate storage table * and a send is scheduled. */ private function _savePending() { if (!empty($this->_pending)) { $sendImmediately = false; $immediateSend = self::immediateSendEvents(); $payload = array(); foreach ($this->_pending as $data) { $time = $data['time']; unset($data['time']); if ($data['action'] == self::AUDIT_LOG_HEARTBEAT) { //Minimize payload for heartbeat $payload[] = array( 'type' => $data['action'], 'data' => array(), 'event_time' => $time, ); } else { $payload[] = array( 'type' => $data['action'], 'data' => $data, 'event_time' => $time, ); } $sendImmediately = ($sendImmediately || in_array($data['action'], $immediateSend)); } if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) { $sendImmediately = true; } if ($sendImmediately && !wfCentral::isConnected()) { $this->_saveEventsToTable($payload); if ($ts = wp_next_scheduled('wordfence_batchSendAuditEvents')) { $this->_unscheduleSendPendingAuditEvents($ts); } $this->_scheduleSendPendingAuditEvents(); $this->_pending = array(); return; } $before = $payload; if ($sendImmediately) { $requestID = wfConfig::atomicInc('auditLogRequestNumber'); foreach ($payload as &$p) { $p['data'] = json_encode($p['data']); $p['request_id'] = $requestID; } } try { if ($this->_sendAuditLogEvents($payload, $sendImmediately)) { $this->_pending = array(); } } catch (wfAuditLogSendFailedException $e) { if ($sendImmediately) { $this->_saveEventsToTable($before); if ($ts = wp_next_scheduled('wordfence_batchSendAuditEvents')) { $this->_unscheduleSendPendingAuditEvents($ts); } $this->_scheduleSendPendingAuditEvents(true); $this->_pending = array(); } } } } protected function _needsDestruct() { if (!$this->_destructRegistered) { register_shutdown_function(array($this, '_lastAction')); $this->_destructRegistered = true; } } /** * Performed as a shutdown handler to finalize all pending actions. * * Note: must remain `public` for PHP 7 compatibility */ public function _lastAction() { global $wpdb; $suppressed = $wpdb->suppress_errors(!(defined('WFWAF_DEBUG') && WFWAF_DEBUG)); $this->_performingFinalization = true; foreach ($this->_coalescers as $c) { call_user_func($c); } $this->_coalescers = array(); $this->_savePending(); $this->_performingFinalization = false; $wpdb->suppress_errors($suppressed); } public function isFinalizing() { return $this->_performingFinalization; } /** * Performs the actual send of $events to the audit log if $sendImmediately is truthy, otherwise it writes them to * the intermediate storage table and schedules a send. * * @param array $events * @param bool $sendImmediately * @return bool * @throws wfAuditLogSendFailedException */ private function _sendAuditLogEvents($events, $sendImmediately = false) { if (empty($events)) { return true; } if (!wfCentral::isConnected()) { return false; //This will cause it to mark them as unsent and try again later } if ($sendImmediately) { $payload = array(); foreach ($events as $e) { $payload[] = self::_formatEventForTransmission($e); } $siteID = wfConfig::get('wordfenceCentralSiteID'); $request = new wfCentralAuthenticatedAPIRequest('/site/' . $siteID . '/audit-log', 'POST', array( 'data' => $payload, )); try { $doing_cron = function_exists('wp_doing_cron') /* WP >= 4.8 */ ? wp_doing_cron() : (defined('DOING_CRON') && DOING_CRON); $response = $request->execute($doing_cron ? 10 : 3); if ($response->isError()) { throw new wfAuditLogSendFailedException(); } //Group by request and update the local preview $preview = array(); foreach ($payload as $r) { if (!isset($preview[$r['attributes']['request_id']])) { $preview[$r['attributes']['request_id']] = array(); } $preview[$r['attributes']['request_id']][] = array($r['attributes']['type'], $r['attributes']['event_time']); } uksort($preview, function($k1, $k2) { if ($k1 == $k2) { return 0; } return ($k1 < $k2) ? 1 : -1; }); $this->_updateAuditPreview(array_values($preview)); } catch (Exception $e) { if (!defined('WORDFENCE_DEACTIVATING') || !WORDFENCE_DEACTIVATING) { wfCentralAPIRequest::handleInternalCentralAPIError($e); } throw new wfAuditLogSendFailedException(); } catch (Throwable $t) { if (!defined('WORDFENCE_DEACTIVATING') || !WORDFENCE_DEACTIVATING) { wfCentralAPIRequest::handleInternalCentralAPIError($t); } throw new wfAuditLogSendFailedException(); } } else { $this->_saveEventsToTable($events, $sendImmediately); if (($ts = $this->_isScheduledAuditEventCronOverdue()) || $sendImmediately) { if ($ts) { $this->_unscheduleSendPendingAuditEvents($ts); } self::sendPendingAuditEvents(); } else { $this->_scheduleSendPendingAuditEvents(); } } return true; } private function _saveEventsToTable($events, &$sendImmediately = false) { $requestID = wfConfig::atomicInc('auditLogRequestNumber'); $wfdb = new wfDB(); $table_wfAuditEvents = wfDB::networkTable('wfAuditEvents'); $query = "INSERT INTO {$table_wfAuditEvents} (`type`, `data`, `event_time`, `request_id`, `state`, `state_timestamp`) VALUES "; $query .= implode(', ', array_fill(0, count($events), "('%s', '%s', %f, %d, 'new', NOW())")); $immediateSendTypes = self::immediateSendEvents(); $args = array(); foreach ($events as $e) { $sendImmediately = $sendImmediately || in_array($e['type'], $immediateSendTypes); $args[] = $e['type']; $args[] = json_encode($e['data']); $args[] = $e['event_time']; $args[] = $requestID; } $wfdb->queryWriteArray($query, $args); } /** * Sends any pending audit events up to the limit (default 100). The list will automatically expand if needed to include * only complete requests so that no partial requests are sent. * * If the events fail to send or there are more remaining, another future send will be scheduled if $scheduleFollowup is truthy. * * @param int $limit * @param bool $scheduleFollowup Whether or not to schedule a followup send if there are more events pending, if false also unschedules any pending cron */ public static function sendPendingAuditEvents($limit = 100, $scheduleFollowup = true) { $wfdb = new wfDB(); $table_wfAuditEvents = wfDB::networkTable('wfAuditEvents'); $limit = intval($limit); $rawEvents = $wfdb->querySelect("SELECT * FROM {$table_wfAuditEvents} WHERE `state` = 'new' ORDER BY `id` ASC LIMIT {$limit}"); if (empty($rawEvents)) { return; } //Grab the entirety of the last request ID, even if it's beyond the 100 item limit $last = wfUtils::array_last($rawEvents); $extendedID = (int) $last['id']; $extendedRequestID = (int) $last['request_id']; $extendedEvents = $wfdb->querySelect("SELECT * FROM {$table_wfAuditEvents} WHERE `state` = 'new' AND `id` > {$extendedID} AND `request_id` = {$extendedRequestID} ORDER BY `id` ASC"); $rawEvents = array_merge($rawEvents, $extendedEvents); //Process for submission $ids = array(); foreach ($rawEvents as $r) { $ids[] = intval($r['id']); } $idParam = '(' . implode(', ', $ids) . ')'; $wfdb->queryWrite("UPDATE {$table_wfAuditEvents} SET `state` = 'sending', `state_timestamp` = NOW() WHERE `id` IN {$idParam}"); try { if (self::shared()->_sendAuditLogEvents($rawEvents, true)) { $wfdb->queryWrite("UPDATE {$table_wfAuditEvents} SET `state` = 'sent', `state_timestamp` = NOW() WHERE `id` IN {$idParam}"); if ($scheduleFollowup) { self::checkForUnsentAuditEvents(); } } else { $wfdb->queryWrite("UPDATE {$table_wfAuditEvents} SET `state` = 'new', `state_timestamp` = NOW() WHERE `id` IN {$idParam}"); if ($scheduleFollowup) { self::shared()->_scheduleSendPendingAuditEvents(); } } if (!$scheduleFollowup) { if ($ts = wp_next_scheduled('wordfence_batchSendAuditEvents')) { self::shared()->_unscheduleSendPendingAuditEvents($ts); } } } catch (wfAuditLogSendFailedException $e) { $wfdb->queryWrite("UPDATE {$table_wfAuditEvents} SET `state` = 'new', `state_timestamp` = NOW() WHERE `id` IN {$idParam}"); if ($ts = wp_next_scheduled('wordfence_batchSendAuditEvents')) { self::shared()->_unscheduleSendPendingAuditEvents($ts); } if (!defined('WORDFENCE_DEACTIVATING') || !WORDFENCE_DEACTIVATING) { self::shared()->_scheduleSendPendingAuditEvents(true); } } } /** * Formats the event record for transmission to Central for recording. * * @param array $rawEvent * @return array */ private static function _formatEventForTransmission($rawEvent) { if ($rawEvent['type'] == self::AUDIT_LOG_HEARTBEAT) { //Minimize payload for heartbeat return array( 'type' => 'audit-event', 'attributes' => array( 'type' => $rawEvent['type'], 'event_time' => (int) $rawEvent['event_time'], 'request_id' => (int) $rawEvent['request_id'], ) ); } $data = json_decode($rawEvent['data'], true); if (empty($data)) { $data = array(); } unset($data['action']); $username = null; if (!empty($data['context']['userdata']) && isset($data['context']['userdata']['user_login'])) { $username = $data['context']['userdata']['user_login']; } $ip = null; if (!empty($data['context']['ip'])) { $ip = $data['context']['ip']; unset($data['context']['ip']); } $path = null; if (!empty($data['context']['path'])) { $path = $data['context']['path']; unset($data['context']['path']); } $method = null; if (!empty($data['context']['method'])) { $method = $data['context']['method']; unset($data['context']['method']); } $body = null; if (!empty($data['context']['body'])) { $body = $data['context']['body']; unset($data['context']['body']); } return array( 'type' => 'audit-event', 'attributes' => array( 'type' => $rawEvent['type'], 'username' => $username, 'ip_address' => $ip, 'method' => $method, 'path' => $path, 'request_body' => $body, 'data' => $data, 'event_time' => (int) $rawEvent['event_time'], 'request_id' => (int) $rawEvent['request_id'], ) ); } /** * Schedules a cron for sending pending audit events. */ private function _scheduleSendPendingAuditEvents($forceDelay = false) { if ((self::$initialMode == self::AUDIT_LOG_MODE_DISABLED || self::$initialMode == self::AUDIT_LOG_MODE_PREVIEW) && ($this->mode() == self::AUDIT_LOG_MODE_DISABLED || $this->mode() == self::AUDIT_LOG_MODE_PREVIEW)) { return; //Do not schedule cron if mode is disabled/preview and was not recently put into that state } $delay = 60; if ($forceDelay || !wfCentral::isConnected()) { $delay = 3600; } if (!defined('DONOTCACHEDB')) { define('DONOTCACHEDB', true); } $notMainSite = is_multisite() && !is_main_site(); if ($notMainSite) { global $current_site; switch_to_blog($current_site->blog_id); } if (!wp_next_scheduled('wordfence_batchSendAuditEvents')) { wp_schedule_single_event(time() + $delay, 'wordfence_batchSendAuditEvents'); } if ($notMainSite) { restore_current_blog(); } } /** * @param int $timestamp */ private function _unscheduleSendPendingAuditEvents($timestamp) { if (!defined('DONOTCACHEDB')) { define('DONOTCACHEDB', true); } $notMainSite = is_multisite() && !is_main_site(); if ($notMainSite) { global $current_site; switch_to_blog($current_site->blog_id); } if ($timestamp) { wp_unschedule_event($timestamp, 'wordfence_batchSendAuditEvents'); } if ($notMainSite) { restore_current_blog(); } } private function _isScheduledAuditEventCronOverdue() { if (!defined('DONOTCACHEDB')) { define('DONOTCACHEDB', true); } $notMainSite = is_multisite() && !is_main_site(); if ($notMainSite) { global $current_site; switch_to_blog($current_site->blog_id); } $overdue = false; if ($ts = wp_next_scheduled('wordfence_batchSendAuditEvents')) { if ((time() - $ts) > 900) { $overdue = $ts; } } if ($notMainSite) { restore_current_blog(); } return $overdue; } public static function checkForUnsentAuditEvents() { $wfdb = new wfDB(); $table_wfAuditEvents = wfDB::networkTable('wfAuditEvents'); $wfdb->queryWrite("UPDATE {$table_wfAuditEvents} SET `state` = 'new', `state_timestamp` = NOW() WHERE `state` = 'sending' AND `state_timestamp` < DATE_SUB(NOW(), INTERVAL 30 MINUTE)"); $count = $wfdb->querySingle("SELECT COUNT(*) AS cnt FROM {$table_wfAuditEvents} WHERE `state` = 'new'"); if ($count) { self::shared()->_scheduleSendPendingAuditEvents(); } } public static function trimAuditEvents() { $wfdb = new wfDB(); $table_wfAuditEvents = wfDB::networkTable('wfAuditEvents'); $count = $wfdb->querySingle("SELECT COUNT(*) AS cnt FROM {$table_wfAuditEvents}"); if ($count > 1000) { $wfdb->truncate($table_wfAuditEvents); //Similar behavior to other logged data, assume possible DoS so truncate } else if ($count > 100) { $wfdb->queryWrite("DELETE FROM {$table_wfAuditEvents} ORDER BY id ASC LIMIT %d", $count - 100); } else if ($count > 0) { $wfdb->queryWrite("DELETE FROM {$table_wfAuditEvents} WHERE (`state` = 'sending' OR `state` = 'sent') AND `state_timestamp` < DATE_SUB(NOW(), INTERVAL 1 DAY)"); } } public static function hasOverdueEvents() { $wfdb = new wfDB(); $table_wfAuditEvents = wfDB::networkTable('wfAuditEvents'); $count = $wfdb->querySingle("SELECT COUNT(*) AS cnt FROM {$table_wfAuditEvents} WHERE `state` = 'new' AND `state_timestamp` < DATE_SUB(NOW(), INTERVAL 2 DAY)"); return $count > 0; } /** * Updates the locally-stored audit preview data that is used to populate the audit log page. The preview data is * stored in descending order. * * @param array $events Structure is [ * [ //Request 1 * [<event type>, <timestamp>], * [<event type>, <timestamp>], * [<event type>, <timestamp>], * ], * [ //Request 2 * [<event type>, <timestamp>], * ], * ... * ] */ protected function _updateAuditPreview($events) { $filtered = array(); foreach ($events as $request) { $request = array_filter($request, function($e) { return $e[0] != self::AUDIT_LOG_HEARTBEAT; //Don't save heartbeats to the local preview }); if (!empty($request)) { $filtered[] = $request; } } $events = $filtered; if (empty($events)) { return; } $existing = wfConfig::get_ser('lastAuditEvents', array()); if (!is_array($existing)) { $existing = array(); } $lastAuditEvents = array_merge($events, $existing); usort($lastAuditEvents, function($a, $b) { $aMax = array_reduce($a, function($carry, $item) { return max($carry, $item[1]); }, 0); $bMax = array_reduce($b, function($carry, $item) { return max($carry, $item[1]); }, 0); if ($aMax == $bMax) { return 0; } return ($aMax < $bMax) ? 1 : -1; }); $lastAuditEvents = array_slice($lastAuditEvents, 0, self::AUDIT_LOG_MAX_SAMPLES); wfConfig::set_ser('lastAuditEvents', $lastAuditEvents); } /** * Returns a summary array of recent events for the audit log. The content of this array will be the most recent * `AUDIT_LOG_MAX_SAMPLES` requests that were sent (or would have been sent if enabled) to Wordfence Central. * * @return array */ public function auditPreview() { $requests = array_filter(wfConfig::get_ser('lastAuditEvents', array()), function($events) { return !empty($events); }); $data = array(); if (is_array($requests)) { $data['requests'] = array(); foreach ($requests as $r) { $events = array_map(function($e) { return array( 'ts' => $e[1], 'event' => $e[0], 'name' => self::eventName($e[0]), 'category' => self::eventCategory($e[0]), ); }, $r); $types = array_reduce($events, function($carry, $e) { //We'll use the most common category if a request covers multiple if (!isset($carry[$e['category']])) { $carry[$e['category']] = 0; } $carry[$e['category']]++; return $carry; }, array()); asort($types, SORT_NUMERIC); $timestamp = array_reduce($events, function($carry, $e) { if ($e['ts'] > $carry) { return $e['ts']; } return $carry; }, 0); $data['requests'][] = array( 'ts' => $timestamp, 'category' => array_keys($types), 'events' => $events, ); } } return $data; } /************************************** * Utility Functions **************************************/ private function _sanitizeRequestBody() { $input = wfUtils::rawPOSTBody(); $contentType = null; if (isset($_SERVER['CONTENT_TYPE'])) { $contentType = strtolower($_SERVER['CONTENT_TYPE']); $boundary = strpos($contentType, ';'); if ($boundary !== false) { $contentType = substr($contentType, 0, $boundary); } } $raw = null; $response = array('type' => null, 'parameters' => array(), 'files' => array()); switch ($contentType) { case 'application/json': try { $raw = json_decode($input, true, 512, JSON_OBJECT_AS_ARRAY); $response['type'] = 'json'; } catch (Exception $e) { //Ignore -- can throw on PHP 8+ } break; case 'multipart/form-data': //PHP has already parsed this into $_POST and $_FILES $response['type'] = 'multipart'; foreach ($_FILES as $k => $f) { $response['files'][] = array( 'name' => $f['name'], 'type' => $f['type'], 'size' => $f['size'], 'error' => $f['error'], ); } $raw = $_POST; break; default: //Typically application/x-www-form-urlencoded if ($input) { parse_str($input, $raw); $response['type'] = 'urlencoded'; } break; } if (!empty($raw)) { foreach ($raw as $k => $v) { $response['parameters'][$k] = null; if ($k == 'action' || //Used in admin-ajax and many other WP calls, typically relevant for auditing and not sensitive $k == 'id' || //Typically the record ID being affected $k == 'log' //Authentication username ) { $response['parameters'][$k] = $v; } // else if -- future full value captures go here, otherwise we just capture the parameter name for privacy reasons } return $response; } return null; } /** * Returns the desired fields from $userdata for the various user-related hooks, ignoring the rest. Returns null if * there is no valid user. * * @param array|object|WP_User $userdata * @param null|int $user_id Used when provided, otherwise extracted from $userdata when possible * @return array|null */ protected function _sanitizeUserdata($userdata, $user_id = null) { if ($userdata === null && $user_id !== null) { //May hit this on older WP versions where $userdata wasn't populated by the hook call $userdata = get_user_by('ID', $user_id); } $roles = array(); if ($userdata instanceof stdClass) { $user = new WP_User($user_id !== null ? $user_id : (isset($userdata->ID) ? $userdata->ID : 0)); if ($user->exists()) { $roles = $user->roles; } $userdata = get_object_vars( $userdata ); } else if ($userdata instanceof WP_User) { $roles = $userdata->roles; $userdata = $userdata->to_array(); } else { $user = new WP_User($user_id !== null ? $user_id : (isset($userdata['ID']) ? $userdata['ID'] : 0)); if (!$user) { return array( 'user_id' => 0, 'user_login' => '', 'user_roles' => array(), ); } if ($user->exists()) { $roles = $user->roles; } } return array( 'user_id' => $user_id !== null ? $user_id : (isset($userdata['ID']) ? $userdata['ID'] : 0), 'user_login' => isset($userdata['user_login']) ? $userdata['user_login'] : '', 'user_roles' => $roles, ); } protected function _userdataDiff($userdata1, $userdata2) { if ($userdata1 instanceof stdClass) { $userdata1 = get_object_vars( $userdata1 ); } else if ($userdata1 instanceof WP_User) { $userdata1 = $userdata1->to_array(); } if ($userdata2 instanceof stdClass) { $userdata2 = get_object_vars( $userdata2 ); } else if ($userdata2 instanceof WP_User) { $userdata2 = $userdata2->to_array(); } return wfUtils::array_diff_assoc($userdata1, $userdata2); } /** * Returns the desired fields for the multisite ignoring the rest. * * @param WP_Network|false $network * @param WP_Site|false $blog * @return array */ protected function _sanitizeMultisiteData($network, $blog) { $result = array(); if ($network) { $result['network_id'] = $network->id; $result['network_domain'] = $network->domain; $result['network_path'] = $network->path; $result['network_name'] = $network->site_name; } if ($blog) { $result['blog_id'] = $blog->blog_id; $result['blog_domain'] = $blog->domain; $result['blog_path'] = $blog->path; $result['blog_name'] = $blog->blogname; } return $result; } protected function _multisiteDiff($blog1, $blog2) { if ($blog1 instanceof WP_Site) { $blog1 = $this->_sanitizeMultisiteData(false, $blog1); } if ($blog2 instanceof WP_Site) { $blog2 = $this->_sanitizeMultisiteData(false, $blog2); } return wfUtils::array_diff_assoc($blog1, $blog2); } /** * Returns the desired fields from an app password record. * * @param array|object $item * @return array */ protected function _sanitizeAppPassword($item) { if ($item instanceof stdClass) { $item = get_object_vars($item); } return array( 'uuid' => empty($item['uuid']) ? '<unknown>' : $item['uuid'], 'app_id' => empty($item['app_id']) ? '<unknown>' : $item['app_id'], 'name' => empty($item['name']) ? '<empty>' : $item['name'], 'created' => empty($item['created']) ? 0 : $item['created'], 'last_used' => empty($item['last_used']) ? null : $item['last_used'], 'last_ip' => empty($item['last_ip']) ? null : $item['last_ip'], ); } /** * Returns the desired fields from a post record. * * @param array|object|WP_Post $post * @return array */ protected function _sanitizePost($post) { if ($post instanceof stdClass) { $post = get_object_vars($post); } else if ($post instanceof WP_Post) { $post = $post->to_array(); } $author = isset($post['post_author']) ? get_user_by('ID', $post['post_author']) : null; $created = null; if (isset($post['post_date_gmt']) && $post['post_date_gmt'] != '0000-00-00 00:00:00') { //Prefer *_gmt, but sometimes WP doesn't set that $created = strtotime($post['post_date_gmt']); } else if (isset($post['post_date'])) { $created = strtotime($post['post_date']); } $modified = null; if (isset($post['post_modified_gmt']) && $post['post_modified_gmt'] != '0000-00-00 00:00:00') { //Prefer *_gmt, but sometimes WP doesn't set that $modified = strtotime($post['post_modified_gmt']); } else if (isset($post['post_modified'])) { $modified = strtotime($post['post_modified']); } $sanitized = array( 'post_id' => $post['ID'], 'author_id' => isset($post['post_author']) ? $post['post_author'] : null, 'author' => $author ? $this->_sanitizeUserdata($author) : null, 'title' => isset($post['post_title']) ? $post['post_title'] : null, 'created' => $created, 'last_modified' => $modified, 'type' => isset($post['post_type']) ? $post['post_type'] : 'post', 'status' => isset($post['post_status']) ? $post['post_status'] : 'publish', ); if (isset($post['post_type']) && $post['post_type'] == wfAuditLogObserversWordPressCoreContent::WP_POST_TYPE_ATTACHMENT) { $sanitized['context'] = get_post_meta($post['ID'], '_wp_attachment_context', true); } return $sanitized; } protected function _postDiff($post1, $post2) { if ($post1 instanceof stdClass) { $post1 = get_object_vars($post1); } else if ($post1 instanceof WP_Post) { $post1 = $post1->to_array(); } if ($post2 instanceof stdClass) { $post2 = get_object_vars($post2); } else if ($post2 instanceof WP_Post) { $post2 = $post2->to_array(); } return wfUtils::array_diff_assoc($post1, $post2); } /** * Returns whether or not the array of post changes should trigger an event recording. It will return false when * there are no changes or when the only changes are innocuous values like post dates. * * @param $changes * @return bool */ protected function _shouldRecordPostChanges($changes) { if (empty($changes) || !is_array($changes)) { return false; } $ignored = array('post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt', 'menu_order'); $test = array_filter($changes, function($i) use ($ignored) { return !in_array($i, $ignored); }); return !empty($test); } protected function _extractMultisiteID($option, $suffix) { global $wpdb; if (!is_multisite()) { return false; } if (substr($option, -1 * strlen($suffix)) == $suffix) { $option = substr($option, 0, strlen($option) - strlen($suffix)); if (substr($option, 0, strlen($wpdb->base_prefix)) == $wpdb->base_prefix) { $option = substr($option, strlen($wpdb->base_prefix)); $option = trim($option, '_'); if (empty($option)) { return 1; } return intval($option); } } return false; } /** * Returns an array containing the installed versions at the time of calling for core and all themes/plugins. * * @return array */ protected function _installedVersions() { $state = array(); require(ABSPATH . WPINC . '/version.php'); /** @var string $wp_version */ $state['core'] = $wp_version; if (!function_exists('get_plugins')) { require_once(ABSPATH . '/wp-admin/includes/plugin.php'); } $plugins = get_plugins(); $state['plugins'] = array_filter(array_map(function($p) { return isset($p['Version']) ? $p['Version'] : null; }, $plugins), function($v) { return $v != null; }); if (!function_exists('wp_get_themes')) { require_once(ABSPATH . '/wp-includes/theme.php'); } $themes = wp_get_themes(); $state['themes'] = array_filter(array_map(function($t) { return isset($t['Version']) ? $t['Version'] : null; }, $themes), function($v) { return $v != null; }); return $state; } /** * Attempts to resolve the given plugin path to the file containing its header. Returns that path if found, otherwise * null. Most plugins will simply be .../slug/slug.php, but some are single-file plugins while others have a * non-standard PHP file containing the header. * * Based on `get_plugins()`. * * @param string $path * @return string|null */ protected function _resolvePlugin($path) { if (is_dir($path)) { $scanner = @opendir($path); if ($scanner) { while (($subfile = readdir($scanner)) !== false) { if (preg_match('/^\./i', $subfile)) { continue; } else if (preg_match('/\.php$/i', $subfile)) { if (!is_readable($path . DIRECTORY_SEPARATOR . $subfile)) { continue; } $plugin_data = get_plugin_data($path . DIRECTORY_SEPARATOR . $subfile, false, false); if (!empty($plugin_data['Name'])) { return $path . DIRECTORY_SEPARATOR . $subfile; } } } closedir($scanner); } } else if (preg_match('/\.php$/i', $path) && is_readable($path)) { $plugin_data = get_plugin_data($path, false, false); if (!empty($plugin_data['Name'])) { return $path; } } return null; } /** * Returns data for the plugin at $path if possible, otherwise null. * * @param string $path * @return array|null */ protected function _getPlugin($path) { $original = $this->_getState('upgrader_pre_install.versions', 0); $raw = get_plugin_data($path); if ($raw) { $data = array(); foreach ($raw as $f => $v) { $k = strtolower(preg_replace('/\s+/', '_', $f)); //Translates all headers: Plugin Name -> plugin_name $data[$k] = $v; } $base = plugin_basename($path); if ($original && isset($original['plugins'][$base])) { $data['previous_version'] = $original['plugins'][$base]; } return $data; } return null; } /** * Returns data for the theme if possible, otherwise null. * * @param WP_Theme|string $theme_or_path * @return array|null */ protected function _getTheme($theme_or_path) { $original = $this->_getState('upgrader_pre_install.versions', 0); if ($theme_or_path instanceof WP_Theme) { $theme = $theme_or_path; } else { $theme = wp_get_theme(basename($theme_or_path), dirname($theme_or_path)); } if ($theme) { $fields = array( 'Name', 'ThemeURI', 'Description', 'Author', 'AuthorURI', 'Version', 'Template', 'Status', 'Tags', 'TextDomain', 'DomainPath', 'RequiresWP', 'RequiresPHP', 'UpdateURI', ); $data = array(); foreach ($fields as $f) { $k = strtolower(preg_replace('/\s+/', '_', $f)); $data[$k] = $theme->display($f); } $base = $theme->get_stylesheet(); if ($original && isset($original['themes'][$base])) { $data['previous_version'] = $original['themes'][$base]; } return $data; } return null; } } class wfAuditLogSendFailedException extends Exception { }