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.
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user