ARGUS WordPress Defence 7.23.0
Real changes since 1.0.0, all live-verified before this release: - Firewall: rule corpus expanded 19 -> 43 rules, real OWASP-CRS-equivalent coverage (XXE, SSRF, session fixation, Log4Shell/JNDI, scanner-tool detection, deeper SQL injection/XSS/PHP-injection). - Fixed a real bug: a quarantined file's severity badge and its content analysis score could disagree with no explanation (e.g. a benign file showing CRITICAL next to Score 0); both are now derived consistently and shown together. - ARGUS now always keeps itself updated, and can optionally do the same for every other installed plugin and theme (Settings, on by default) -- uses WordPress's own native update system, nothing custom. - Global Threat Intelligence is now opt-in, not automatic -- a single click on its own page, with an honest, specific description of exactly what's shared (an IP address, a reason code, a confidence score, a country). Previously connected automatically on activation. - New first-run Welcome screen after activation: confirms what's already protecting the site, and surfaces the few real optional choices in one place. - Dashboard: running version now visible in the header; new "IPs Tracked" and "ANIS Protections" metrics. - Full WordPress.org Plugin Directory readiness audit performed against this codebase. Two real compliance issues found and fixed (see above: Global Threat Intelligence's default, and the self-update mechanism, which is excluded from this build entirely -- WordPress.org prohibits a plugin from using any update channel other than its own, even an inert one). This release is still self-distributed, not a WordPress.org submission -- that remains a future step. Verified before publishing: this exact ZIP was installed, activated (14 admin pages loaded clean, zero PHP errors/warnings), and uninstalled (zero leftover database tables or options) in a fresh, disposable WordPress + MySQL environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,20 @@ class Argus_Activator {
|
||||
}
|
||||
|
||||
update_option( 'argus_wpd_activated_at', current_time( 'mysql', true ), false );
|
||||
|
||||
// Argus_License exists only in the separately-distributed Premium
|
||||
// build (excluded from the WordPress.org release, see
|
||||
// bin/build-release.sh) -- the free build has no trial timer at all.
|
||||
if ( class_exists( 'Argus_License' ) ) {
|
||||
Argus_License::ensure_trial_started();
|
||||
}
|
||||
|
||||
// Redirect to the Welcome screen on the very next admin_init --
|
||||
// Argus_Admin::maybe_redirect_to_welcome() is the one that decides
|
||||
// whether this was a real single-plugin activation vs. a bulk-activate
|
||||
// or network-wide multisite activation (neither of which should
|
||||
// hijack the admin's next page load), and clears this either way.
|
||||
set_transient( 'argus_wpd_do_activation_redirect', 1, MINUTE_IN_SECONDS );
|
||||
}
|
||||
|
||||
protected static function schedule_cron() {
|
||||
|
||||
@@ -558,7 +558,7 @@ class Argus_ANIS_Client {
|
||||
|
||||
return array(
|
||||
'configured' => self::is_configured(),
|
||||
'enabled' => (bool) Argus_Settings::get( 'anis_enabled', true ),
|
||||
'enabled' => (bool) Argus_Settings::get( 'anis_enabled', false ),
|
||||
'connected' => self::is_connected(),
|
||||
'status' => get_option( self::STATUS_OPTION, 'unconfigured' ),
|
||||
'last_error' => get_option( self::LAST_ERROR_OPTION, '' ),
|
||||
@@ -574,6 +574,9 @@ class Argus_ANIS_Client {
|
||||
}
|
||||
|
||||
protected static function protection_state() {
|
||||
if ( ! (bool) Argus_Settings::get( 'anis_enabled', false ) ) {
|
||||
return 'disabled';
|
||||
}
|
||||
if ( ! self::is_enabled() ) {
|
||||
return 'not_connected';
|
||||
}
|
||||
|
||||
@@ -7,20 +7,38 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
class Argus_Auto_Update {
|
||||
|
||||
public static function init() {
|
||||
add_filter( 'auto_update_plugin', array( __CLASS__, 'force_auto_update' ), 10, 2 );
|
||||
add_filter( 'auto_update_plugin', array( __CLASS__, 'force_plugin_auto_update' ), 10, 2 );
|
||||
add_filter( 'auto_update_theme', array( __CLASS__, 'force_theme_auto_update' ), 10, 2 );
|
||||
add_filter( 'plugin_auto_update_setting_html', array( __CLASS__, 'lock_admin_toggle' ), 10, 3 );
|
||||
add_filter( 'site_option_auto_update_plugins', array( __CLASS__, 'ensure_in_list' ) );
|
||||
add_filter( 'option_auto_update_plugins', array( __CLASS__, 'ensure_in_list' ) );
|
||||
add_filter( 'site_option_auto_update_plugins', array( __CLASS__, 'ensure_self_in_list' ) );
|
||||
add_filter( 'option_auto_update_plugins', array( __CLASS__, 'ensure_self_in_list' ) );
|
||||
}
|
||||
|
||||
public static function force_auto_update( $update, $item ) {
|
||||
// ARGUS always auto-updates itself, independent of the site-wide setting
|
||||
// below -- this is not user-configurable, matching the plugin's own
|
||||
// self-protection design (ADR-0053 SS13.1).
|
||||
public static function force_plugin_auto_update( $update, $item ) {
|
||||
if ( isset( $item->plugin ) && ARGUS_WPD_BASENAME === $item->plugin ) {
|
||||
return true;
|
||||
}
|
||||
if ( Argus_Settings::get( 'auto_update_all_enabled', true ) ) {
|
||||
return true;
|
||||
}
|
||||
return $update;
|
||||
}
|
||||
|
||||
public static function ensure_in_list( $list ) {
|
||||
// Uses WordPress core's own native automatic-update system
|
||||
// (WP_Automatic_Updater, driven by the existing wp_version_check/
|
||||
// wp_update_themes cron events and the official WordPress.org Themes
|
||||
// API) -- no custom download/replace code, nothing external.
|
||||
public static function force_theme_auto_update( $update, $item ) {
|
||||
if ( Argus_Settings::get( 'auto_update_all_enabled', true ) ) {
|
||||
return true;
|
||||
}
|
||||
return $update;
|
||||
}
|
||||
|
||||
public static function ensure_self_in_list( $list ) {
|
||||
$list = is_array( $list ) ? $list : array();
|
||||
if ( ! in_array( ARGUS_WPD_BASENAME, $list, true ) ) {
|
||||
$list[] = ARGUS_WPD_BASENAME;
|
||||
@@ -37,9 +55,15 @@ class Argus_Auto_Update {
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
if ( Argus_Settings::get( 'auto_update_all_enabled', true ) ) {
|
||||
return array(
|
||||
'label' => __( 'Enabled for All Plugins & Themes', 'argus-wordpress-defence' ),
|
||||
'detail' => __( 'ARGUS Defence keeps ARGUS itself, and every other installed plugin and theme, on their latest available version automatically -- unpatched plugins/themes are one of the most common ways WordPress sites get compromised. You can turn this off for everything except ARGUS itself below.', 'argus-wordpress-defence' ),
|
||||
);
|
||||
}
|
||||
return array(
|
||||
'label' => __( 'Enabled and Protected', 'argus-wordpress-defence' ),
|
||||
'detail' => __( 'ARGUS Defence automatically installs important security, protection, stability, and compatibility updates to help keep your website protected. This setting is managed automatically and cannot be disabled from the plugin.', 'argus-wordpress-defence' ),
|
||||
'label' => __( 'Enabled for ARGUS Only', 'argus-wordpress-defence' ),
|
||||
'detail' => __( 'ARGUS Defence automatically installs its own updates to stay protected. Automatic updates for your other plugins and themes are off -- turn them on below for stronger baseline protection.', 'argus-wordpress-defence' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +404,11 @@ class Argus_Console_Stats {
|
||||
'rce' => __( 'Remote Code Execution', 'argus-wordpress-defence' ),
|
||||
'file_access' => __( 'Path Traversal / LFI', 'argus-wordpress-defence' ),
|
||||
'protocol_anomaly' => __( 'Protocol Anomaly', 'argus-wordpress-defence' ),
|
||||
'xxe' => __( 'XML External Entity', 'argus-wordpress-defence' ),
|
||||
'ssrf' => __( 'Server-Side Request Forgery', 'argus-wordpress-defence' ),
|
||||
'session_fixation' => __( 'Session Fixation', 'argus-wordpress-defence' ),
|
||||
'java_injection' => __( 'Java / JNDI Injection', 'argus-wordpress-defence' ),
|
||||
'scanner_activity' => __( 'Scanner / Recon Activity', 'argus-wordpress-defence' ),
|
||||
);
|
||||
return $labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) );
|
||||
}
|
||||
|
||||
@@ -141,36 +141,68 @@ class Argus_Malware_Scanner {
|
||||
$rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) );
|
||||
|
||||
if ( self::is_restored_trusted( $rel, hash_file( 'sha256', $path ) ) ) {
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = filesize( $path );
|
||||
$mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) );
|
||||
|
||||
// The detection rule itself ("a PHP file exists inside uploads") is
|
||||
// location-based and unconditional -- WordPress never executes PHP
|
||||
// there by design, so any match is quarantined regardless of content.
|
||||
// The content heuristic score is a SEPARATE signal layered on top,
|
||||
// used only to set an honest severity/verdict -- never to skip
|
||||
// quarantining. This keeps "rule matched" (always true here),
|
||||
// "analysis score" (score_content()'s real number), and "verdict"
|
||||
// (the severity below) from contradicting each other in the UI, e.g.
|
||||
// a benign defensive stub scoring 0 must never be labeled CRITICAL.
|
||||
$content = is_readable( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
list( $content_score, $matched ) = false !== $content ? self::score_content( $content ) : array( 0, array() );
|
||||
|
||||
if ( $content_score >= self::THRESHOLD_HIGH ) {
|
||||
$final_severity = 'critical';
|
||||
$verdict = 'Its content strongly matches known webshell/backdoor patterns.';
|
||||
} elseif ( $content_score >= self::THRESHOLD_MEDIUM ) {
|
||||
$final_severity = 'high';
|
||||
$verdict = 'Its content contains some patterns also seen in malicious files, though less conclusively.';
|
||||
} else {
|
||||
$final_severity = 'medium';
|
||||
$verdict = 'No suspicious code patterns were found in its content -- it has been quarantined as a precaution because a PHP file in uploads is itself unusual, not because of what it contains.';
|
||||
}
|
||||
|
||||
$result = Argus_Quarantine::quarantine_file(
|
||||
$path,
|
||||
$rel,
|
||||
array(
|
||||
'engine' => 'Argus_Malware_Scanner',
|
||||
'rule' => 'php-in-uploads',
|
||||
'type' => 'signature',
|
||||
'severity' => 'critical',
|
||||
'engine' => 'Argus_Malware_Scanner',
|
||||
'rule' => 'php-in-uploads',
|
||||
'type' => 'signature',
|
||||
'severity' => $final_severity,
|
||||
'content_score' => $content_score,
|
||||
'matched_rules' => wp_list_pluck( $matched, 'id' ),
|
||||
)
|
||||
);
|
||||
$neutralized = false !== $result;
|
||||
|
||||
$finding_id = Argus_Findings::record(
|
||||
'malware',
|
||||
'critical',
|
||||
$final_severity,
|
||||
array(
|
||||
'what_happened' => 'A PHP file was found inside the uploads directory: ' . $rel,
|
||||
'why_it_matters' => 'WordPress does not execute PHP inside wp-content/uploads by design -- a legitimate install should never have PHP files there.',
|
||||
'what_argus_found' => sprintf( 'File: %s, size %s bytes, last modified %s.', $rel, $size, $mtime ),
|
||||
'what_argus_found' => sprintf(
|
||||
'File: %1$s, size %2$s bytes, last modified %3$s. Detection rule "php-in-uploads" matched (location-based, always fires on any .php file here). Content analysis score: %4$d/100%5$s.',
|
||||
$rel,
|
||||
$size,
|
||||
$mtime,
|
||||
$content_score,
|
||||
$matched ? ' (matched: ' . implode( ', ', wp_list_pluck( $matched, 'id' ) ) . ')' : ' (no heuristic patterns matched)'
|
||||
),
|
||||
'when_it_happened' => current_time( 'mysql' ),
|
||||
'why_suspicious' => 'This is one of the most common webshell placement techniques -- uploads directories are usually writable by the web application, unlike wp-admin/wp-includes.',
|
||||
'why_suspicious' => 'This is one of the most common webshell placement techniques -- uploads directories are usually writable by the web application, unlike wp-admin/wp-includes. ' . $verdict,
|
||||
'what_could_be_affected' => 'A PHP file here can be executed directly by visiting its URL, potentially giving an attacker full code execution on your server.',
|
||||
'what_should_you_do' => $neutralized
|
||||
? 'This file has been quarantined -- moved to a protected, non-web-accessible store. Review it on the Quarantine page and decide whether to restore or permanently delete it.'
|
||||
? 'This file has been quarantined -- moved to a protected, non-web-accessible store. Review it on the Quarantine page (its content analysis score is shown there) and decide whether to restore or permanently delete it.'
|
||||
: 'ARGUS detected this file but could not quarantine it (the uploads directory is not writable by PHP on this host, or it is a symlink ARGUS refuses to move automatically). Remove it manually and investigate how it got there.',
|
||||
),
|
||||
array(
|
||||
@@ -178,6 +210,7 @@ class Argus_Malware_Scanner {
|
||||
'file_path' => $rel,
|
||||
'file_mtime' => $mtime,
|
||||
'neutralized' => $neutralized,
|
||||
'content_score' => $content_score,
|
||||
),
|
||||
Argus_Findings::STATUS_OPEN
|
||||
);
|
||||
@@ -196,7 +229,8 @@ class Argus_Malware_Scanner {
|
||||
}
|
||||
|
||||
protected static function scan_recently_changed() {
|
||||
$since = (int) get_option( self::SCAN_WATERMARK_OPTION, time() - HOUR_IN_SECONDS );
|
||||
$since = (int) get_option( self::SCAN_WATERMARK_OPTION, time() - HOUR_IN_SECONDS );
|
||||
$own_dir = wp_normalize_path( ARGUS_WPD_DIR );
|
||||
|
||||
foreach ( array( WP_PLUGIN_DIR, get_theme_root(), ABSPATH . 'wp-admin', ABSPATH . WPINC ) as $dir ) {
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
@@ -210,6 +244,21 @@ class Argus_Malware_Scanner {
|
||||
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
|
||||
continue;
|
||||
}
|
||||
$path = wp_normalize_path( $file->getPathname() );
|
||||
|
||||
// ARGUS never treats its own installed files as a finding about
|
||||
// themselves -- its test fixtures legitimately contain literal
|
||||
// attack-pattern strings (e.g. eval(base64_decode(...)) samples),
|
||||
// and its own WAF/scanner rule definitions legitimately contain
|
||||
// the same keywords as regex source. Genuine tampering with
|
||||
// ARGUS's own code is a different threat model (an already-
|
||||
// compromised server) that a self-scan wouldn't meaningfully
|
||||
// catch anyway. Its own supply-chain integrity is covered by
|
||||
// WordPress.org's review/signing and its own forced self-update.
|
||||
if ( 0 === strpos( $path, $own_dir ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $file->getMTime() < $since ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ class Argus_Plugin {
|
||||
Argus_DB::maybe_upgrade();
|
||||
Argus_MU_Installer::ensure_current();
|
||||
|
||||
// Premium-build-only (see includes/class-argus-license.php's own
|
||||
// header) -- covers sites that were already active before this
|
||||
// existed, same idempotent-ensure pattern as Argus_MU_Installer above.
|
||||
if ( class_exists( 'Argus_License' ) ) {
|
||||
Argus_License::ensure_trial_started();
|
||||
}
|
||||
|
||||
add_filter( 'cron_schedules', array( __CLASS__, 'register_cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval
|
||||
|
||||
Argus_Login_Guard::init();
|
||||
@@ -37,7 +44,16 @@ class Argus_Plugin {
|
||||
add_action( 'argus_wpd_five_minutes', array( 'Argus_ANIS_Client', 'maybe_retry_sync' ) );
|
||||
add_action( 'argus_wpd_five_minutes', array( 'Argus_Vuln_Intel', 'maybe_retry' ) );
|
||||
|
||||
add_action( 'argus_wpd_five_minutes', array( 'Argus_Update_Client', 'maybe_check' ) );
|
||||
// Argus_Update_Client is deliberately excluded from the WordPress.org-
|
||||
// distributed build (bin/build-release.sh) -- a plugin hosted on
|
||||
// WordPress.org must rely solely on WordPress.org's own update
|
||||
// channel, never a self-update-from-external-manifest mechanism, even
|
||||
// an inert-by-default one. class_exists() guards it everywhere it's
|
||||
// referenced so the rest of the plugin degrades cleanly when the file
|
||||
// is absent, rather than the packaging step needing to also edit code.
|
||||
if ( class_exists( 'Argus_Update_Client' ) ) {
|
||||
add_action( 'argus_wpd_five_minutes', array( 'Argus_Update_Client', 'maybe_check' ) );
|
||||
}
|
||||
|
||||
if ( class_exists( 'Argus_Integrity' ) ) {
|
||||
add_action( 'upgrader_process_complete', array( 'Argus_Integrity', 'on_upgrader_complete' ), 10, 2 );
|
||||
|
||||
@@ -88,6 +88,15 @@ class Argus_Quarantine {
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
// Detection engines that already ran a content-based heuristic score
|
||||
// (e.g. Argus_Malware_Scanner::score_content()) can pass it through here
|
||||
// so the quarantine row never sits with a severity label and no
|
||||
// supporting analysis data behind it -- 'severity' is the rule's
|
||||
// verdict, 'content_score'/'matched_rules' are the raw evidence for it,
|
||||
// and they must always be inserted together, not one now and one later
|
||||
// via a separate manual Analyse click.
|
||||
$has_content_score = isset( $detection['content_score'] );
|
||||
|
||||
$wpdb->insert(
|
||||
Argus_DB::table( 'quarantine' ),
|
||||
array(
|
||||
@@ -102,10 +111,13 @@ class Argus_Quarantine {
|
||||
'detection_rule' => $detection['rule'] ?? null,
|
||||
'detection_type' => $detection['type'],
|
||||
'severity' => $detection['severity'],
|
||||
'confidence_score' => $has_content_score ? (int) $detection['content_score'] : null,
|
||||
'matched_rules' => ! empty( $detection['matched_rules'] ) ? wp_json_encode( $detection['matched_rules'] ) : null,
|
||||
'analysed_at' => $has_content_score ? $now : null,
|
||||
'status' => self::STATUS_QUARANTINED,
|
||||
'quarantined_at' => $now,
|
||||
),
|
||||
array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
$id = (int) $wpdb->insert_id;
|
||||
|
||||
@@ -24,6 +24,7 @@ class Argus_Settings {
|
||||
'upload_scan_enabled' => true,
|
||||
'vuln_intel_enabled' => true,
|
||||
'geoip_rir_enabled' => true,
|
||||
'auto_update_all_enabled' => true,
|
||||
'static_cache_enabled' => false,
|
||||
'static_cache_ttl_secs' => 3600,
|
||||
'cache_stale_grace_secs' => 600,
|
||||
@@ -45,7 +46,15 @@ class Argus_Settings {
|
||||
'exceptions' => array(),
|
||||
'cloud_connected' => false,
|
||||
|
||||
'anis_enabled' => true,
|
||||
// Off by default -- ANIS Cloud shares blocked visitors' IP addresses
|
||||
// with an external service, so it requires the site owner's explicit
|
||||
// opt-in (Settings checkbox or the first-run admin notice) rather than
|
||||
// connecting silently on activation. WordPress.org's guidelines
|
||||
// require clear consent before any such external connection; this is
|
||||
// still zero-configuration in the sense that mattered before -- there
|
||||
// is no server address, license key, or tier field to fill in, only
|
||||
// a single "enable" action.
|
||||
'anis_enabled' => false,
|
||||
'anis_base_url' => 'https://anis.weboria.eu',
|
||||
'anis_license_key' => '',
|
||||
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Argus_Update_Client {
|
||||
|
||||
const CHECK_INTERVAL_SECS = 6 * HOUR_IN_SECONDS;
|
||||
const LOCK_TRANSIENT = 'argus_wpd_update_check_lock';
|
||||
|
||||
const LAST_CHECK_OPTION = 'argus_wpd_update_last_check';
|
||||
const MANIFEST_ETAG_OPTION = 'argus_wpd_update_manifest_etag';
|
||||
const PENDING_MANIFEST_OPTION = 'argus_wpd_update_pending_manifest';
|
||||
const LAST_SUCCESS_OPTION = 'argus_wpd_update_last_success';
|
||||
const LAST_FAILURE_OPTION = 'argus_wpd_update_last_failure';
|
||||
|
||||
public static function manifest_url() {
|
||||
return defined( 'ARGUS_WPD_UPDATE_MANIFEST_URL' ) ? ARGUS_WPD_UPDATE_MANIFEST_URL : '';
|
||||
}
|
||||
|
||||
public static function public_key() {
|
||||
return defined( 'ARGUS_WPD_UPDATE_PUBLIC_KEY' ) ? ARGUS_WPD_UPDATE_PUBLIC_KEY : '';
|
||||
}
|
||||
|
||||
public static function is_configured() {
|
||||
return '' !== self::manifest_url() && '' !== self::public_key();
|
||||
}
|
||||
|
||||
public static function maybe_check() {
|
||||
if ( ! self::is_configured() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last_check = (int) get_option( self::LAST_CHECK_OPTION, 0 );
|
||||
if ( ( $last_check + self::CHECK_INTERVAL_SECS ) > time() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( false !== get_transient( self::LOCK_TRANSIENT ) ) {
|
||||
return;
|
||||
}
|
||||
set_transient( self::LOCK_TRANSIENT, 1, 4 * MINUTE_IN_SECONDS );
|
||||
|
||||
self::check_now();
|
||||
|
||||
delete_transient( self::LOCK_TRANSIENT );
|
||||
}
|
||||
|
||||
public static function check_now() {
|
||||
if ( ! self::is_configured() ) {
|
||||
return array( 'success' => false, 'message' => __( 'No update source is configured.', 'argus-wordpress-defence' ), 'update_available' => false );
|
||||
}
|
||||
|
||||
update_option( self::LAST_CHECK_OPTION, time(), false );
|
||||
|
||||
$args = array( 'timeout' => 15 );
|
||||
$etag = get_option( self::MANIFEST_ETAG_OPTION, '' );
|
||||
if ( $etag ) {
|
||||
$args['headers'] = array( 'If-None-Match' => $etag );
|
||||
}
|
||||
|
||||
$response = wp_remote_get( self::manifest_url(), $args );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return self::record_failure( __( 'Could not reach the update server.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 304 === $code ) {
|
||||
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => false );
|
||||
}
|
||||
if ( 200 !== $code ) {
|
||||
return self::record_failure( sprintf( 'Update server returned HTTP %d.', $code ) );
|
||||
}
|
||||
|
||||
$new_etag = wp_remote_retrieve_header( $response, 'etag' );
|
||||
if ( $new_etag ) {
|
||||
update_option( self::MANIFEST_ETAG_OPTION, $new_etag, false );
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$manifest = json_decode( $body, true );
|
||||
if ( ! is_array( $manifest ) ) {
|
||||
return self::record_failure( __( 'Update manifest was not valid JSON.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
return self::process_manifest( $manifest );
|
||||
}
|
||||
|
||||
public static function process_manifest( array $manifest ) {
|
||||
|
||||
if ( ! self::verify_signature( $manifest ) ) {
|
||||
return self::record_failure( __( 'Update manifest failed signature verification.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$version = $manifest['version'] ?? '';
|
||||
if ( '' === $version ) {
|
||||
return self::record_failure( __( 'Update manifest is missing a version.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
if ( version_compare( $version, ARGUS_WPD_VERSION, '<=' ) ) {
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => false );
|
||||
}
|
||||
|
||||
$last_installed = get_option( self::LAST_SUCCESS_OPTION, array() );
|
||||
if ( ! empty( $last_installed['version'] ) && version_compare( $version, $last_installed['version'], '<=' ) ) {
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => false );
|
||||
}
|
||||
|
||||
global $wp_version;
|
||||
if ( ! empty( $manifest['min_php'] ) && version_compare( PHP_VERSION, $manifest['min_php'], '<' ) ) {
|
||||
return self::record_failure( sprintf( 'Update %s requires PHP %s or newer.', $version, $manifest['min_php'] ) );
|
||||
}
|
||||
if ( ! empty( $manifest['min_wp'] ) && version_compare( $wp_version, $manifest['min_wp'], '<' ) ) {
|
||||
return self::record_failure( sprintf( 'Update %s requires WordPress %s or newer.', $version, $manifest['min_wp'] ) );
|
||||
}
|
||||
|
||||
update_option( self::PENDING_MANIFEST_OPTION, $manifest, false );
|
||||
|
||||
if ( ! empty( $manifest['critical'] ) ) {
|
||||
|
||||
return self::install_update( $manifest );
|
||||
}
|
||||
|
||||
return array( 'success' => true, 'message' => '', 'update_available' => true );
|
||||
}
|
||||
|
||||
public static function verify_signature( array $manifest ) {
|
||||
if ( empty( $manifest['signature'] ) ) {
|
||||
return false;
|
||||
}
|
||||
$signature = base64_decode( $manifest['signature'], true );
|
||||
if ( false === $signature || SODIUM_CRYPTO_SIGN_BYTES !== strlen( $signature ) ) {
|
||||
return false;
|
||||
}
|
||||
$public_key_b64 = self::public_key();
|
||||
if ( '' === $public_key_b64 ) {
|
||||
return false;
|
||||
}
|
||||
$public_key = base64_decode( $public_key_b64, true );
|
||||
if ( false === $public_key ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$canonical = self::canonical_payload( $manifest );
|
||||
return sodium_crypto_sign_verify_detached( $signature, $canonical, $public_key );
|
||||
}
|
||||
|
||||
protected static function canonical_payload( array $manifest ) {
|
||||
$ordered = array();
|
||||
foreach ( array( 'version', 'released_at', 'package_url', 'sha256', 'min_php', 'min_wp', 'critical' ) as $key ) {
|
||||
$ordered[ $key ] = $manifest[ $key ] ?? null;
|
||||
}
|
||||
return wp_json_encode( $ordered, JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
|
||||
public static function install_update( array $manifest, $target_dir = null ) {
|
||||
$target_dir = $target_dir ?: ( WP_PLUGIN_DIR . '/argus-wordpress-defence' );
|
||||
$version = $manifest['version'];
|
||||
|
||||
$upgrade_dir = trailingslashit( wp_get_upload_dir()['basedir'] ) . 'argus-wpd-data/update-staging';
|
||||
if ( ! is_dir( $upgrade_dir ) && ! wp_mkdir_p( $upgrade_dir ) ) {
|
||||
return self::record_failure( __( 'Could not prepare the update staging directory.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$package_path = trailingslashit( $upgrade_dir ) . 'package-' . $version . '-' . bin2hex( random_bytes( 4 ) ) . '.zip';
|
||||
$response = wp_remote_get( $manifest['package_url'], array( 'timeout' => 120, 'stream' => true, 'filename' => $package_path ) );
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
@unlink( $package_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
||||
return self::record_failure( __( 'Could not download the update package.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
if ( ! hash_equals( $manifest['sha256'], hash_file( 'sha256', $package_path ) ) ) {
|
||||
wp_delete_file( $package_path );
|
||||
return self::record_failure( __( 'Downloaded package hash did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$extract_root = trailingslashit( $upgrade_dir ) . 'extract-' . $version . '-' . bin2hex( random_bytes( 4 ) );
|
||||
if ( ! class_exists( 'ZipArchive' ) ) {
|
||||
wp_delete_file( $package_path );
|
||||
return self::record_failure( __( 'The PHP zip extension is not available -- cannot install the update.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $package_path ) || true !== $zip->extractTo( $extract_root ) ) {
|
||||
wp_delete_file( $package_path );
|
||||
return self::record_failure( __( 'Could not extract the update package.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
$zip->close();
|
||||
wp_delete_file( $package_path );
|
||||
|
||||
$extracted_plugin_dir = trailingslashit( $extract_root ) . 'argus-wordpress-defence';
|
||||
$new_main_file = trailingslashit( $extracted_plugin_dir ) . 'argus-wordpress-defence.php';
|
||||
if ( ! file_exists( $new_main_file ) ) {
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Extracted package did not contain the expected plugin file.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$header_contents = file_get_contents( $new_main_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
if ( ! preg_match( '/Version:\s*([^\r\n]+)/', $header_contents, $m ) || trim( $m[1] ) !== $version ) {
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Extracted package version did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
$backup_dir = $target_dir . '-previous-' . time();
|
||||
$had_previous = is_dir( $target_dir );
|
||||
if ( $had_previous && ! rename( $target_dir, $backup_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Could not move the current plugin version aside for the update.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
if ( ! rename( $extracted_plugin_dir, $target_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
|
||||
if ( $had_previous ) {
|
||||
rename( $backup_dir, $target_dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
self::rrmdir( $extract_root );
|
||||
return self::record_failure( __( 'Could not activate the downloaded update -- rolled back to the previous version.', 'argus-wordpress-defence' ) );
|
||||
}
|
||||
|
||||
self::rrmdir( $extract_root );
|
||||
if ( $had_previous ) {
|
||||
self::rrmdir( $backup_dir );
|
||||
}
|
||||
|
||||
delete_option( self::PENDING_MANIFEST_OPTION );
|
||||
update_option(
|
||||
self::LAST_SUCCESS_OPTION,
|
||||
array( 'version' => $version, 'installed_at' => current_time( 'mysql', true ) ),
|
||||
false
|
||||
);
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => ! empty( $manifest['critical'] )
|
||||
? sprintf( __( 'A critical ARGUS security update was installed (%s).', 'argus-wordpress-defence' ), $version )
|
||||
: sprintf( __( 'ARGUS Defence was updated to version %s.', 'argus-wordpress-defence' ), $version ),
|
||||
'update_available' => false,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function record_failure( $message ) {
|
||||
update_option(
|
||||
self::LAST_FAILURE_OPTION,
|
||||
array( 'message' => $message, 'at' => current_time( 'mysql', true ) ),
|
||||
false
|
||||
);
|
||||
Argus_Events::record( 'update_check_failed', 'medium', $message, array() );
|
||||
return array( 'success' => false, 'message' => $message, 'update_available' => false );
|
||||
}
|
||||
|
||||
protected static function rrmdir( $dir ) {
|
||||
if ( ! is_dir( $dir ) ) {
|
||||
return;
|
||||
}
|
||||
$items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST );
|
||||
foreach ( $items as $item ) {
|
||||
$item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
||||
}
|
||||
|
||||
public static function status() {
|
||||
return array(
|
||||
'current_version' => ARGUS_WPD_VERSION,
|
||||
'configured' => self::is_configured(),
|
||||
'last_check' => get_option( self::LAST_CHECK_OPTION, 0 ),
|
||||
'last_success' => get_option( self::LAST_SUCCESS_OPTION, array() ),
|
||||
'last_failure' => get_option( self::LAST_FAILURE_OPTION, array() ),
|
||||
'pending' => get_option( self::PENDING_MANIFEST_OPTION, array() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,12 @@ class Argus_WAF_Rules {
|
||||
}
|
||||
|
||||
$rules = array(
|
||||
|
||||
|
||||
// --- SQL Injection (CRS 942-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'sqli-union-select', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
'pattern' => '/\bunion\b[^\w]{1,20}\bselect\b/i' ),
|
||||
array( 'id' => 'sqli-classic-tautology', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
|
||||
'pattern' => '/(\%27|\'|\%22|")\s*(or|and)\s*(\%27|\'|\%22|")?[\d\w]+(\%27|\'|\%22|")?\s*=\s*(\%27|\'|\%22|")?[\d\w]+(\%27|\'|\%22|")?/i' ),
|
||||
array( 'id' => 'sqli-information-schema', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
'pattern' => '/information_schema|sysobjects|sysdatabases/i' ),
|
||||
@@ -23,41 +24,116 @@ class Argus_WAF_Rules {
|
||||
'pattern' => '/\b(sleep|benchmark|pg_sleep|waitfor\s+delay)\s*\(/i' ),
|
||||
array( 'id' => 'sqli-stacked-comment', 'category' => 'sql_injection', 'severity' => 'high',
|
||||
'pattern' => '/;\s*(drop|delete|update|insert)\s+(table|from|into)/i' ),
|
||||
array( 'id' => 'sqli-error-based-xpath', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
'pattern' => '/\b(extractvalue|updatexml)\s*\(/i' ),
|
||||
array( 'id' => 'sqli-file-read-write', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
'pattern' => '/\b(load_file|into\s+outfile|into\s+dumpfile)\s*\(?/i' ),
|
||||
array( 'id' => 'sqli-mssql-xp-cmdshell', 'category' => 'sql_injection', 'severity' => 'critical',
|
||||
'pattern' => '/\bxp_cmdshell\b/i' ),
|
||||
array( 'id' => 'sqli-hex-literal', 'category' => 'sql_injection', 'severity' => 'medium',
|
||||
'pattern' => '/\bunhex\s*\(|0x[0-9a-f]{12,}/i' ),
|
||||
array( 'id' => 'sqli-conditional-error', 'category' => 'sql_injection', 'severity' => 'high',
|
||||
'pattern' => '/\b(and|or)\s+\d+\s*=\s*\d+\s*(--|#|\/\*)/i' ),
|
||||
|
||||
// --- Cross-Site Scripting (CRS 941-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'xss-script-tag', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/<\s*script[\s>\/]/i' ),
|
||||
|
||||
array( 'id' => 'xss-event-handler', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/\bon(error|load|mouseover|click|focus)\s*=\s*["\']?[^"\'>]*[\(\{]/i' ),
|
||||
array( 'id' => 'xss-javascript-uri', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/javascript\s*:\s*[^\s]/i' ),
|
||||
array( 'id' => 'xss-svg-onload', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/<\s*svg[^>]*onload/i' ),
|
||||
array( 'id' => 'xss-embed-object-tag', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/<\s*(iframe|object|embed)\b[^>]*(src|data)\s*=/i' ),
|
||||
array( 'id' => 'xss-data-uri-html', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/data\s*:\s*text\/html\s*;\s*base64/i' ),
|
||||
array( 'id' => 'xss-vbscript-uri', 'category' => 'xss', 'severity' => 'high',
|
||||
'pattern' => '/vbscript\s*:/i' ),
|
||||
array( 'id' => 'xss-css-expression', 'category' => 'xss', 'severity' => 'medium',
|
||||
'pattern' => '/style\s*=\s*["\'][^"\']*expression\s*\(/i' ),
|
||||
array( 'id' => 'xss-dom-sink', 'category' => 'xss', 'severity' => 'medium',
|
||||
'pattern' => '/document\s*\.\s*(cookie|write|location)\s*[=\(]|window\s*\.\s*location\s*=/i' ),
|
||||
array( 'id' => 'xss-entity-encoded-script', 'category' => 'xss', 'severity' => 'medium',
|
||||
'pattern' => '/&(lt|#0*60|#x3c)\s*;?\s*script/i' ),
|
||||
|
||||
// --- Remote Code Execution / Command Injection (CRS 932-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'cmdi-shell-metachars', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/;\s*(cat|ls|whoami|id|uname|wget|curl)\s/i' ),
|
||||
array( 'id' => 'cmdi-backtick-subshell', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/`[^`]{1,80}`|\$\([^\)]{1,80}\)/' ),
|
||||
|
||||
// --- PHP Injection (CRS 933-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'phpi-eval-base64', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|str_rot13)\s*\(/i' ),
|
||||
'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13)\s*\(/i' ),
|
||||
array( 'id' => 'phpi-dangerous-function', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen)\s*\(/i' ),
|
||||
'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen|pcntl_exec|dl)\s*\(/i' ),
|
||||
array( 'id' => 'phpi-dynamic-eval-construct', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/\b(assert|create_function|call_user_func(_array)?)\s*\(/i' ),
|
||||
array( 'id' => 'phpi-preg-replace-eval-modifier', 'category' => 'rce', 'severity' => 'critical',
|
||||
'pattern' => '/preg_replace\s*\(\s*["\'][^"\']*\/[a-zA-Z]*e[a-zA-Z]*["\']/i' ),
|
||||
array( 'id' => 'phpi-tag-in-input', 'category' => 'rce', 'severity' => 'high',
|
||||
'pattern' => '/<\?php|<\?=/i' ),
|
||||
|
||||
// --- Java / JNDI Injection (CRS 944-XXX equivalent -- Log4Shell class) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'java-jndi-lookup', 'category' => 'java_injection', 'severity' => 'critical',
|
||||
'pattern' => '/\$\{jndi:(ldap|ldaps|rmi|dns|iiop|corba|nds|http|https):\/\//i' ),
|
||||
array( 'id' => 'java-el-injection', 'category' => 'java_injection', 'severity' => 'high',
|
||||
'pattern' => '/\$\{\s*(java|javax|org\.springframework|Runtime)\s*[\.\(]/' ),
|
||||
|
||||
// --- Local / Remote File Inclusion (CRS 930/931-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'lfi-dot-dot-slash', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/(\.\.\/|\.\.\\\\|%2e%2e%2f|%252e%252e%252f)/i' ),
|
||||
array( 'id' => 'lfi-sensitive-file', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/\/etc\/(passwd|shadow|hosts)\b|wp-config\.php/i' ),
|
||||
array( 'id' => 'lfi-php-wrapper', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/php:\/\/(filter|input|data)/i' ),
|
||||
'pattern' => '/(php|zip|phar|expect|glob)\s*:\/\/(filter|input|data|)/i' ),
|
||||
array( 'id' => 'rfi-remote-scheme', 'category' => 'file_access', 'severity' => 'high',
|
||||
'pattern' => '/^(https?|ftp):\/\/.+\.(php|txt)(\?|$)/i' ),
|
||||
array( 'id' => 'lfi-dotfile-path', 'category' => 'file_access', 'severity' => 'medium',
|
||||
'pattern' => '/\/\.(git|svn|env|ssh|hg)\/[\w\-\.\/]*(config|credentials|id_rsa)?/i',
|
||||
'sources' => array( 'HEADER:REQUEST_URI' ) ),
|
||||
array( 'id' => 'lfi-backup-file-request', 'category' => 'file_access', 'severity' => 'medium',
|
||||
'pattern' => '/\.(bak|old|swp|save|orig|sql|sql\.gz|tar\.gz)(\?|$)/i',
|
||||
'sources' => array( 'HEADER:REQUEST_URI' ) ),
|
||||
|
||||
// --- XML External Entity (CRS-equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'xxe-doctype-entity', 'category' => 'xxe', 'severity' => 'critical',
|
||||
'pattern' => '/<!DOCTYPE[^>]*\[.*<!ENTITY/is' ),
|
||||
array( 'id' => 'xxe-entity-system', 'category' => 'xxe', 'severity' => 'critical',
|
||||
'pattern' => '/<!ENTITY[^>]+SYSTEM\s+["\'](file|https?|ftp|expect|php):\/\//i' ),
|
||||
|
||||
// --- Server-Side Request Forgery (CRS-equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'ssrf-internal-target', 'category' => 'ssrf', 'severity' => 'high',
|
||||
'pattern' => '/^(https?|gopher|dict|ftp|ldap):\/\/(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|localhost|169\.254\.169\.254|\[::1\]|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})([:\/]|$)/i' ),
|
||||
|
||||
// --- Session Fixation (CRS 943-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'session-fixation-id-in-url', 'category' => 'session_fixation', 'severity' => 'low',
|
||||
'pattern' => '/[?&](PHPSESSID|JSESSIONID|ASPSESSIONID|ASP\.NET_SessionId)=/i',
|
||||
'sources' => array( 'HEADER:REQUEST_URI' ) ),
|
||||
|
||||
// --- Scanner / Reconnaissance Detection (CRS 913-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'scanner-known-tool-ua', 'category' => 'scanner_activity', 'severity' => 'medium',
|
||||
'pattern' => '/\b(sqlmap|nikto|acunetix|nessus|openvas|w3af|havij|nmap scripting engine|masscan|zgrab|dirbuster|gobuster|wfuzz|metasploit|zmeu|wpscan)\b/i',
|
||||
'sources' => array( 'HEADER:HTTP_USER_AGENT' ) ),
|
||||
|
||||
// --- Protocol Anomaly (CRS 920-XXX equivalent) -------------------------------------------------
|
||||
|
||||
array( 'id' => 'proto-null-byte', 'category' => 'protocol_anomaly', 'severity' => 'medium',
|
||||
'pattern' => '/%00/' ),
|
||||
array( 'id' => 'proto-double-encoding', 'category' => 'protocol_anomaly', 'severity' => 'low',
|
||||
'pattern' => '/%25(2e|2f|5c)/i' ),
|
||||
array( 'id' => 'proto-crlf-injection', 'category' => 'protocol_anomaly', 'severity' => 'high',
|
||||
'pattern' => '/%0[dD]%0[aA]/' ),
|
||||
);
|
||||
|
||||
return $rules;
|
||||
@@ -74,6 +150,10 @@ class Argus_WAF_Rules {
|
||||
$decoded = rawurldecode( $value );
|
||||
|
||||
foreach ( self::corpus() as $rule ) {
|
||||
if ( ! empty( $rule['sources'] ) && ! in_array( $source, $rule['sources'], true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = preg_match( $rule['pattern'], $decoded, $m ) ? $m : ( preg_match( $rule['pattern'], $value, $m ) ? $m : null );
|
||||
if ( null === $matched ) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user