Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
384 lines
13 KiB
PHP
384 lines
13 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Vuln_Intel {
|
|
|
|
const STATUS_PENDING = 'pending';
|
|
const STATUS_CLEAN = 'clean';
|
|
const STATUS_VULNERABLE = 'vulnerable';
|
|
const STATUS_UNAVAILABLE = 'unavailable';
|
|
|
|
const ACTION_UPDATE = 'update';
|
|
const ACTION_IGNORE = 'ignore';
|
|
const ACTION_DEFER = 'defer';
|
|
const ACTION_RISK_ACCEPTANCE = 'risk_acceptance';
|
|
|
|
const CHECK_INTERVAL_SECS = HOUR_IN_SECONDS;
|
|
|
|
const UNAVAILABLE_AFTER_SECS = 3 * HOUR_IN_SECONDS;
|
|
|
|
const RETRY_DELAYS_MINUTES = array( 5, 5, 15, 30 );
|
|
|
|
const LAST_SUCCESS_OPTION = 'argus_wpd_vuln_last_success';
|
|
const RETRY_STAGE_OPTION = 'argus_wpd_vuln_retry_stage';
|
|
const NEXT_RETRY_AT_OPTION = 'argus_wpd_vuln_next_retry_at';
|
|
|
|
public static function init() {
|
|
if ( class_exists( 'Argus_Integrity' ) ) {
|
|
|
|
add_action( 'upgrader_process_complete', array( __CLASS__, 'on_upgrader_complete' ), 10, 2 );
|
|
}
|
|
}
|
|
|
|
public static function on_upgrader_complete( $upgrader, $data ) {
|
|
self::resolve_inventory();
|
|
}
|
|
|
|
public static function scheduled_check() {
|
|
$success = self::check_core_currency();
|
|
self::resolve_inventory();
|
|
|
|
if ( $success ) {
|
|
self::clear_retry_ladder();
|
|
} else {
|
|
self::start_retry_ladder();
|
|
}
|
|
}
|
|
|
|
public static function maybe_retry() {
|
|
$stage = get_option( self::RETRY_STAGE_OPTION, null );
|
|
if ( null === $stage ) {
|
|
return;
|
|
}
|
|
if ( time() < (int) get_option( self::NEXT_RETRY_AT_OPTION, 0 ) ) {
|
|
return;
|
|
}
|
|
|
|
$success = self::check_core_currency();
|
|
if ( $success ) {
|
|
self::clear_retry_ladder();
|
|
return;
|
|
}
|
|
|
|
$next_stage = (int) $stage + 1;
|
|
if ( ! isset( self::RETRY_DELAYS_MINUTES[ $next_stage ] ) ) {
|
|
self::clear_retry_ladder();
|
|
return;
|
|
}
|
|
update_option( self::RETRY_STAGE_OPTION, $next_stage, false );
|
|
update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[ $next_stage ] * MINUTE_IN_SECONDS ), false );
|
|
}
|
|
|
|
protected static function start_retry_ladder() {
|
|
update_option( self::RETRY_STAGE_OPTION, 0, false );
|
|
update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[0] * MINUTE_IN_SECONDS ), false );
|
|
}
|
|
|
|
protected static function clear_retry_ladder() {
|
|
delete_option( self::RETRY_STAGE_OPTION );
|
|
delete_option( self::NEXT_RETRY_AT_OPTION );
|
|
}
|
|
|
|
protected static function check_core_currency() {
|
|
if ( ! function_exists( 'get_core_updates' ) ) {
|
|
require_once ABSPATH . 'wp-admin/includes/update.php';
|
|
}
|
|
|
|
$ping = wp_remote_get( 'https://api.wordpress.org/core/version-check/1.7/', array( 'timeout' => 15 ) );
|
|
if ( is_wp_error( $ping ) || (int) wp_remote_retrieve_response_code( $ping ) >= 500 ) {
|
|
return false;
|
|
}
|
|
|
|
wp_version_check();
|
|
$updates = get_core_updates();
|
|
|
|
global $wp_version;
|
|
self::upsert_status( 'core', 'core', 'WordPress Core', $wp_version, self::STATUS_CLEAN );
|
|
|
|
update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false );
|
|
|
|
if ( empty( $updates ) || ! is_array( $updates ) || 'upgrade' !== ( $updates[0]->response ?? '' ) ) {
|
|
return true;
|
|
}
|
|
|
|
$update = $updates[0];
|
|
Argus_Findings::record(
|
|
'vulnerability',
|
|
'medium',
|
|
array(
|
|
'what_happened' => sprintf( 'WordPress core is out of date: running %s, %s is available', $wp_version, $update->version ),
|
|
'why_it_matters' => 'Older WordPress core releases can be missing security fixes, even when no specific vulnerability is separately confirmed for this exact version.',
|
|
'what_argus_found' => sprintf( 'Checked against WordPress.org\'s own update API. Current: %s. Available: %s.', $wp_version, $update->version ),
|
|
'when_it_happened' => current_time( 'mysql' ),
|
|
|
|
'why_suspicious' => 'This is a currency check, not a confirmed exploit -- ARGUS flags any available core update for visibility, not only ones known to fix a specific CVE.',
|
|
'what_could_be_affected' => 'The entire site, since WordPress core underlies every plugin and theme.',
|
|
'what_should_you_do' => 'Review the WordPress release notes for ' . $update->version . ' and update when convenient. If you have a reason to defer (compatibility testing, staging validation), that is a reasonable choice -- just don\'t defer indefinitely.',
|
|
),
|
|
array(
|
|
'current_version' => $wp_version,
|
|
'available_version' => $update->version,
|
|
'action' => array(
|
|
'type' => 'update_core',
|
|
'label' => sprintf( __( 'Update to %s', 'argus-wordpress-defence' ), $update->version ),
|
|
),
|
|
)
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
public static function inventory() {
|
|
global $wp_version;
|
|
$items = array( array( 'type' => 'core', 'slug' => 'core', 'name' => 'WordPress Core', 'version' => $wp_version ) );
|
|
|
|
if ( ! function_exists( 'get_plugins' ) ) {
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
}
|
|
foreach ( get_plugins() as $plugin_file => $plugin_data ) {
|
|
$items[] = array(
|
|
'type' => 'plugin',
|
|
'slug' => self::slug_from_plugin_file( $plugin_file ),
|
|
'name' => $plugin_data['Name'] ?? $plugin_file,
|
|
'version' => $plugin_data['Version'] ?? '',
|
|
);
|
|
}
|
|
|
|
foreach ( wp_get_themes() as $stylesheet => $theme ) {
|
|
$items[] = array(
|
|
'type' => 'theme',
|
|
'slug' => $stylesheet,
|
|
'name' => $theme->get( 'Name' ) ?: $stylesheet,
|
|
'version' => $theme->get( 'Version' ) ?: '',
|
|
);
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
public static function resolve_inventory() {
|
|
global $wpdb;
|
|
$vuln_cache_total = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'vuln_cache' ) ); // phpcs:ignore
|
|
|
|
foreach ( self::inventory() as $item ) {
|
|
if ( 'core' === $item['type'] ) {
|
|
continue;
|
|
}
|
|
self::resolve_component( $item['type'], $item['slug'], $item['name'], $item['version'], $vuln_cache_total );
|
|
}
|
|
|
|
self::prune_removed_components();
|
|
}
|
|
|
|
protected static function resolve_component( $type, $slug, $name, $version, $vuln_cache_total ) {
|
|
global $wpdb;
|
|
|
|
if ( 0 === $vuln_cache_total ) {
|
|
|
|
self::upsert_status( $type, $slug, $name, $version, self::STATUS_PENDING );
|
|
return;
|
|
}
|
|
|
|
$table = Argus_DB::table( 'vuln_cache' );
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE component_type = %s AND slug = %s", $type, $slug ) // phpcs:ignore
|
|
);
|
|
|
|
$flagged = null;
|
|
foreach ( $rows as $row ) {
|
|
if ( '' === $version || ! $row->vulnerable_below ) {
|
|
continue;
|
|
}
|
|
if ( version_compare( $version, $row->vulnerable_below, '<' ) ) {
|
|
$flagged = $row;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( $flagged ) {
|
|
self::upsert_status( $type, $slug, $name, $version, self::STATUS_VULNERABLE, $flagged );
|
|
self::record_vulnerability_finding( $type, $slug, $name, $version, $flagged );
|
|
return;
|
|
}
|
|
|
|
self::upsert_status( $type, $slug, $name, $version, self::STATUS_CLEAN );
|
|
}
|
|
|
|
protected static function upsert_status( $type, $slug, $name, $version, $status, $vuln_row = null ) {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'vuln_status' );
|
|
$now = current_time( 'mysql', true );
|
|
|
|
$data = array(
|
|
'component_type' => $type,
|
|
'slug' => $slug,
|
|
'name' => $name,
|
|
'installed_version' => $version,
|
|
'checked_version' => $version,
|
|
'status' => $status,
|
|
'severity' => $vuln_row->severity ?? null,
|
|
'cve' => $vuln_row->cve ?? null,
|
|
'fixed_in' => $vuln_row->fixed_in ?? null,
|
|
'description' => $vuln_row->description ?? null,
|
|
'last_checked_at' => $now,
|
|
'updated_at' => $now,
|
|
);
|
|
|
|
$existing_id = $wpdb->get_var(
|
|
$wpdb->prepare( "SELECT id FROM {$table} WHERE component_type = %s AND slug = %s", $type, $slug ) // phpcs:ignore
|
|
);
|
|
if ( $existing_id ) {
|
|
$wpdb->update( $table, $data, array( 'id' => $existing_id ) );
|
|
} else {
|
|
$wpdb->insert( $table, $data );
|
|
}
|
|
}
|
|
|
|
protected static function prune_removed_components() {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'vuln_status' );
|
|
|
|
$current = array();
|
|
foreach ( self::inventory() as $item ) {
|
|
$current[] = $item['type'] . '|' . $item['slug'];
|
|
}
|
|
|
|
$existing = $wpdb->get_results( "SELECT id, component_type, slug FROM {$table}" ); // phpcs:ignore
|
|
foreach ( $existing as $row ) {
|
|
if ( ! in_array( $row->component_type . '|' . $row->slug, $current, true ) ) {
|
|
$wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
protected static function record_vulnerability_finding( $type, $slug, $name, $version, $vuln_row ) {
|
|
Argus_Findings::record(
|
|
'vulnerability',
|
|
$vuln_row->severity,
|
|
array(
|
|
'what_happened' => sprintf( '%s "%s" (v%s) has a known vulnerability%s', ucfirst( $type ), $name, $version, $vuln_row->cve ? ' (' . $vuln_row->cve . ')' : '' ),
|
|
'why_it_matters' => $vuln_row->description ?: 'This version is affected by a publicly documented vulnerability.',
|
|
'what_argus_found' => sprintf( 'Installed: %s. Vulnerable below: %s. Fixed in: %s.', $version, $vuln_row->vulnerable_below, $vuln_row->fixed_in ?: 'unknown' ),
|
|
'when_it_happened' => current_time( 'mysql' ),
|
|
'why_suspicious' => 'This is a version-matching result against ARGUS\'s vulnerability intelligence, not a detected exploitation attempt.',
|
|
'what_could_be_affected' => 'Depends on the specific vulnerability -- see the description above.',
|
|
'what_should_you_do' => $vuln_row->fixed_in
|
|
? sprintf( 'Update to version %s or later.', $vuln_row->fixed_in )
|
|
: 'No fixed version is currently known -- consider deactivating this component until one is available, or accept the risk deliberately if it\'s not exposed.',
|
|
),
|
|
array(
|
|
'component_type' => $type,
|
|
'slug' => $slug,
|
|
'installed_version' => $version,
|
|
'cve' => $vuln_row->cve,
|
|
'action' => $vuln_row->fixed_in ? array(
|
|
'type' => 'plugin' === $type ? 'update_plugin' : 'update_theme',
|
|
'label' => sprintf( __( 'Update to %s', 'argus-wordpress-defence' ), $vuln_row->fixed_in ),
|
|
'plugin' => 'plugin' === $type ? self::plugin_file_for_slug( $slug ) : null,
|
|
'theme' => 'theme' === $type ? $slug : null,
|
|
) : null,
|
|
)
|
|
);
|
|
}
|
|
|
|
protected static function plugin_file_for_slug( $slug ) {
|
|
if ( ! function_exists( 'get_plugins' ) ) {
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
}
|
|
foreach ( array_keys( get_plugins() ) as $plugin_file ) {
|
|
if ( self::slug_from_plugin_file( $plugin_file ) === $slug ) {
|
|
return $plugin_file;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public static function import_feed( array $records ) {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'vuln_cache' );
|
|
$now = current_time( 'mysql', true );
|
|
$count = 0;
|
|
|
|
foreach ( $records as $r ) {
|
|
if ( empty( $r['component_type'] ) || empty( $r['slug'] ) ) {
|
|
continue;
|
|
}
|
|
|
|
$existing_id = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT id FROM {$table} WHERE component_type = %s AND slug = %s AND cve <=> %s", // phpcs:ignore
|
|
$r['component_type'],
|
|
$r['slug'],
|
|
$r['cve'] ?? null
|
|
)
|
|
);
|
|
|
|
$data = array(
|
|
'component_type' => $r['component_type'],
|
|
'slug' => $r['slug'],
|
|
'vulnerable_below' => $r['vulnerable_below'] ?? null,
|
|
'fixed_in' => $r['fixed_in'] ?? null,
|
|
'severity' => $r['severity'] ?? 'medium',
|
|
'cve' => $r['cve'] ?? null,
|
|
'description' => $r['description'] ?? '',
|
|
'updated_at' => $now,
|
|
);
|
|
|
|
if ( $existing_id ) {
|
|
$wpdb->update( $table, $data, array( 'id' => $existing_id ) );
|
|
} else {
|
|
$wpdb->insert( $table, $data );
|
|
}
|
|
$count++;
|
|
}
|
|
|
|
if ( $count > 0 ) {
|
|
self::resolve_inventory();
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
protected static function slug_from_plugin_file( $plugin_file ) {
|
|
$parts = explode( '/', $plugin_file );
|
|
return $parts[0] ?? $plugin_file;
|
|
}
|
|
|
|
public static function status() {
|
|
$last_success = get_option( self::LAST_SUCCESS_OPTION, '' );
|
|
|
|
global $wpdb;
|
|
$rows = $wpdb->get_results( 'SELECT * FROM ' . Argus_DB::table( 'vuln_status' ) . ' ORDER BY component_type = "core" DESC, name ASC' ); // phpcs:ignore
|
|
|
|
$components = array();
|
|
foreach ( $rows as $row ) {
|
|
$status = $row->status;
|
|
|
|
if ( 'core' === $row->component_type && $last_success && strtotime( $last_success . ' UTC' ) < ( time() - self::UNAVAILABLE_AFTER_SECS ) ) {
|
|
$status = self::STATUS_UNAVAILABLE;
|
|
}
|
|
$components[] = array(
|
|
'type' => $row->component_type,
|
|
'slug' => $row->slug,
|
|
'name' => $row->name,
|
|
'installed_version' => $row->installed_version,
|
|
'status' => $status,
|
|
'severity' => $row->severity,
|
|
'cve' => $row->cve,
|
|
'fixed_in' => $row->fixed_in,
|
|
);
|
|
}
|
|
|
|
return array(
|
|
|
|
'protection' => $last_success ? 'active' : 'starting',
|
|
'last_check' => $last_success,
|
|
'next_check' => $last_success ? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + self::CHECK_INTERVAL_SECS ) : '',
|
|
'components' => $components,
|
|
);
|
|
}
|
|
}
|