diff --git a/README.md b/README.md index 8375306..be56516 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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:** 7.23.0 +**Version:** 7.23.1 **Requires:** WordPress 6.0+, PHP 7.4+ **License:** GPLv2 or later @@ -43,11 +43,12 @@ release is not it). - **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 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. +- **Automatic updates for ARGUS itself are opt-in, not on by default.** The signed-update + mechanism is real and live (this release's own manifest is signed and hosted in this + repository) -- add two constants to `wp-config.php` to turn it on. See + [Updating](#updating) below for the exact lines and how to verify it's working. + Automatic updates for *other* plugins/themes on your site, if you enable that option in + Settings, use WordPress's own built-in update system and work without any extra setup. ## Installation @@ -63,11 +64,23 @@ sidebar with an overview of your site's protection status. ## Updating -Automatic in-dashboard updates require an update channel to be configured for your -deployment (`ARGUS_WPD_UPDATE_MANIFEST_URL` and `ARGUS_WPD_UPDATE_PUBLIC_KEY` in -`wp-config.php`). Without one configured, update the plugin the same way you installed -it: download the new release ZIP and upload it again through **Plugins → Add New → -Upload Plugin** — WordPress will offer to replace the existing version. +**Automatic** — add these two lines to `wp-config.php` (above the +`/* That's all, stop editing! */` line): + +```php +define( 'ARGUS_WPD_UPDATE_MANIFEST_URL', 'https://git-cloud.weboria.eu/Weboria/argus-wp-defence/raw/branch/main/manifest.json' ); +define( 'ARGUS_WPD_UPDATE_PUBLIC_KEY', 'jBQFZLG+fvtW9y7kmhJa4BTBw8u3lmxbgOcF2ptEhFs=' ); +``` + +From then on ARGUS checks for updates every few hours and installs anything newer +automatically, after verifying its Ed25519 signature and SHA-256 checksum. To check +immediately instead of waiting: **ARGUS Defence → Settings → Automatic Security Updates → +Check for Updates Now**, which also shows the current status (last check, last successful +update, last failure). + +**Manual** — download the new release ZIP and upload it through **Plugins → Add New → +Upload Plugin** — WordPress will offer to replace the existing version. Works with or +without the automatic option configured. ## Uninstalling diff --git a/argus-wordpress-defence.php b/argus-wordpress-defence.php index a18e5fe..5afe57d 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: 7.23.0 + * Version: 7.23.1 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: ARGUS @@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'ARGUS_WPD_VERSION', '7.23.0' ); +define( 'ARGUS_WPD_VERSION', '7.23.1' ); define( 'ARGUS_WPD_FILE', __FILE__ ); define( 'ARGUS_WPD_DIR', plugin_dir_path( __FILE__ ) ); define( 'ARGUS_WPD_URL', plugin_dir_url( __FILE__ ) ); diff --git a/bin/build-release.sh b/bin/build-release.sh index e286dd0..dd41fbc 100755 --- a/bin/build-release.sh +++ b/bin/build-release.sh @@ -8,12 +8,20 @@ # the result against an explicit allowlist before packaging, failing # closed on anything unexpected rather than silently shipping it. # -# Usage: bin/build-release.sh [git-ref] (defaults to HEAD) +# Usage: bin/build-release.sh [git-ref] [channel] +# channel: "self" (default) -- the self-distributed release (Gitea +# releases page), includes the signed self-update client, since +# nothing prohibits it outside WordPress.org. +# "wporg" -- strips the self-update client too (and the +# license/trial system, stripped either way) -- required before +# any submission to the WordPress.org Plugin Directory, which +# prohibits a plugin using any update channel but its own. # set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REF="${1:-HEAD}" +CHANNEL="${2:-self}" BUILD_DIR="$(mktemp -d)" STAGE_DIR="${BUILD_DIR}/argus-wordpress-defence" OUT_DIR="${REPO_ROOT}/dist" @@ -35,13 +43,18 @@ rm -rf \ "${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" +if [ "${CHANNEL}" = "wporg" ]; then + # 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. + echo "==> [wporg channel] Removing the self-update client (WordPress.org must be the only update channel)" + rm -f "${STAGE_DIR}/includes/class-argus-update-client.php" +elif [ "${CHANNEL}" != "self" ]; then + echo "==> BUILD FAILED: unknown channel '${CHANNEL}' (expected 'self' or 'wporg')" + exit 1 +fi # WordPress.org explicitly prohibits trialware -- a plugin submitted to the # directory must stay genuinely, permanently functional with no license diff --git a/bin/sign-manifest.php b/bin/sign-manifest.php new file mode 100644 index 0000000..c874b4b --- /dev/null +++ b/bin/sign-manifest.php @@ -0,0 +1,81 @@ + \ + * --version=7.23.1 \ + * --package-url=https://.../argus-wordpress-defence-7.23.1.zip \ + * --sha256= \ + * [--min-php=7.4] [--min-wp=6.0] [--critical] \ + * [--out=manifest.json] + * + * The signed payload's field set and order (version, released_at, + * package_url, sha256, min_php, min_wp, critical) MUST exactly match + * Argus_Update_Client::canonical_payload() -- any drift and every + * signature this produces fails verification client-side. + */ + +$args = array(); +foreach ( $argv as $arg ) { + if ( 0 === strpos( $arg, '--' ) ) { + $parts = explode( '=', substr( $arg, 2 ), 2 ); + $args[ $parts[0] ] = $parts[1] ?? true; + } +} + +function required( $args, $key ) { + if ( empty( $args[ $key ] ) ) { + fwrite( STDERR, "Missing required --{$key}\n" ); + exit( 1 ); + } + return $args[ $key ]; +} + +$private_key_b64 = required( $args, 'private-key' ); +$version = required( $args, 'version' ); +$package_url = required( $args, 'package-url' ); +$sha256 = required( $args, 'sha256' ); +$min_php = $args['min-php'] ?? '7.4'; +$min_wp = $args['min-wp'] ?? '6.0'; +$critical = ! empty( $args['critical'] ); +$released_at = gmdate( 'c' ); +$out = $args['out'] ?? ( dirname( __DIR__ ) . '/manifest.json' ); + +if ( 64 !== strlen( $sha256 ) || ! ctype_xdigit( $sha256 ) ) { + fwrite( STDERR, "--sha256 must be a 64-character hex string (run: sha256sum )\n" ); + exit( 1 ); +} + +$private_key = base64_decode( $private_key_b64, true ); +if ( false === $private_key || SODIUM_CRYPTO_SIGN_SECRETKEYBYTES !== strlen( $private_key ) ) { + fwrite( STDERR, "--private-key is not a valid base64-encoded Ed25519 secret key\n" ); + exit( 1 ); +} + +// Must match Argus_Update_Client::canonical_payload() exactly -- same key +// set, same order, same JSON_UNESCAPED_SLASHES flag. +$ordered = array( + 'version' => $version, + 'released_at' => $released_at, + 'package_url' => $package_url, + 'sha256' => $sha256, + 'min_php' => $min_php, + 'min_wp' => $min_wp, + 'critical' => $critical, +); +$canonical = json_encode( $ordered, JSON_UNESCAPED_SLASHES ); + +$signature = sodium_crypto_sign_detached( $canonical, $private_key ); +$manifest = $ordered; +$manifest['signature'] = base64_encode( $signature ); + +file_put_contents( $out, json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" ); + +echo "Wrote {$out}\n"; +echo " version: {$version}\n"; +echo " package_url: {$package_url}\n"; +echo " sha256: {$sha256}\n"; +echo " critical: " . ( $critical ? 'true' : 'false' ) . "\n"; diff --git a/includes/class-argus-update-client.php b/includes/class-argus-update-client.php new file mode 100644 index 0000000..bb128d2 --- /dev/null +++ b/includes/class-argus-update-client.php @@ -0,0 +1,275 @@ + 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 ); + + // Any newer, compatible, signature-verified version is installed -- + // 'critical' is informational only (used in the success message and + // by Argus_Events severity), not an install gate. It used to be: a + // non-critical manifest was recorded as "update_available" and left + // there permanently, since nothing else ever called install_update() + // for it -- no button, no cron path, nothing. That made "automatic + // updates" only ever true for updates someone remembered to flag + // critical, which defeats the actual feature. + return self::install_update( $manifest ); + } + + 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/manifest.json b/manifest.json new file mode 100644 index 0000000..e83b598 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "version": "7.23.1", + "released_at": "2026-08-09T19:17:57+00:00", + "package_url": "https://git-cloud.weboria.eu/Weboria/argus-wp-defence/releases/download/v7.23.1/argus-wordpress-defence-7.23.1.zip", + "sha256": "22e4e75f6ea0933787a8e3f63c9c8f99da97df36529354b116af730eaa34c38a", + "min_php": "7.4", + "min_wp": "6.0", + "critical": false, + "signature": "68j0L5KMYY+10s9NmhQCeNr80+13ZMs+fj8ynW3ov9Nst64+Wc+e1nqE3Y0TlNKBj5fHqty1Fz0HBT14pXcwCg==" +} diff --git a/readme.txt b/readme.txt index 4ba9d6e..21147e1 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: security, firewall, malware, vulnerability, backup Requires at least: 6.0 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 7.23.0 +Stable tag: 7.23.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -53,6 +53,11 @@ dashboard whenever a new version is available, and you can update it in one clic == Changelog == += 7.23.1 = +* Automatic updates: fixed a bug where a normal (non-critical) update would be detected and + shown as available, but never actually installed -- now any newer, compatible, verified + update installs automatically, the same way updates to other plugins already do. + = 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).