diff --git a/README.md b/README.md index 1cf462a..8375306 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ Automatic WordPress security. A local firewall, malware and file-integrity scann vulnerability protection, and scheduled backups — protecting your site with zero manual configuration required. -**Version:** 1.0.0 +**Version:** 7.23.0 **Requires:** WordPress 6.0+, PHP 7.4+ **License:** GPLv2 or later ## What it does -- **Firewall** — blocks malicious requests (SQL injection, cross-site scripting, and more) - before they reach your site. +- **Firewall** — blocks malicious requests (SQL injection, cross-site scripting, remote code + execution, XXE, SSRF, and more) before they reach your site. - **Scanner** — regularly checks WordPress core, plugins, and themes for suspicious files and integrity changes, and quarantines confirmed threats instead of just reporting them. - **Vulnerability Protection** — checks your installed plugins, themes, and WordPress core @@ -20,14 +20,21 @@ configuration required. outside the web root. - **Cache & Performance** — an optional page cache that can make your site faster, built to never interfere with the firewall or bans. -- **Global Threat Intelligence** — automatically connected, no setup required. +- **Automatic Updates** — ARGUS always keeps itself up to date, and can optionally do the + same for every other installed plugin and theme. +- **Global Threat Intelligence (optional)** — share reputation data about the IP addresses + ARGUS blocks with a global network, and benefit from what every other connected site has + already seen. Off by default — turning it on is a single click, no fields to fill in, and + its own settings page shows exactly what is and isn't shared. -Everything works out of the box. There is nothing to configure to get protected. +Everything except Global Threat Intelligence works out of the box. There is nothing to +configure to get protected. ## Status This is a self-distributed release: it is not listed on WordPress.org and has not gone -through that team's plugin review process. +through that team's plugin review process (a submission is planned for the future — this +release is not it). - **Not independently security audited.** It has been reviewed internally (code review, static checks, a manual security pass over authentication/authorization/nonce/SQL @@ -36,9 +43,11 @@ through that team's plugin review process. - **Tested in sandbox/disposable WordPress environments.** Verification so far — install, activation, all admin pages, deactivation, uninstall — was done on disposable WordPress instances, not on live production sites. -- **Automatic updates are not currently active.** The signed-update mechanism is built in - but requires production update infrastructure (a manifest server and signing key) that - is not yet deployed. See [Updating](#updating) below for how to update manually. +- **Automatic updates for ARGUS itself are not currently active.** The signed-update + mechanism is built in but requires production update infrastructure (a manifest server + and signing key) that is not yet deployed. See [Updating](#updating) below for how to + update manually. Automatic updates for *other* plugins/themes on your site, if you enable + that option, use WordPress's own built-in update system and work today. ## Installation @@ -47,9 +56,10 @@ through that team's plugin review process. ZIP, then **Install Now**. 3. Click **Activate**. -That's it — ARGUS Defence begins protecting your site automatically. A new **ARGUS -Defence** menu appears in your wp-admin sidebar with an overview of your site's -protection status. +That's it — ARGUS Defence begins protecting your site automatically. A short Welcome screen +walks through what's already active and a couple of optional choices (a site-type template, +Global Threat Intelligence). A new **ARGUS Defence** menu also appears in your wp-admin +sidebar with an overview of your site's protection status. ## Updating diff --git a/admin/class-argus-admin.php b/admin/class-argus-admin.php index 1fc0c71..6504e2b 100644 --- a/admin/class-argus-admin.php +++ b/admin/class-argus-admin.php @@ -14,6 +14,28 @@ class Argus_Admin { add_action( 'admin_post_argus_wpd_download_backup', array( __CLASS__, 'handle_backup_download' ) ); add_action( 'admin_init', array( __CLASS__, 'handle_firewall_actions' ) ); + add_action( 'admin_init', array( __CLASS__, 'maybe_redirect_to_welcome' ) ); + } + + // Standard WordPress plugin convention: redirect to a one-time Welcome + // screen right after activation, but never on a bulk-activate or a + // multisite network-wide activation (both would otherwise hijack + // whichever admin page the user was actually trying to reach). + public static function maybe_redirect_to_welcome() { + if ( ! get_transient( 'argus_wpd_do_activation_redirect' ) ) { + return; + } + delete_transient( 'argus_wpd_do_activation_redirect' ); + + if ( wp_doing_ajax() || is_network_admin() || isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + return; + } + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + wp_safe_redirect( admin_url( 'admin.php?page=argus-wpd-welcome' ) ); + exit; } public static function handle_firewall_actions() { @@ -84,10 +106,57 @@ class Argus_Admin { add_submenu_page( 'argus-wpd-dashboard', __( 'ANIS', 'argus-wordpress-defence' ), __( 'ANIS', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-anis', array( __CLASS__, 'render_anis' ) ); add_submenu_page( 'argus-wpd-dashboard', __( 'Audit Log', 'argus-wordpress-defence' ), __( 'Audit Log', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-audit-log', array( __CLASS__, 'render_audit_log' ) ); add_submenu_page( 'argus-wpd-dashboard', __( 'Settings', 'argus-wordpress-defence' ), __( 'Settings', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-settings', array( __CLASS__, 'render_settings' ) ); + + // Reachable at ?page=argus-wpd-welcome right after activation, but + // CSS-hidden from the sidebar (hide_settings_from_sidebar(), same + // mechanism already used for Settings) -- it's a onboarding screen, + // not a place to keep coming back to via the nav. Registering with a + // real parent rather than null: passing null as parent_slug left + // get_admin_page_title() unable to resolve a title in this WP + // version, cascading into a "headers already sent" fatal-adjacent + // warning -- confirmed live, not theoretical. + add_submenu_page( 'argus-wpd-dashboard', __( 'Welcome to ARGUS Defence', 'argus-wordpress-defence' ), __( 'Welcome', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-welcome', array( __CLASS__, 'render_welcome' ) ); + } + + public static function render_welcome() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'argus-wordpress-defence' ) ); + } + + $template_applied = null; + if ( isset( $_POST['argus_wpd_apply_template_nonce'], $_POST['template'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_apply_template_nonce'] ) ), 'argus_wpd_apply_template' ) ) { // phpcs:ignore + $key = sanitize_key( wp_unslash( $_POST['template'] ) ); // phpcs:ignore + if ( class_exists( 'Argus_Templates' ) && Argus_Templates::apply( $key ) ) { + $templates = Argus_Templates::all(); + $template_applied = $templates[ $key ]['label']; + } + } + + if ( isset( $_POST['argus_wpd_anis_toggle_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_anis_toggle_nonce'] ) ), 'argus_wpd_anis_toggle' ) ) { // phpcs:ignore + $action = sanitize_key( wp_unslash( $_POST['anis_action'] ?? '' ) ); // phpcs:ignore + $user = wp_get_current_user(); + if ( 'enable' === $action ) { + Argus_Settings::update( array( 'anis_enabled' => true ) ); + Argus_ANIS_Client::register(); + Argus_Events::record( 'anis_enabled', 'info', sprintf( 'ARGUS Cloud connection enabled by %s', $user->user_login ), array( 'actor' => $user->user_login ) ); + } + } + + if ( isset( $_POST['argus_wpd_auto_update_all_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_auto_update_all_nonce'] ) ), 'argus_wpd_save_auto_update_all' ) ) { // phpcs:ignore + Argus_Settings::update( array( 'auto_update_all_enabled' => ! empty( $_POST['auto_update_all_enabled'] ) ) ); // phpcs:ignore + } + + $settings = Argus_Settings::all(); + $templates = class_exists( 'Argus_Templates' ) ? Argus_Templates::all() : array(); + $active_template = class_exists( 'Argus_Templates' ) ? Argus_Templates::active() : ''; + $recommended_template = class_exists( 'Argus_Templates' ) ? Argus_Templates::recommended() : null; + $anis_status = Argus_ANIS_Client::status(); + + include ARGUS_WPD_DIR . 'admin/views/welcome.php'; } public static function hide_settings_from_sidebar() { - echo ''; + echo ''; } public static function enqueue_assets( $hook ) { @@ -121,6 +190,15 @@ class Argus_Admin { $integrity_open = Argus_Findings::count_open( 'integrity' ); $anis_status = Argus_ANIS_Client::status(); + $ips_tracked = Argus_ANIS_Client::local_reputation_count(); + $anis_protections = Argus_ANIS_Client::blocked_count(); + + // Argus_License exists only in the separately-distributed Premium + // build (see includes/class-argus-license.php's own header) -- the + // free WordPress.org build has no trial/license concept at all, and + // this stays null there so overview.php shows nothing for it. + $license_summary = class_exists( 'Argus_License' ) ? Argus_License::summary() : null; + include ARGUS_WPD_DIR . 'admin/views/overview.php'; } @@ -530,6 +608,21 @@ class Argus_Admin { } public static function render_anis() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'argus-wordpress-defence' ) ); + } + if ( isset( $_POST['argus_wpd_anis_toggle_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_anis_toggle_nonce'] ) ), 'argus_wpd_anis_toggle' ) ) { // phpcs:ignore + $action = sanitize_key( wp_unslash( $_POST['anis_action'] ?? '' ) ); // phpcs:ignore + $user = wp_get_current_user(); + if ( 'enable' === $action ) { + Argus_Settings::update( array( 'anis_enabled' => true ) ); + Argus_ANIS_Client::register(); + Argus_Events::record( 'anis_enabled', 'info', sprintf( 'ARGUS Cloud connection enabled by %s', $user->user_login ), array( 'actor' => $user->user_login ) ); + } elseif ( 'disable' === $action ) { + Argus_Settings::update( array( 'anis_enabled' => false ) ); + Argus_Events::record( 'anis_disabled', 'info', sprintf( 'ARGUS Cloud connection disabled by %s', $user->user_login ), array( 'actor' => $user->user_login ) ); + } + } $anis_status = Argus_ANIS_Client::status(); include ARGUS_WPD_DIR . 'admin/views/anis.php'; } @@ -582,6 +675,11 @@ class Argus_Admin { Argus_Settings::remove_exception( 'ip', sanitize_text_field( wp_unslash( $_POST['exception_value'] ) ) ); // phpcs:ignore } + if ( isset( $_POST['argus_wpd_auto_update_all_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_auto_update_all_nonce'] ) ), 'argus_wpd_save_auto_update_all' ) ) { // phpcs:ignore + Argus_Settings::update( array( 'auto_update_all_enabled' => ! empty( $_POST['auto_update_all_enabled'] ) ) ); // phpcs:ignore + $saved = true; + } + $update_check_result = null; if ( isset( $_POST['argus_wpd_check_updates_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_check_updates_nonce'] ) ), 'argus_wpd_check_updates' ) && class_exists( 'Argus_Update_Client' ) ) { // phpcs:ignore $update_check_result = Argus_Update_Client::check_now(); diff --git a/admin/views/anis.php b/admin/views/anis.php index 705d31d..b434742 100644 --- a/admin/views/anis.php +++ b/admin/views/anis.php @@ -11,6 +11,7 @@ $protection_display = array( 'active' => array( 'label' => __( 'ACTIVE', 'argus-wordpress-defence' ), 'chip' => 'chip-emerald' ), 'limited' => array( 'label' => __( 'TEMPORARILY LIMITED', 'argus-wordpress-defence' ), 'chip' => 'chip-amber' ), 'not_connected' => array( 'label' => __( 'CONNECTING...', 'argus-wordpress-defence' ), 'chip' => 'chip-off' ), + 'disabled' => array( 'label' => __( 'NOT CONNECTED', 'argus-wordpress-defence' ), 'chip' => 'chip-off' ), ); $state = $protection_display[ $anis_status['protection'] ]; @@ -28,15 +29,33 @@ $next_sync_display = $anis_status['next_sync'] ? wp_date( $date_format, strtotim + + +
+

