Real changes since 1.0.0, all live-verified before this release: - Firewall: rule corpus expanded 19 -> 43 rules, real OWASP-CRS-equivalent coverage (XXE, SSRF, session fixation, Log4Shell/JNDI, scanner-tool detection, deeper SQL injection/XSS/PHP-injection). - Fixed a real bug: a quarantined file's severity badge and its content analysis score could disagree with no explanation (e.g. a benign file showing CRITICAL next to Score 0); both are now derived consistently and shown together. - ARGUS now always keeps itself updated, and can optionally do the same for every other installed plugin and theme (Settings, on by default) -- uses WordPress's own native update system, nothing custom. - Global Threat Intelligence is now opt-in, not automatic -- a single click on its own page, with an honest, specific description of exactly what's shared (an IP address, a reason code, a confidence score, a country). Previously connected automatically on activation. - New first-run Welcome screen after activation: confirms what's already protecting the site, and surfaces the few real optional choices in one place. - Dashboard: running version now visible in the header; new "IPs Tracked" and "ANIS Protections" metrics. - Full WordPress.org Plugin Directory readiness audit performed against this codebase. Two real compliance issues found and fixed (see above: Global Threat Intelligence's default, and the self-update mechanism, which is excluded from this build entirely -- WordPress.org prohibits a plugin from using any update channel other than its own, even an inert one). This release is still self-distributed, not a WordPress.org submission -- that remains a future step. Verified before publishing: this exact ZIP was installed, activated (14 admin pages loaded clean, zero PHP errors/warnings), and uninstalled (zero leftover database tables or options) in a fresh, disposable WordPress + MySQL environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
361 lines
12 KiB
PHP
361 lines
12 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Quarantine {
|
|
|
|
const STATUS_QUARANTINED = 'quarantined';
|
|
const STATUS_RESTORED = 'restored';
|
|
const STATUS_DELETED = 'deleted';
|
|
|
|
const LOCKDOWN_STATUS_OPTION = 'argus_wpd_quarantine_lockdown_status';
|
|
const HTACCESS_MARKER = 'ARGUS WordPress Defence -- quarantine store, deny all direct access';
|
|
|
|
public static function quarantine_dir() {
|
|
$uploads = wp_get_upload_dir();
|
|
return trailingslashit( $uploads['basedir'] ?? '' ) . 'argus-wpd-data/quarantine';
|
|
}
|
|
|
|
public static function ensure_quarantine_lockdown() {
|
|
$dir = self::quarantine_dir();
|
|
|
|
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_mkdir', false );
|
|
return false;
|
|
}
|
|
if ( ! is_writable( $dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false );
|
|
return false;
|
|
}
|
|
|
|
$htaccess = trailingslashit( $dir ) . '.htaccess';
|
|
$rule = "# " . self::HTACCESS_MARKER . "\nRequire all denied\n";
|
|
$existing = file_exists( $htaccess ) ? file_get_contents( $htaccess ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
if ( false === $existing || false === strpos( $existing, 'Require all denied' ) ) {
|
|
$written = file_put_contents( $htaccess, $rule ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $written ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false );
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$index = trailingslashit( $dir ) . 'index.php';
|
|
if ( ! file_exists( $index ) ) {
|
|
file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
}
|
|
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false );
|
|
return true;
|
|
}
|
|
|
|
public static function lockdown_status() {
|
|
return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' );
|
|
}
|
|
|
|
public static function quarantine_file( $abs_path, $rel_path, array $detection ) {
|
|
global $wpdb;
|
|
|
|
if ( is_link( $abs_path ) ) {
|
|
return false;
|
|
}
|
|
|
|
if ( ! self::ensure_quarantine_lockdown() ) {
|
|
return false;
|
|
}
|
|
|
|
if ( ! is_readable( $abs_path ) ) {
|
|
return false;
|
|
}
|
|
|
|
$hash = hash_file( 'sha256', $abs_path );
|
|
$size = filesize( $abs_path );
|
|
$mtime = gmdate( 'Y-m-d H:i:s', filemtime( $abs_path ) );
|
|
$perms = substr( sprintf( '%o', fileperms( $abs_path ) ), -4 );
|
|
|
|
$dir = self::quarantine_dir();
|
|
do {
|
|
$filename = 'q-' . wp_generate_password( 24, false, false ) . '.bin';
|
|
$target = trailingslashit( $dir ) . $filename;
|
|
} while ( file_exists( $target ) );
|
|
|
|
$moved = @rename( $abs_path, $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
if ( ! $moved ) {
|
|
return false;
|
|
}
|
|
|
|
$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(
|
|
'original_path' => $rel_path,
|
|
'quarantine_filename' => $filename,
|
|
'original_filename' => basename( $rel_path ),
|
|
'file_size' => $size,
|
|
'file_hash' => $hash,
|
|
'original_mtime' => $mtime,
|
|
'original_perms' => $perms,
|
|
'detection_engine' => $detection['engine'],
|
|
'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', '%d', '%s', '%s', '%s', '%s' )
|
|
);
|
|
|
|
$id = (int) $wpdb->insert_id;
|
|
|
|
Argus_Events::record(
|
|
'quarantine_added',
|
|
'high',
|
|
sprintf( 'Quarantined %s (%s)', $rel_path, $detection['engine'] ),
|
|
array( 'quarantine_id' => $id, 'original_path' => $rel_path, 'detection_type' => $detection['type'] )
|
|
);
|
|
|
|
return array( 'id' => $id, 'hash' => $hash, 'quarantine_filename' => $filename );
|
|
}
|
|
|
|
protected static function store_path( $row ) {
|
|
return trailingslashit( self::quarantine_dir() ) . $row->quarantine_filename;
|
|
}
|
|
|
|
public static function restore( $id ) {
|
|
$row = self::get( $id );
|
|
if ( ! $row || self::STATUS_QUARANTINED !== $row->status ) {
|
|
return array( 'success' => false, 'message' => __( 'That item is not currently quarantined.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$store_path = self::store_path( $row );
|
|
if ( ! file_exists( $store_path ) ) {
|
|
return array( 'success' => false, 'message' => __( 'The quarantined file is missing from the quarantine store.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$content = file_get_contents( $store_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
list( $score, $matched ) = Argus_Malware_Scanner::score_content( (string) $content );
|
|
|
|
global $wpdb;
|
|
|
|
if ( $score >= Argus_Malware_Scanner::THRESHOLD_MEDIUM ) {
|
|
$wpdb->update(
|
|
Argus_DB::table( 'quarantine' ),
|
|
array( 'restore_attempts' => (int) $row->restore_attempts + 1 ),
|
|
array( 'id' => $row->id ),
|
|
array( '%d' ),
|
|
array( '%d' )
|
|
);
|
|
return array(
|
|
'success' => false,
|
|
|
|
'message' => __( 'Restore cancelled -- the restored file still triggers the security engine.', 'argus-wordpress-defence' ),
|
|
);
|
|
}
|
|
|
|
$abs_original = ABSPATH . ltrim( $row->original_path, '/' );
|
|
if ( file_exists( $abs_original ) ) {
|
|
return array( 'success' => false, 'message' => __( 'A file already exists at the original location.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$moved = @rename( $store_path, $abs_original ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
if ( ! $moved ) {
|
|
return array( 'success' => false, 'message' => __( 'Could not move the file back to its original location.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
if ( $row->original_perms ) {
|
|
@chmod( $abs_original, octdec( $row->original_perms ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.PHP.NoSilencedErrors
|
|
}
|
|
|
|
$now = current_time( 'mysql', true );
|
|
$wpdb->update(
|
|
Argus_DB::table( 'quarantine' ),
|
|
array( 'status' => self::STATUS_RESTORED, 'resolved_at' => $now, 'restore_attempts' => (int) $row->restore_attempts + 1 ),
|
|
array( 'id' => $row->id ),
|
|
array( '%s', '%s', '%d' ),
|
|
array( '%d' )
|
|
);
|
|
|
|
Argus_Malware_Scanner::mark_restored_trusted( $row->original_path, hash_file( 'sha256', $abs_original ) );
|
|
|
|
if ( $row->finding_id ) {
|
|
Argus_Findings::set_status( $row->finding_id, Argus_Findings::STATUS_RESOLVED );
|
|
}
|
|
|
|
Argus_Events::record(
|
|
'quarantine_restored',
|
|
'info',
|
|
sprintf( 'Restored %s from quarantine', $row->original_path ),
|
|
array( 'quarantine_id' => $row->id, 'original_path' => $row->original_path, 'score' => $score )
|
|
);
|
|
|
|
return array(
|
|
'success' => true,
|
|
|
|
'message' => __( 'Restored -- ARGUS\'s static-analysis engine found no suspicious patterns in the current content. This is not a guarantee the file is safe, only that it did not match ARGUS\'s known indicators.', 'argus-wordpress-defence' ),
|
|
);
|
|
}
|
|
|
|
public static function delete( $id ) {
|
|
global $wpdb;
|
|
|
|
$row = self::get( $id );
|
|
if ( ! $row || self::STATUS_QUARANTINED !== $row->status ) {
|
|
return array( 'success' => false, 'message' => __( 'That item is not currently quarantined.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$store_path = self::store_path( $row );
|
|
if ( file_exists( $store_path ) && ! @unlink( $store_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
return array( 'success' => false, 'message' => __( 'Could not delete the quarantined file.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$now = current_time( 'mysql', true );
|
|
$wpdb->update(
|
|
Argus_DB::table( 'quarantine' ),
|
|
array( 'status' => self::STATUS_DELETED, 'resolved_at' => $now ),
|
|
array( 'id' => $row->id ),
|
|
array( '%s', '%s' ),
|
|
array( '%d' )
|
|
);
|
|
|
|
if ( $row->finding_id ) {
|
|
Argus_Findings::set_status( $row->finding_id, Argus_Findings::STATUS_RESOLVED );
|
|
}
|
|
|
|
Argus_Events::record(
|
|
'quarantine_deleted',
|
|
'info',
|
|
sprintf( 'Permanently deleted quarantined file: %s', $row->original_path ),
|
|
array( 'quarantine_id' => $row->id, 'original_path' => $row->original_path )
|
|
);
|
|
|
|
return array( 'success' => true, 'message' => __( 'Permanently deleted.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
public static function analyse( $id ) {
|
|
global $wpdb;
|
|
|
|
$row = self::get( $id );
|
|
if ( ! $row ) {
|
|
return array( 'success' => false, 'message' => __( 'Quarantine item not found.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$store_path = self::store_path( $row );
|
|
if ( ! file_exists( $store_path ) ) {
|
|
return array( 'success' => false, 'message' => __( 'The quarantined file is missing from the quarantine store.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$content = file_get_contents( $store_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
list( $score, $matched ) = Argus_Malware_Scanner::score_content( (string) $content );
|
|
|
|
$wpdb->update(
|
|
Argus_DB::table( 'quarantine' ),
|
|
array(
|
|
'confidence_score' => $score,
|
|
'matched_rules' => wp_json_encode( wp_list_pluck( $matched, 'id' ) ),
|
|
'analysed_at' => current_time( 'mysql', true ),
|
|
),
|
|
array( 'id' => $row->id ),
|
|
array( '%d', '%s', '%s' ),
|
|
array( '%d' )
|
|
);
|
|
|
|
return array( 'success' => true, 'score' => $score, 'matched' => $matched );
|
|
}
|
|
|
|
public static function get( $id ) {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'quarantine' );
|
|
return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); // phpcs:ignore
|
|
}
|
|
|
|
const PER_PAGE = 10;
|
|
|
|
public static function paginated( $page = 1, $status = null, $per_page = self::PER_PAGE ) {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'quarantine' );
|
|
$page = max( 1, (int) $page );
|
|
$offset = ( $page - 1 ) * $per_page;
|
|
|
|
if ( $status ) {
|
|
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", $status ) ); // phpcs:ignore
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY quarantined_at DESC LIMIT %d OFFSET %d", $status, $per_page, $offset ) // phpcs:ignore
|
|
);
|
|
} else {
|
|
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY quarantined_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore
|
|
);
|
|
}
|
|
|
|
foreach ( $rows as &$row ) {
|
|
$row->matched_rules = $row->matched_rules ? json_decode( $row->matched_rules, true ) : array();
|
|
}
|
|
|
|
return array(
|
|
'rows' => $rows,
|
|
'total' => $total,
|
|
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
|
'page' => $page,
|
|
);
|
|
}
|
|
|
|
public static function count_open() {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'quarantine' );
|
|
return (int) $wpdb->get_var(
|
|
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", self::STATUS_QUARANTINED ) // phpcs:ignore
|
|
);
|
|
}
|
|
|
|
public static function counts_by_status() {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'quarantine' );
|
|
$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS cnt FROM {$table} GROUP BY status" ); // phpcs:ignore
|
|
|
|
$out = array( self::STATUS_QUARANTINED => 0, self::STATUS_RESTORED => 0, self::STATUS_DELETED => 0 );
|
|
foreach ( $rows as $row ) {
|
|
if ( isset( $out[ $row->status ] ) ) {
|
|
$out[ $row->status ] = (int) $row->cnt;
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public static function legacy_count() {
|
|
$uploads = wp_get_upload_dir();
|
|
$dir = $uploads['basedir'] ?? '';
|
|
if ( ! $dir || ! is_dir( $dir ) ) {
|
|
return 0;
|
|
}
|
|
|
|
$count = 0;
|
|
$suffix = Argus_Malware_Scanner::QUARANTINE_SUFFIX;
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
|
RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
foreach ( $iterator as $file ) {
|
|
if ( $file->isFile() && $suffix === substr( $file->getFilename(), -strlen( $suffix ) ) ) {
|
|
$count++;
|
|
}
|
|
}
|
|
return $count;
|
|
}
|
|
}
|