ARGUS WordPress Defence 7.23.1 -- real, working automatic updates

Fixes a real bug: a normal (non-critical) update was previously
detected and shown as available, but nothing ever actually installed
it -- there was no button, cron path, or any other code that did.
Automatic updates only ever worked for updates flagged critical, which
isn't what "automatic" means. Now any newer, compatible,
signature-verified update installs on its own.

This is also the first release where the self-update mechanism itself
ships in this self-distributed channel -- it was unconditionally
excluded from every previous build (a WordPress.org-only restriction
that doesn't apply here, since this channel isn't WordPress.org).

manifest.json in this repo is the real, live update manifest: signed
with Ed25519 (public key documented in README.md's Updating section),
pointing at this exact release's ZIP and its real SHA-256. Verified
end-to-end before publishing -- not just "the code looks right": ran a
full real update cycle (an older installed version checking this
manifest, downloading this exact package, verifying its signature and
hash, replacing itself, and the site continuing to work with zero
errors afterward) using the actual signing key and the actual
package this commit ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ARGUS
2026-08-09 19:19:40 +00:00
co-authored by Claude Sonnet 5
parent 35efd3e485
commit 8a6c9ca4de
7 changed files with 419 additions and 22 deletions
+24 -11
View File
@@ -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
+2 -2
View File
@@ -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__ ) );
+21 -8
View File
@@ -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
+81
View File
@@ -0,0 +1,81 @@
<?php
/**
* Signs an update manifest for Argus_Update_Client (includes/class-argus-update-client.php).
* Never shipped in any release ZIP -- a dev/release-time tool only.
*
* Usage:
* php bin/sign-manifest.php \
* --private-key=<base64 Ed25519 secret key> \
* --version=7.23.1 \
* --package-url=https://.../argus-wordpress-defence-7.23.1.zip \
* --sha256=<hex sha256 of that exact zip> \
* [--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 <zip>)\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";
+275
View File
@@ -0,0 +1,275 @@
<?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 );
// 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() ),
);
}
}
+10
View File
@@ -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=="
}
+6 -1
View File
@@ -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).