File "wfAuditLog.php"
Full Path: /home/rrterraplen/public_html/wp-content-20241221212636/plugins/wordfence/lib/wfAuditLog.php
File size: 47.13 KB
MIME-type: text/x-php
Charset: utf-8
<?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 { }