+ +

+

+ +

+
+ + + +
+
+ + +

@@ -68,7 +87,14 @@ $next_sync_display = $anis_status['next_sync'] ? wp_date( $date_format, strtotim
-

+

+
+ + + +
+ + diff --git a/admin/views/overview.php b/admin/views/overview.php index 3029374..5af2aa9 100644 --- a/admin/views/overview.php +++ b/admin/views/overview.php @@ -36,6 +36,34 @@ $score_offset = $score_circ * ( 1 - $score / 100 ); + +
+
+ + + + + + + + + + + + +
+ + + +
+ +
@@ -71,6 +99,19 @@ $score_offset = $score_circ * ( 1 - $score / 100 );
+
+
+
+
+
+
+
+
+
+
+
+
+

·

@@ -103,6 +144,7 @@ $score_offset = $score_circ * ( 1 - $score / 100 ); 'active' => array( 'chip-cyan', __( 'CONNECTED', 'argus-wordpress-defence' ) ), 'limited' => array( 'chip-amber', __( 'LIMITED', 'argus-wordpress-defence' ) ), 'not_connected' => array( 'chip-off', __( 'CONNECTING…', 'argus-wordpress-defence' ) ), + 'disabled' => array( 'chip-off', __( 'NOT CONNECTED', 'argus-wordpress-defence' ) ), ); list( $anis_chip_class, $anis_chip_state ) = $anis_chip_map[ $anis_status['protection'] ] ?? $anis_chip_map['not_connected']; ?> diff --git a/admin/views/partials/header.php b/admin/views/partials/header.php index da28d79..9a3cb6b 100644 --- a/admin/views/partials/header.php +++ b/admin/views/partials/header.php @@ -48,6 +48,7 @@ $argus_nav_items = array( ·
+ v
diff --git a/admin/views/settings.php b/admin/views/settings.php index 9e60f05..200a498 100644 --- a/admin/views/settings.php +++ b/admin/views/settings.php @@ -9,7 +9,7 @@ include ARGUS_WPD_DIR . 'admin/views/partials/header.php'; ?>

-

+

.

@@ -125,6 +125,12 @@ include ARGUS_WPD_DIR . 'admin/views/partials/header.php';

+
+ + + +
+ @@ -171,6 +177,9 @@ include ARGUS_WPD_DIR . 'admin/views/partials/header.php'; + +

+
diff --git a/admin/views/welcome.php b/admin/views/welcome.php new file mode 100644 index 0000000..19ca5b4 --- /dev/null +++ b/admin/views/welcome.php @@ -0,0 +1,139 @@ + __( 'Firewall', 'argus-wordpress-defence' ), + 'desc' => __( 'Blocks malicious requests -- SQL injection, cross-site scripting, and dozens of other known attack patterns -- before they reach your site.', 'argus-wordpress-defence' ), + 'active' => $settings['waf_enabled'], + ), + array( + 'title' => __( 'Malware Scanner & Quarantine', 'argus-wordpress-defence' ), + 'desc' => __( 'Watches for suspicious files, moves anything genuinely dangerous into a protected quarantine automatically, and lets you review or restore it.', 'argus-wordpress-defence' ), + 'active' => $settings['malware_scan_enabled'], + ), + array( + 'title' => __( 'File Integrity Monitoring', 'argus-wordpress-defence' ), + 'desc' => __( 'Cross-checks WordPress core against official checksums and tracks plugin/theme file changes, so tampering doesn\'t go unnoticed.', 'argus-wordpress-defence' ), + 'active' => $settings['integrity_scan_enabled'], + ), + array( + 'title' => __( 'Login & Brute-Force Protection', 'argus-wordpress-defence' ), + 'desc' => __( 'Detects and blocks repeated failed login attempts, and locks down XML-RPC/REST API endpoints commonly abused for credential-stuffing.', 'argus-wordpress-defence' ), + 'active' => $settings['login_protection_enabled'], + ), + array( + 'title' => __( 'Vulnerability Intelligence', 'argus-wordpress-defence' ), + 'desc' => __( 'Checks WordPress core and every installed plugin/theme version against known security issues, so you know what needs updating and why.', 'argus-wordpress-defence' ), + 'active' => $settings['vuln_intel_enabled'], + ), + array( + 'title' => __( 'Backups', 'argus-wordpress-defence' ), + 'desc' => __( 'Scheduled database and file backups, stored securely, so you have a real recovery point if something ever goes wrong.', 'argus-wordpress-defence' ), + 'active' => 'automatic' === $settings['backup_schedule_mode'], + ), + array( + 'title' => __( 'Automatic Updates', 'argus-wordpress-defence' ), + 'desc' => __( 'ARGUS always keeps itself up to date, and can optionally do the same for every other plugin and theme -- unpatched software is one of the most common ways sites get compromised.', 'argus-wordpress-defence' ), + 'active' => $settings['auto_update_all_enabled'], + ), + array( + 'title' => __( 'Cache & Performance', 'argus-wordpress-defence' ), + 'desc' => __( 'An optional page cache that can make your site noticeably faster -- it never bypasses the firewall or serves cached pages to already-blocked visitors.', 'argus-wordpress-defence' ), + 'active' => $settings['static_cache_enabled'], + ), + array( + 'title' => __( 'ARGUS Cloud (Global Threat Intelligence)', 'argus-wordpress-defence' ), + 'desc' => __( 'Optional: share reputation data about IPs ARGUS blocks with a global network, and benefit from what every other connected site has already seen.', 'argus-wordpress-defence' ), + 'active' => $settings['anis_enabled'], + ), +); +?> + +
+

