File "api.lib.php"
Full Path: /home/rrterraplen/public_html/wp-content-20241221212636/plugins/sucuri-scanner/src/api.lib.php
File size: 47.5 KB
MIME-type: text/x-php
Charset: utf-8
<?php
/**
* Code related to the api.lib.php interface.
*
* PHP version 5
*
* @category Library
* @package Sucuri
* @subpackage SucuriScanner
* @author Daniel Cid <[email protected]>
* @copyright 2010-2018 Sucuri Inc.
* @license https://www.gnu.org/licenses/gpl-2.0.txt GPL2
* @link https://wordpress.org/plugins/sucuri-scanner
*/
if (!defined('SUCURISCAN_INIT') || SUCURISCAN_INIT !== true) {
if (!headers_sent()) {
/* Report invalid access if possible. */
header('HTTP/1.1 403 Forbidden');
}
exit(1);
}
/**
* Plugin API library.
*
* When used in the context of web development, an API is typically defined as a
* set of Hypertext Transfer Protocol (HTTP) request messages, along with a
* definition of the structure of response messages, which is usually in an
* Extensible Markup Language (XML) or JavaScript Object Notation (JSON) format.
* While "web API" historically has been virtually synonymous for web service,
* the recent trend (so-called Web 2.0) has been moving away from Simple Object
* Access Protocol (SOAP) based web services and service-oriented architecture
* (SOA) towards more direct representational state transfer (REST) style web
* resources and resource-oriented architecture (ROA). Part of this trend is
* related to the Semantic Web movement toward Resource Description Framework
* (RDF), a concept to promote web-based ontology engineering technologies. Web
* APIs allow the combination of multiple APIs into new applications known as
* mashups.
*
* @category Library
* @package Sucuri
* @subpackage SucuriScanner
* @author Daniel Cid <[email protected]>
* @copyright 2010-2018 Sucuri Inc.
* @license https://www.gnu.org/licenses/gpl-2.0.txt GPL2
* @link https://wordpress.org/plugins/sucuri-scanner
*/
class SucuriScanAPI extends SucuriScanOption
{
/**
* Alternative to the built-in PHP method http_build_query.
*
* Some PHP installations with different encoding or with different language
* (German for example) might produce an unwanted behavior when building an
* URL, because of this we decided to write our own URL query builder to
* keep control of the output.
*
* @param array $params May be an array or object containing properties.
* @return string Returns a URL-encoded string.
*/
private static function buildQuery($params = array())
{
$trail = '';
foreach ($params as $param => $value) {
$value = urlencode($value);
$trail .= sprintf('&%s=%s', $param, $value);
}
return substr($trail, 1);
}
/**
* Sends a HTTP request via WordPress WP_HTTP class.
*
* @suppress PhanNonClassMethodCall
* @see https://secure.php.net/manual/en/book.curl.php
* @see https://developer.wordpress.org/reference/classes/wp_http/request/
*
* @param string $url The target URL where the request will be sent.
* @param string $method HTTP method that will be used to send the request.
* @param array $params Parameters for the request defined in an associative array.
* @param array $args Request arguments like the timeout, headers, cookies, etc.
* @return mixed HTTP response, JSON-decoded array, or false on failure.
*/
public static function apiCall($url = '', $method = 'GET', $params = array(), $args = array())
{
if (!$url) {
return self::throwException(__('URL is invalid', 'sucuri-scanner'));
}
if ($method !== 'GET' && $method !== 'POST') {
return self::throwException(__('Only GET and POST methods allowed', 'sucuri-scanner'));
}
$res = null;
$timeout = SUCURISCAN_MAX_REQUEST_TIMEOUT;
$args = is_array($args) ? $args : array();
if (isset($args['timeout'])) {
$timeout = (int)$args['timeout'];
}
/* include request arguments */
$args['method'] = $method;
$args['timeout'] = $timeout;
$args['redirection'] = 5;
$args['httpversion'] = '1.1';
$args['blocking'] = true;
$args['sslverify'] = true;
/* separate hardcoded query parameters */
if (empty($params) && strpos($url, '?')) {
$parts = @parse_url($url);
if (array_key_exists('query', $parts)) {
$portions = explode('&', $parts['query']);
$url = str_replace('?' . $parts['query'], '', $url);
foreach ($portions as $portion) {
$bits = explode('=', $portion, 2);
$params[$bits[0]] = $bits[1];
}
}
}
/* include current timestamp for trackability */
if (!array_key_exists('time', $params)) {
$params['time'] = time();
}
/* support HTTP GET requests */
if ($method === 'GET') {
$args['body'] = null;
$url .= '?' . self::buildQuery($params);
$res = wp_remote_get($url, $args);
}
/* support HTTP POST requests */
if ($method === 'POST') {
if (array_key_exists('a', $params)) {
/* include action to increase visibility */
$url .= '?a=' . $params['a'];
}
$args['body'] = $params;
$res = wp_remote_post($url, $args);
}
if (is_wp_error($res)) {
return self::throwException($res->get_error_message());
}
/* try to return a JSON-encode object */
$data = @json_decode($res['body'], true);
return $data ? $data : $res['body'];
}
/**
* Check whether the plugin API key is valid or not.
*
* @param string $api_key An unique string to identify this installation.
* @return bool True if the API key is valid, false otherwise.
*/
private static function isValidKey($api_key = '')
{
return (bool)@preg_match('/^[a-z0-9]{32}$/', $api_key);
}
/**
* Store the API key locally.
*
* @param string $api_key An unique string of characters to identify this installation.
* @param bool $validate Whether the format of the key should be validated before store it.
* @return bool Either true or false if the key was saved successfully or not respectively.
*/
public static function setPluginKey($api_key = '', $validate = false)
{
if ($validate && !self::isValidKey($api_key)) {
return SucuriScanInterface::error(__('Invalid API key format', 'sucuri-scanner'));
}
if (!empty($api_key)) {
SucuriScanEvent::notifyEvent(
'plugin_change',
sprintf(__('API key was successfully set: %s', 'sucuri-scanner'), $api_key)
);
}
return self::updateOption(':api_key', $api_key);
}
/**
* Retrieve the API key from the local storage.
*
* @return string|bool The API key or false if it does not exists.
*/
public static function getPluginKey()
{
$api_key = self::getOption(':api_key');
if (is_string($api_key) && self::isValidKey($api_key)) {
return $api_key;
}
return false;
}
/**
* Call an action from the remote API interface of our WordPress service.
*
* @param string $method HTTP method that will be used to send the request.
* @param array $params Parameters for the request defined in an associative array of key-value.
* @param bool $send_api_key Whether the API key should be added to the request parameters or not.
* @param array $args Request arguments like the timeout, redirections, headers, cookies, etc.
* @return array|bool Response object after the HTTP request is executed.
*/
public static function apiCallWordpress($method = 'GET', $params = array(), $send_api_key = true, $args = array())
{
if (!SucuriScan::issetScanApiUrl()) {
return false;
}
$params[SUCURISCAN_API_VERSION] = 1;
$params['p'] = 'wordpress';
if ($send_api_key) {
$api_key = self::getPluginKey();
if (!$api_key) {
return false;
}
$params['k'] = $api_key;
}
return self::apiCall(SUCURISCAN_API_URL, $method, $params, $args);
}
/**
* Determine whether an API response was successful or not by checking the
* expected generic variables and types, in case of an error a notification
* will appears in the administrator panel explaining the result of the
* operation.
*
* For failures in the HTTP response:
*
* Log file not found: means that the API key used to execute the request is
* not associated to the website, this may indicate that either the key was
* invalidated by an administrator of the service or that the API key was
* custom generated with invalid data.
*
* Wrong API key: means that the TLD of the origin of the request is not the
* domain used to generate the API key in the first place, or that the email
* address of the site administrator was changed so the data is not valid
* anymore.
*
* @param array $res HTTP response after API endpoint execution.
* @return bool False if the API call failed, true otherwise.
*/
public static function handleResponse($res = array())
{
if (!$res || getenv('SUCURISCAN_NO_API_HANDLE')) {
return false;
}
if (is_array($res)
&& array_key_exists('status', $res)
&& intval($res['status']) === 1
) {
return true;
}
if (is_string($res) && !empty($res)) {
return SucuriScanInterface::error($res);
}
if (!is_array($res)
|| !isset($res['messages'])
|| empty($res['messages'])
) {
return SucuriScanInterface::error(__('Unknown error, there is no information', 'sucuri-scanner'));
}
$msg = implode(".\x20", $res['messages']);
$raw = $msg; /* Keep a copy of the original message. */
// Special response for invalid API keys.
if (stripos($raw, 'log file not found') !== false) {
$key = SucuriScanOption::getOption(':api_key');
$msg .= '; this generally happens when you use an invalid API key,'
. ' or when the connection with the API service suddently closes.';
SucuriScanEvent::reportCriticalEvent($msg);
}
// Special response for invalid firewall API keys.
if (stripos($raw, 'wrong api key') !== false) {
$key = SucuriScanOption::getOption(':cloudproxy_apikey');
$key = SucuriScan::escape($key);
$msg .= sprintf('; invalid firewall API key: %s', $key);
SucuriScanOption::setRevProxy('disable', true);
SucuriScanOption::setAddrHeader('REMOTE_ADDR', true);
return SucuriScanInterface::error($msg);
}
// Stop SSL peer verification on connection failures.
if (stripos($raw, 'no alternative certificate')
|| stripos($raw, 'error setting certificate')
|| stripos($raw, 'SSL connect error')
) {
$msg .= '. The website seems to be using an old version of the Ope'
. 'nSSL library or the CURL extension was compiled without support'
. ' for the algorithm used in the certificate installed in the API'
. ' service. Contact your hosting provider to fix this issue.';
}
// Check if the MX records as missing for API registration.
if (strpos($raw, 'Invalid email') !== false) {
$msg = __('Invalid email format or the host is missing MX records.', 'sucuri-scanner');
}
return SucuriScanInterface::error($msg);
}
/**
* Send a request to the API to register this site.
*
* @param string $email Optional email address for the registration.
* @return bool True if the API key was generated, false otherwise.
*/
public static function registerSite($email = '')
{
if (!is_string($email) || empty($email)) {
$email = self::getSiteEmail();
}
$res = self::apiCallWordpress(
'POST',
array(
'e' => $email,
's' => self::getDomain(),
'a' => 'register_site',
),
false
);
if (!self::handleResponse($res)) {
return false;
}
self::setPluginKey($res['output']['api_key']);
SucuriScanEvent::installScheduledTask();
SucuriScanEvent::notifyEvent('plugin_change', __('API key was generated and set', 'sucuri-scanner'));
return SucuriScanInterface::info(__('API key successfully generated and saved.', 'sucuri-scanner'));
}
/**
* Send a request to recover a previously registered API key.
*
* @return bool True if the API key was sent to the admin email, false otherwise.
*/
public static function recoverKey()
{
$domain = self::getDomain();
$res = self::apiCallWordpress(
'GET',
array(
'e' => self::getSiteEmail(),
's' => $domain,
'a' => 'recover_key',
),
false
);
if (!self::handleResponse($res)) {
return false;
}
SucuriScanEvent::notifyEvent(
'plugin_change',
sprintf(__('API key recovery for domain: %s', 'sucuri-scanner'), $domain)
);
return SucuriScanInterface::info($res['output']['message']);
}
/**
* Retrieve the event logs registered by the API service.
*
* @param int $lines Maximum number of logs to return.
* @param array $filters Filters to apply to the logs.
* @return array|bool The data structure with the logs.
*/
public static function getAuditLogs($lines = 50, $filters = array())
{
if (SucuriScanOption::isDisabled(':api_service') || SucuriScan::issetScanApiUrl()) {
return self::parseAuditLogs(array());
}
$res = self::apiCallWordpress(
'GET',
array(
'a' => 'get_logs',
'l' => $lines,
)
);
if (!self::handleResponse($res)) {
return false;
}
return self::parseAuditLogs($res, $filters);
}
/**
* Returns the security logs from the system queue.
* In case the logs comes from the queue, set key "from_queue" to true,
* as the parse function later will need to prevent timezone conflicts.
*
* @return array The data structure with the logs.
*/
public static function getAuditLogsFromQueue($filters = array())
{
$auditlogs = array();
$cache = new SucuriScanCache('auditqueue');
$events = $cache->getAll();
if (is_array($events) && !empty($events)) {
$events = array_reverse($events);
foreach ($events as $micro => $message) {
if (!is_string($message)) {
/* incompatible JSON data */
continue;
}
$offset = strpos($micro, '_');
$time = substr($micro, 0, $offset);
$auditlogs[] = sprintf(
'%s %s : %s',
SucuriScan::datetime($time, 'Y-m-d H:i:s'),
SucuriScan::getSiteEmail(),
$message
);
}
}
$res = array(
'status' => 1,
'action' => 'get_logs',
'request_time' => time(),
'verbose' => 0,
'output' => array_reverse($auditlogs),
'total_entries' => count($auditlogs),
'from_queue' => '1',
);
return self::parseAuditLogs($res, $filters);
}
/**
* Retrieves the available filters for the audit logs.
*
* @return array An associative array containing the filters for the auditlogs.
*/
public static function getFilters()
{
$format = 'Y-m-d';
$today = strtotime('today');
$yesterday = strtotime('yesterday');
$thisWeek = strtotime('monday this week');
$last7Days = strtotime('-7 days');
$lastWeek = strtotime('monday last week');
$last14Days = strtotime('-14 days');
$thisMonth = strtotime('first day of this month');
$last30Days = strtotime('-30 days');
$lastMonth = strtotime('first day of last month');
$thisYear = strtotime('first day of January this year');
$lastYear = strtotime('first day of January last year');
return array(
'time' => array(
'all time' => array(
'label' => __('All Time', 'sucuri-scanner'),
'date' => null,
),
'today' => array(
'label' => __('Today', 'sucuri-scanner'),
'date' => SucuriScan::datetime($today, $format),
),
'yesterday' => array(
'label' => __('Yesterday', 'sucuri-scanner'),
'date' => SucuriScan::datetime($yesterday, $format),
),
'this week' => array(
'label' => __('This Week', 'sucuri-scanner'),
'date' => SucuriScan::datetime($thisWeek, $format),
),
'last 7 days' => array(
'label' => __('Last 7 Days', 'sucuri-scanner'),
'date' => SucuriScan::datetime($last7Days, $format),
),
'last week' => array(
'label' => __('Last Week', 'sucuri-scanner'),
'date' => SucuriScan::datetime($lastWeek, $format),
),
'last 14 days' => array(
'label' => __('Last 14 Days', 'sucuri-scanner'),
'date' => SucuriScan::datetime($last14Days, $format),
),
'this month' => array(
'label' => __('This Month', 'sucuri-scanner'),
'date' => SucuriScan::datetime($thisMonth, $format),
),
'last 30 days' => array(
'label' => __('Last 30 Days', 'sucuri-scanner'),
'date' => SucuriScan::datetime($last30Days, $format),
),
'last month' => array(
'label' => __('Last Month', 'sucuri-scanner'),
'date' => SucuriScan::datetime($lastMonth, $format),
),
'this year' => array(
'label' => __('This Year', 'sucuri-scanner'),
'date' => SucuriScan::datetime($thisYear, $format),
),
'last year' => array(
'label' => __('Last Year', 'sucuri-scanner'),
'date' => SucuriScan::datetime($lastYear, $format),
),
'custom' => array(
'label' => __('Custom', 'sucuri-scanner'),
'date' => null,
),
),
'startDate' => '',
'endDate' => '',
'posts' => array(
'all posts' => array(
'label' => __('All Posts', 'sucuri-scanner'),
'value' => null,
),
'created' => array(
'label' => __('Created', 'sucuri-scanner'),
'value' => 'Post was created',
),
'updated' => array(
'label' => __('Updated', 'sucuri-scanner'),
'value' => 'Post was updated',
),
'deleted' => array(
'label' => __('Deleted', 'sucuri-scanner'),
'value' => 'Post moved to trash',
),
),
'logins' => array(
'all logins' => array(
'label' => __('All Logins', 'sucuri-scanner'),
'value' => '',
),
'failed' => array(
'label' => __('Failed', 'sucuri-scanner'),
'value' => 'authentication failed',
),
'succeeded' => array(
'label' => __('Succeeded', 'sucuri-scanner'),
'value' => 'authentication succeeded',
),
),
'users' => array(
'all users' => array(
'label' => __('All Users', 'sucuri-scanner'),
'value' => '',
),
'created' => array(
'label' => __('Created', 'sucuri-scanner'),
'value' => 'User account created',
),
'edited' => array(
'label' => __('Edited', 'sucuri-scanner'),
'value' => 'User account edited',
),
'deleted' => array(
'label' => __('Deleted', 'sucuri-scanner'),
'value' => 'User account deleted',
),
),
'plugins' => array(
'all plugins' => array(
'label' => __('All Plugins', 'sucuri-scanner'),
'value' => '',
),
'installed' => array(
'label' => __('Installed', 'sucuri-scanner'),
'value' => 'Plugin installed',
),
'activated' => array(
'label' => __('Activated', 'sucuri-scanner'),
'value' => 'Plugin activated',
),
'deactivated' => array(
'label' => __('Deactivated', 'sucuri-scanner'),
'value' => 'Plugin deactivated',
),
),
);
}
/**
* Filters a log entry based on the frontend filters provided.
* Returns true if the log matches the time filter (if applied) and any of the other filters.
*
* @param array $log The log entry to be filtered.
* @param array $frontend_filters The filters applied from the frontend.
*
* @return bool True if the log passes the filters, false otherwise.
*/
private static function filterAuditLog($log, $frontend_filters)
{
$filters = self::getFilters();
// Check the time filter first
if (isset($frontend_filters['time']) && $frontend_filters['time'] !== 'all time') {
if (!self::filterByTime($log['date'], $frontend_filters)) {
return false;
}
}
$other_filters = $frontend_filters;
unset($other_filters['time'], $other_filters['startDate'], $other_filters['endDate']);
if (empty($other_filters)) {
return true;
}
// Check if the log matches any of the other filters
foreach ($other_filters as $active_filter => $value_filter) {
if (isset($filters[$active_filter][$value_filter])) {
$search_term = $filters[$active_filter][$value_filter]['value'];
if (strpos($log['message'], $search_term) !== false) {
return true; // Log matches one of the filters
}
}
}
return false;
}
/**
* Checks if a log entry matches the specified time filter.
*
* @param array $log_date The date of the log.
* @param array $frontend_filters The filters applied from the frontend.
*
* @return bool True if the log matches the time filter, false otherwise.
*/
private static function filterByTime($log_date, $frontend_filters)
{
$filters = self::getFilters();
$today = strtotime('today');
$time_option = $frontend_filters['time'];
$filter_date = SucuriScan::datetime($today, 'Y-m-d');
if (isset($filters['time'][$time_option]['date'])) {
$filter_date = $filters['time'][$time_option]['date'];
}
switch ($time_option) {
case 'today':
case 'yesterday':
return $log_date === $filter_date;
case 'last week':
$endDate = strtotime('sunday last week');
$endDate = SucuriScan::datetime($endDate, 'Y-m-d');
return $log_date >= $filter_date && $log_date <= $endDate;
case 'last month':
$endDate = strtotime('last day of last month');
$endDate = SucuriScan::datetime($endDate, 'Y-m-d');
return $log_date >= $filter_date && $log_date <= $endDate;
case 'last year':
$endDate = strtotime('last day of December last year');
$endDate = SucuriScan::datetime($endDate, 'Y-m-d');
return $log_date >= $filter_date && $log_date <= $endDate;
case 'this week':
case 'last 7 days':
case 'last 14 days':
case 'this month':
case 'last 30 days':
case 'this year':
return $log_date >= $filter_date;
case 'custom':
$startDate = strtotime($frontend_filters['startDate']);
$startDate = SucuriScan::datetime($startDate, 'Y-m-d');
$endDate = strtotime($frontend_filters['endDate']);
$endDate = SucuriScan::datetime($endDate, 'Y-m-d');
return $log_date >= $startDate && $log_date <= $endDate;
default:
// Unrecognized time option; don't consider it a match.
return false;
}
}
/**
* Reads, parses and extracts relevant data from the security logs.
*
* @param array $res JSON-decoded logs.
* @return array|null Full data extracted from the logs.
*/
private static function parseAuditLogs($res, $filters = array())
{
if (!is_array($res) || !isset($res['output'])) {
return null;
}
$res['output_data'] = array();
foreach ((array)@$res['output'] as $log) {
/* YYYY-MM-dd HH:ii:ss EMAIL : MESSAGE: (multiple entries): a,b,c */
if (strpos($log, "\x20:\x20") === false) {
continue; /* ignore; invalid format */
}
$log_data = array(
'event' => 'notice',
'date' => '',
'time' => '',
'datetime' => '',
'timestamp' => 0,
'account' => '',
'username' => 'system',
'remote_addr' => '127.0.0.1',
'message' => '',
'file_list' => false,
'file_list_count' => 0,
);
list($left, $right) = explode("\x20:\x20", $log, 2);
$dateAndEmail = explode("\x20", $left, 3);
/* set basic information */
$log_data['message'] = $right;
$log_data['account'] = $dateAndEmail[2];
/**
* When the audit logs comes from the queue, it's necessary to convert
* the logs using the correct timezone before parsing to avoid issues.
* First, use timezone override feature if set on the plugin settings,
* convert it properly as the syntax must be compatible with php strtotime,
* otherwise use WordPress timezone or offset with a quick fix only for UTC
* as by default it would be set as "0" instead of "UTC".
*/
$tz_override = SucuriScanOption::getOption(':timezone');
if (empty($tz_override)) {
$wpTimezone = get_option('timezone_string');
if (empty($wpTimezone)) {
$wpTimezone = get_option('gmt_offset');
}
/* set wpTimezone to UTC if was previously unset */
if ($wpTimezone == "0") {
$wpTimezone = "UTC";
}
} else {
$tz_override_replace_from = array(".", "UTC");
$tz_override_replace_to = array(":", "");
$wpTimezone = str_replace($tz_override_replace_from, $tz_override_replace_to, $tz_override);
}
/**
* When the audit logs comes from the audit logs server, it will
* be using EDT timezone, however due to the seasonal nature of the
* EDT timzeone, here we will be using America/New_York when and only
* when the audit logs comes from the audit logs server, cause when
* it comes from the queue, wpTimezone var will be used.
*/
if (array_key_exists('from_queue', $res)) {
$datetime = sprintf('%s %s %s', $dateAndEmail[0], $dateAndEmail[1], $wpTimezone);
} else {
$datetime = sprintf('%s %s America/New_York', $dateAndEmail[0], $dateAndEmail[1]);
}
$log_data['timestamp'] = strtotime($datetime);
$log_data['datetime'] = SucuriScan::datetime($log_data['timestamp'], 'Y-m-d H:i:s');
$log_data['date'] = SucuriScan::datetime($log_data['timestamp'], 'Y-m-d');
$log_data['time'] = SucuriScan::datetime($log_data['timestamp'], 'H:i:s');
/* extract more information from the generic audit logs */
$log_data['message'] = str_replace('<br>', ";\x20", $log_data['message']);
$eventTypes = self::getAuditEventTypes();
$eventTypes = array_keys($eventTypes);
/* LEVEL: USERNAME, IP; MESSAGE */
if (strpos($log_data['message'], ":\x20") && strpos($log_data['message'], ";\x20")) {
$offset = strpos($log_data['message'], ":\x20");
$level = substr($log_data['message'], 0, $offset);
$log_data['event'] = strtolower($level);
/* ignore; invalid event type */
if (!in_array($log_data['event'], $eventTypes)) {
continue;
}
/* extract the IP address */
$log_data['message'] = substr($log_data['message'], $offset + 2);
$offset = strpos($log_data['message'], ";\x20");
$log_data['remote_addr'] = substr($log_data['message'], 0, $offset);
/* extract the username */
if (strpos($log_data['remote_addr'], ",\x20")) {
$index = strpos($log_data['remote_addr'], ",\x20");
$log_data['username'] = substr($log_data['remote_addr'], 0, $index);
$log_data['remote_addr'] = substr($log_data['remote_addr'], $index + 2);
}
/* fix old user authentication logs for backward compatibility */
$log_data['message'] = substr($log_data['message'], $offset + 2);
$log_data['message'] = str_replace(
'logged in',
'authentication succeeded',
$log_data['message']
);
/* extract the username of a successful/failed login */
if (strpos($log_data['message'], "User authentication\x20") === 0) {
$offset = strpos($log_data['message'], ":\x20");
$username = substr($log_data['message'], $offset + 2);
if (strpos($username, ';') !== false) {
$username = substr($username, 0, strpos($username, ';'));
}
$log_data['username'] = $username;
}
}
/* extract more information from the special formatted logs */
if (strpos($log_data['message'], "(multiple entries):\x20")) {
$offset = strpos($log_data['message'], "(multiple entries):\x20");
$message = substr($log_data['message'], 0, $offset + 19);
$entries = substr($log_data['message'], $offset + 20);
$log_data['message'] = $message;
$entries = str_replace(', new size', '; new size', $entries);
$entries = str_replace(",\x20", ";\x20", $entries);
$log_data['file_list'] = explode(',', $entries);
$log_data['file_list_count'] = count($log_data['file_list']);
}
/* extract additional details from the message */
if (strpos($log_data['message'], '; details:')) {
$idx = strpos($log_data['message'], '; details:');
$message = substr($log_data['message'], 0, $idx);
$details = substr($log_data['message'], $idx + 11);
$log_data['message'] = $message . ' (details):';
$log_data['file_list'] = explode(',', $details);
$log_data['file_list_count'] = count($log_data['file_list']);
}
$log_data = self::getLogsHotfix($log_data);
// Based on filters, evaluate if should skip.
if (self::filterAuditLog($log_data, $filters) === false) {
continue;
}
if ($log_data) {
$res['output_data'][] = $log_data;
}
}
return $res;
}
/**
* Modifies some of the security logs to detail the information.
*
* @param array $data Valid security log data structure.
* @return array|bool Modified security log.
*/
private static function getLogsHotfix($data)
{
/**
* PHP Compatibility Checker
*
* The WP Engine PHP Compatibility Checker can be used by any WordPress
* website on any web host to check PHP version compatibility. This
* plugin will lint theme and plugin code inside your WordPress file
* system and give you back a report of compatibility issues for you to
* fix.
*
* @see https://wordpress.org/plugins/php-compatibility-checker/
*/
if (isset($data['message']) && strpos($data['message'], 'Wpephpcompat_jobs') === 0) {
$offset = strpos($data['message'], "ID:\x20");
$id = substr($data['message'], $offset + 4);
$id = substr($id, 0, strpos($id, ';'));
$offset = strpos($data['message'], "name:\x20");
$name = substr($data['message'], $offset + 6);
$data['message'] = sprintf(
__('WP Engine PHP Compatibility Checker: %s (created post #%d as cache)', 'sucuri-scanner'),
$name, /* plugin or theme name */
$id /* unique post or page identifier */
);
}
return $data;
}
/**
* Get a list of valid audit event types with their respective colors.
*
* @return array Valid audit event types with their colors.
*/
public static function getAuditEventTypes()
{
return array(
'critical' => '#000000',
'debug' => '#c690ec',
'error' => '#f27d7d',
'info' => '#5bc0de',
'notice' => '#428bca',
'warning' => '#f0ad4e',
);
}
/**
* Parse the event logs with multiple entries.
*
* @param string $event_log Event log that will be processed.
* @return string|array List of parts of the event log.
*/
public static function parseMultipleEntries($event_log = '')
{
$pattern = "\x20(multiple entries):\x20";
if (strpos($event_log, $pattern)) {
return explode(',', str_replace($pattern, ',', $event_log));
}
return $event_log;
}
/**
* Send a request to the API to store and analyze the file's hashes of the site.
* This will be the core of the monitoring tools and will enhance the
* information of the audit logs alerting the administrator of suspicious
* changes in the system.
*
* @param string $hashes The information gathered after the scanning of the site's files.
* @return bool True if the hashes were stored, false otherwise.
*/
public static function sendHashes($hashes = '')
{
if (empty($hashes)) {
return false;
}
$params = array('a' => 'send_hashes', 'h' => $hashes);
$res = self::apiCallWordpress('POST', $params);
return self::handleResponse($res);
}
/**
* Generates a new set of WordPress security keys.
*
* @return array New set of WordPress security keys.
*/
public static function getNewSecretKeys()
{
$new_keys = array();
$pattern = self::secretKeyPattern();
$res = self::apiCall('https://api.wordpress.org/secret-key/1.1/salt/', 'GET');
if ($res && @preg_match_all($pattern, $res, $match)) {
foreach ($match[1] as $key => $value) {
$new_keys[$value] = $match[3][$key];
}
}
return $new_keys;
}
/**
* Returns the URL for the WordPress checksums API service.
*
* @return string URL for the WordPress checksums API.
*/
public static function checksumAPI()
{
$url = 'https://api.wordpress.org/core/checksums/1.0/?version={version}&locale={locale}';
$custom = SucuriScanOption::getOption(':checksum_api');
if ($custom) {
$url = sprintf(
'https://api.github.com/repos/%s/git/trees/master?recursive=1',
$custom /* expect: username/repository */
);
}
$url = str_replace('{version}', SucuriScan::siteVersion(), $url);
$url = str_replace('{locale}', get_locale(), $url);
return $url;
}
/**
* Returns the name of the hash to use in the integrity tool
*
* By default, the plugin will use MD5 to hash the content of the specified
* file, however, if the core integrity tool is using a custom URL, and this
* URL is pointing to GitHub API, then we will assume that the checksum that
* comes from this service is using SHA1.
*
* @return string Hash to use in the integrity tool.
*/
public static function checksumAlgorithm()
{
return strpos(self::checksumAPI(), '//api.github.com') ? 'sha1' : 'md5';
}
/**
* Calculates the md5/sha1 hash of a given file.
*
* When the user decides to configure the integrity tool to use the checksum
* from a GitHub repository the plugin will have to use the SHA1 algorithm
* instead of MD5 (which is what WordPress uses in their API). For this, we
* will have to calculate the GIT hash object of the file which is basically
* the merge of the text "blob" a single white space, the length of the text
* a null byte and then the text in itself (content of the file).
*
* Example:
*
* - Input: "hello world\n"
* - GIT (object): "blob 16\u0000hello world\n"
* - GIT (shaobj): "3b18e512dba79e4c8300dd08aeb37f8e728b8dad"
*
* @see https://git-scm.com/book/en/v2/Git-Internals-Git-Objects#_object_storage
*
* @param string $algorithm Either md5 or sha1.
* @param string $filename Absolute path to the given file.
* @return string Hash of the given file.
*/
public static function checksum($algorithm, $filename)
{
if ($algorithm === 'sha1') {
$content = SucuriScanFileInfo::fileContent($filename);
return @sha1("blob\x20" . strlen($content) . "\x00" . $content);
}
return @md5_file($filename);
}
/**
* Returns the checksum of all the files of the current WordPress version.
*
* The webmaster can change this URL using an option form the settings page.
* This allows them to control which repository will be used to check the
* integrity of the installation.
*
* For example, projectnami.org offers an option to use Microsoft SQL Server
* instead of MySQL has a different set of files and even with the same
* filenames many of them have been modified to support the new database
* engine, since the checksums are different than the official ones the
* number of false positives will increase. This option allows the webmaster
* to point the plugin to a different URL where the new checksums for this
* project will be retrieved.
*
* If the custom API is part of GitHub infrastructure, the plugin will try
* to build the expected JSON object from the output, if it fails it will
* pass the unmodified response to the rest of the code and try to analyze
* the integrity of the installation with that information.
*
* @see Release Archive https://wordpress.org/download/release-archive/
* @see https://api.github.com/repos/user/repo/git/trees/master?recursive=1
*
* @return array|bool Checksums of the WordPress installation.
*/
public static function getOfficialChecksums()
{
$url = self::checksumAPI();
$version = SucuriScan::siteVersion();
$res = self::apiCall($url, 'GET', array());
if (is_array($res)
&& array_key_exists('sha', $res)
&& array_key_exists('url', $res)
&& array_key_exists('tree', $res)
&& strpos($url, '//api.github.com')
) {
$checksums = array();
foreach ($res['tree'] as $meta) {
$checksums[$meta['path']] = $meta['sha'];
}
$res = array('checksums' => array($version => $checksums));
}
if (!isset($res['checksums'])) {
return false;
}
/* checksums for a specific version */
if (isset($res['checksums'][$version])) {
return $res['checksums'][$version];
}
return $res['checksums'];
}
/**
* Returns the metadata of all the installed plugins.
*
* @see https://developer.wordpress.org/reference/functions/is_plugin_active/
*
* @return array List of plugins with associated metadata.
*/
public static function getPlugins()
{
$cache = new SucuriScanCache('plugindata');
$cached_data = $cache->get('plugins', SUCURISCAN_GET_PLUGINS_LIFETIME, 'array');
/* use cache data instead of API */
if ($cached_data) {
return $cached_data;
}
// Get the plugin's basic information from WordPress transient data.
$plugins = get_plugins();
$wp_market = 'https://wordpress.org/plugins/%s/';
$pattern = '/^http(s)?:\/\/wordpress\.org\/plugins\/(.*)\/$/';
// Loop through each plugin data and complement its information with more attributes.
foreach ($plugins as $path => $plugin_data) {
// Default values for the plugin extra attributes.
$repository = '';
$repository_name = '';
$is_free_plugin = false;
/**
* Extract the information of the plugin which includes the repository name,
* repository URL, and if the source code of the plugin is publicly released or
* not, in this last case if the source code of the plugin is not hosted in the
* official WordPress server it means that it is premium and is being
* distributed by an independent developer.
*/
if (isset($plugin_data['PluginURI'])
&& strpos($plugin_data['PluginURI'], '.org/plugins/')
&& strpos($plugin_data['PluginURI'], '://wordpress.org/')
) {
$is_free_plugin = true;
$repository = $plugin_data['PluginURI'];
$offset = strpos($plugin_data['PluginURI'], '/plugins/');
$repository_name = substr($plugin_data['PluginURI'], $offset + 9);
if (strpos($repository_name, '/') !== false) {
$offset = strpos($repository_name, '/');
$repository_name = substr($repository_name, 0, $offset);
}
} else {
$delimiter = strpos($path, '/') ? '/' : '.';
$parts = explode($delimiter, $path, 2);
$possible_repository = sprintf($wp_market, $parts[0]);
$resp = wp_remote_head($possible_repository);
if (!is_wp_error($resp) && $resp['response']['code'] == 200) {
$repository = $possible_repository;
$repository_name = $parts[0];
$is_free_plugin = true;
}
}
// Complement the plugin's information with these attributes.
$plugins[$path]['Repository'] = $repository;
$plugins[$path]['RepositoryName'] = $repository_name;
$plugins[$path]['InstallationPath'] = sprintf('%s/%s', WP_PLUGIN_DIR, $repository_name);
$plugins[$path]['PluginType'] = ($is_free_plugin ? 'free' : 'premium');
$plugins[$path]['IsPluginInstalled'] = is_dir($plugins[$path]['InstallationPath']);
$plugins[$path]['IsPluginActive'] = is_plugin_active($path);
$plugins[$path]['IsFreePlugin'] = $is_free_plugin;
}
/* cache data for future usage */
$cache->add('plugins', $plugins);
return $plugins;
}
/**
* Retrieve plugin installer pages from WordPress Plugins API.
*
* It is possible for a plugin to override the Plugin API result with three
* filters. Assume this is for plugins, which can extend on the Plugin Info to
* offer more choices. This is very powerful and must be used with care, when
* overriding the filters.
*
* The first filter, 'plugins_api_args', is for the args and gives the action as
* the second parameter. The hook for 'plugins_api_args' must ensure that an
* object is returned.
*
* The second filter, 'plugins_api', is the result that would be returned.
*
* @param string $plugin Frienly name of the plugin.
* @return array|bool Object on success, WP_Error on failure.
*/
public static function getRemotePluginData($plugin = '')
{
$resp = self::apiCall('https://api.wordpress.org/plugins/info/1.0/' . $plugin . '.json', 'GET');
return ($resp === 'null') ? false : $resp;
}
/**
* Retrieve a specific file from the official WordPress subversion repository,
* the content of the file is determined by the tags defined using the site
* version specified. Only official core files are allowed to fetch.
*
* @see https://core.svn.wordpress.org/
* @see https://i18n.svn.wordpress.org/
* @see https://core.svn.wordpress.org/tags/VERSION_NUMBER/
*
* @param string $filename Relative path of a core file.
* @return string|bool Original code for the core file, false otherwise.
*/
public static function getOriginalCoreFile($filename)
{
$version = self::siteVersion();
$url = 'https://core.svn.wordpress.org/tags/{version}/{filename}';
$custom = SucuriScanOption::getOption(':checksum_api');
if ($custom) {
$url = sprintf(
'https://raw.githubusercontent.com/%s/master/{filename}',
$custom /* expect: username/repository */
);
}
$url = str_replace('{version}', $version, $url);
$url = str_replace('{filename}', $filename, $url);
$resp = self::apiCall($url, 'GET');
if (strpos($resp, '404 Not Found') !== false) {
/* not found comes from the official WordPress API */
return self::throwException(__('WordPress version is not supported anymore', 'sucuri-scanner'));
}
if (strpos($resp, '400: Invalid request') !== false) {
/* invalid request comes from the unofficial GitHub API */
return self::throwException(__('WordPress version is not supported anymore', 'sucuri-scanner'));
}
return $resp ? $resp : false;
}
}