Files
argus-wp-defence/includes/class-argus-quarantine.php
T
root df0f2fccb8 ARGUS WordPress Defence 1.0.0 — first production release
Automatic WordPress security: local firewall, malware and file-integrity
scanning, vulnerability protection, quarantine, scheduled backups, an
optional page cache, and automatic global threat intelligence.

See README.md for installation, update, and uninstall instructions.
2026-08-09 13:40:16 +00:00

349 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 );
$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'],
'status' => self::STATUS_QUARANTINED,
'quarantined_at' => $now,
),
array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%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;
}
}