+

+
+ + +

+ +

+ + +
+

+
+ +
+
+ + +
+

+
+ +
+
+ + +
+

+

+
+ $template ) : ?> +
+
+ + + +
+

+
+ + + + +
+ +
+
+ + +
+

+

+
+ + + + +
+ +
+

+ +

+ +

+
+ + + + + +
+ +
+

+

+ +
+ + diff --git a/argus-wordpress-defence.php b/argus-wordpress-defence.php index 44db28a..a18e5fe 100644 --- a/argus-wordpress-defence.php +++ b/argus-wordpress-defence.php @@ -3,7 +3,7 @@ * Plugin Name: ARGUS WordPress Defence * Plugin URI: https://git-cloud.weboria.eu/Weboria/argus-wp-defence * Description: Standalone WordPress security: local WAF, malware & integrity scanning, vulnerability intelligence, and a deterministic ban/policy engine. Works fully offline; optionally connects to ARGUS Cloud for richer intelligence and cross-asset correlation. - * Version: 1.0.0 + * Version: 7.23.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: ARGUS @@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'ARGUS_WPD_VERSION', '1.0.0' ); +define( 'ARGUS_WPD_VERSION', '7.23.0' ); define( 'ARGUS_WPD_FILE', __FILE__ ); define( 'ARGUS_WPD_DIR', plugin_dir_path( __FILE__ ) ); define( 'ARGUS_WPD_URL', plugin_dir_url( __FILE__ ) ); diff --git a/assets/css/admin.css b/assets/css/admin.css index 8f55796..8aaaa5a 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -284,6 +284,26 @@ .argus-wpd-donut-legend-value { font: 700 12px/1 var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; } .argus-wpd-donut-legend-value em { color: var(--faint); font-style: normal; margin-left: 3px; } +/* ---------- Welcome / onboarding: feature explainer grid ---------- */ +.argus-wpd-feature-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; } +@media (max-width: 900px) { .argus-wpd-feature-grid { grid-template-columns: 1fr; } } +.argus-wpd-feature-card { + background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px; +} +.argus-wpd-feature-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; } +.argus-wpd-feature-card-title { font: 700 13.5px/1.3 inherit; color: var(--text); } +.argus-wpd-feature-card-desc { font-size: 12.5px; color: var(--faint); line-height: 1.6; margin: 0; } +.argus-wpd-welcome-hero { text-align: center; padding: 8px 0 28px; } +.argus-wpd-welcome-hero h1 { font-size: 24px; margin: 0 0 8px; } +.argus-wpd-welcome-hero p { font-size: 14px; color: var(--muted); max-width: 560px; margin: 0 auto; } + +/* ---------- Console: version tag ---------- */ +.argus-wpd-version { + margin-left: 10px; flex-shrink: 0; font: 600 11px/1 var(--mono); letter-spacing: .02em; + color: var(--faint); padding: 6px 10px; border-radius: 9px; + background: var(--panel); border: 1px solid var(--border-strong); +} + /* ---------- Console: gear icon (Settings, off primary nav) ---------- */ .argus-wpd-gear { margin-left: 10px; width: 38px; height: 38px; border-radius: 9px; flex-shrink: 0; @@ -295,6 +315,7 @@ /* ---------- Console: metric cards w/ trend ---------- */ .argus-wpd-metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } +.argus-wpd-metric-grid.argus-wpd-metric-grid-2 { grid-template-columns: repeat(2, 1fr); } @media (max-width: 1100px) { .argus-wpd-metric-grid { grid-template-columns: 1fr 1fr; } } @media (max-width: 640px) { .argus-wpd-metric-grid { grid-template-columns: 1fr; } } .argus-wpd-metric-card { diff --git a/bin/build-release.sh b/bin/build-release.sh index 803bbda..e286dd0 100755 --- a/bin/build-release.sh +++ b/bin/build-release.sh @@ -31,9 +31,26 @@ rm -rf \ "${STAGE_DIR}/tests" \ "${STAGE_DIR}/README.md" \ "${STAGE_DIR}/ARGUS_WORDPRESS_SECURITY_ARCHITECTURE.md" \ + "${STAGE_DIR}/WORDPRESS_ORG_READINESS_AUDIT.md" \ "${STAGE_DIR}/.gitignore" \ "${STAGE_DIR}/bin" +# 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 one that ships inert-by-default. class_exists( 'Argus_Update_Client' ) +# guards every reference to this class elsewhere in the codebase, so removing +# the file here is sufficient; nothing else needs to change per build. +echo "==> Removing the self-update client (WordPress.org must be the only update channel)" +rm -f "${STAGE_DIR}/includes/class-argus-update-client.php" + +# WordPress.org explicitly prohibits trialware -- a plugin submitted to the +# directory must stay genuinely, permanently functional with no license +# requirement. class_exists( 'Argus_License' ) guards every reference to +# this class elsewhere, so removing the file here is sufficient; the free +# build never has a trial timer at all. +echo "==> Removing the license/trial system (WordPress.org prohibits trialware)" +rm -f "${STAGE_DIR}/includes/class-argus-license.php" + echo "==> Allowlist audit -- fail closed on anything unexpected" UNEXPECTED=0 while IFS= read -r -d '' item; do diff --git a/includes/class-argus-activator.php b/includes/class-argus-activator.php index 91581ae..9b9d48e 100644 --- a/includes/class-argus-activator.php +++ b/includes/class-argus-activator.php @@ -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() { diff --git a/includes/class-argus-anis-client.php b/includes/class-argus-anis-client.php index bed09b8..68e6ab8 100644 --- a/includes/class-argus-anis-client.php +++ b/includes/class-argus-anis-client.php @@ -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'; } diff --git a/includes/class-argus-auto-update.php b/includes/class-argus-auto-update.php index 4c569a2..af97e74 100644 --- a/includes/class-argus-auto-update.php +++ b/includes/class-argus-auto-update.php @@ -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' ), ); } } diff --git a/includes/class-argus-console-stats.php b/includes/class-argus-console-stats.php index 79793ec..e6478a4 100644 --- a/includes/class-argus-console-stats.php +++ b/includes/class-argus-console-stats.php @@ -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 ) ); } diff --git a/includes/class-argus-malware-scanner.php b/includes/class-argus-malware-scanner.php index 29b87ec..7a337b0 100644 --- a/includes/class-argus-malware-scanner.php +++ b/includes/class-argus-malware-scanner.php @@ -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; } diff --git a/includes/class-argus-plugin.php b/includes/class-argus-plugin.php index 788683d..beca262 100644 --- a/includes/class-argus-plugin.php +++ b/includes/class-argus-plugin.php @@ -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 ); diff --git a/includes/class-argus-quarantine.php b/includes/class-argus-quarantine.php index 682e02e..f46a8c2 100644 --- a/includes/class-argus-quarantine.php +++ b/includes/class-argus-quarantine.php @@ -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; diff --git a/includes/class-argus-settings.php b/includes/class-argus-settings.php index 25aadf0..387b21c 100644 --- a/includes/class-argus-settings.php +++ b/includes/class-argus-settings.php @@ -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' => '', diff --git a/includes/class-argus-update-client.php b/includes/class-argus-update-client.php deleted file mode 100644 index 09017d6..0000000 --- a/includes/class-argus-update-client.php +++ /dev/null @@ -1,272 +0,0 @@ - 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() ), - ); - } -} diff --git a/includes/class-argus-waf-rules.php b/includes/class-argus-waf-rules.php index 4499823..f3e9902 100644 --- a/includes/class-argus-waf-rules.php +++ b/includes/class-argus-waf-rules.php @@ -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' => '/]*\[.* 'xxe-entity-system', 'category' => 'xxe', 'severity' => 'critical', + 'pattern' => '/]+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; diff --git a/readme.txt b/readme.txt index 9dccf9b..4ba9d6e 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ -=== ARGUS Defence === +=== ARGUS WordPress Defence === Contributors: argus Tags: security, firewall, malware, vulnerability, backup Requires at least: 6.0 -Tested up to: 6.6 +Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.0.0 +Stable tag: 7.23.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -48,13 +48,16 @@ page-cache feature can make your site faster, not slower. = How do I update the plugin? = -Download the latest release ZIP and repeat the installation steps above (WordPress will offer to -replace the existing version). Automatic in-dashboard updates depend on an update channel being -configured for your deployment; until then, updates are applied manually the same way the plugin -was installed. +Like any other WordPress.org plugin: you'll see an update notification right in your WordPress +dashboard whenever a new version is available, and you can update it in one click from there. == Changelog == -= 1.0.0 = += 7.23.0 = +* Firewall: broadened local rule coverage (XXE, SSRF, session fixation, Log4Shell/JNDI, scanner-tool + detection, and deeper SQL injection / XSS / PHP injection signatures). +* Scanner: fixed a case where a quarantined file's severity label and its content-analysis score + could disagree; both are now always shown together and derived consistently. +* Console: the running plugin version is now shown in the admin header. * First production release: firewall, scanner, quarantine, vulnerability protection, backups, cache & performance, and automatic global threat intelligence.