Files
argus-wp-defence/includes/class-argus-vuln-intel.php
T
root df0f2fccb8 ARGUS WordPress Defence 1.0.0 — first production release
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.
2026-08-09 13:40:16 +00:00

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,
);
}
}