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.
349 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|