time() ) );
}
if ( Malsift_Firewall::settings()['uploads_php'] && Malsift_Firewall::server_supports_htaccess() ) {
Malsift_Firewall::set_uploads_block( true );
}
}
/**
* Deactivation: remove scheduled events.
*/
public static function deactivate() {
wp_clear_scheduled_hook( 'malsift_daily_scan' );
wp_clear_scheduled_hook( 'malsift_continue_scan' );
delete_option( Malsift_Storage::OPT_STATE );
Malsift_Firewall::set_uploads_block( false );
}
/**
* Schedule the daily scan for 03:00 site time.
*/
public static function schedule() {
if ( ! wp_next_scheduled( 'malsift_daily_scan' ) ) {
$when = new DateTime( 'tomorrow 03:00', wp_timezone() );
wp_schedule_event( $when->getTimestamp(), 'daily', 'malsift_daily_scan' );
}
}
/**
* Cron: start the daily scan.
*/
public static function cron_start() {
if ( Malsift_Scanner::running() ) {
return;
}
$start = Malsift_Scanner::start( 'scheduled' );
if ( ! is_wp_error( $start ) ) {
self::cron_continue();
}
}
/**
* Cron: continue a scan in 20-second slices.
*/
public static function cron_continue() {
$progress = Malsift_Scanner::run( 20 );
if ( ! is_wp_error( $progress ) && empty( $progress['done'] ) ) {
wp_schedule_single_event( time() + 60, 'malsift_continue_scan' );
}
}
/**
* Block the theme/plugin code editor when locked.
*
* @param array $caps Primitive caps.
* @param string $cap Meta cap.
* @return array
*/
public static function lock_editor( $caps, $cap ) {
if ( in_array( $cap, array( 'edit_themes', 'edit_plugins', 'edit_files' ), true ) && Malsift_Storage::settings()['lock_editor'] ) {
$caps[] = 'do_not_allow';
}
return $caps;
}
/**
* Admin menu.
*/
public static function menu() {
$cap = self::cap();
add_menu_page( __( 'Malsift', 'malsift' ), __( 'Malsift', 'malsift' ), $cap, 'malsift', array( __CLASS__, 'render' ), 'dashicons-shield', 80 );
add_submenu_page( 'malsift', __( 'Malsift Dashboard', 'malsift' ), __( 'Dashboard', 'malsift' ), $cap, 'malsift', array( __CLASS__, 'render' ) );
add_submenu_page( 'malsift', __( 'Malsift Findings', 'malsift' ), __( 'Findings', 'malsift' ), $cap, 'malsift-findings', array( __CLASS__, 'render' ) );
add_submenu_page( 'malsift', __( 'Malsift Firewall', 'malsift' ), __( 'Firewall', 'malsift' ), $cap, 'malsift-firewall', array( __CLASS__, 'render' ) );
add_submenu_page( 'malsift', __( 'Malsift Reinstall', 'malsift' ), __( 'Reinstall', 'malsift' ), $cap, 'malsift-reinstall', array( __CLASS__, 'render' ) );
add_submenu_page( 'malsift', __( 'Malsift Quarantine', 'malsift' ), __( 'Quarantine', 'malsift' ), $cap, 'malsift-quarantine', array( __CLASS__, 'render' ) );
add_submenu_page( 'malsift', __( 'Malsift Scan History', 'malsift' ), __( 'Scan history', 'malsift' ), $cap, 'malsift-history', array( __CLASS__, 'render' ) );
add_submenu_page( 'malsift', __( 'Malsift Settings', 'malsift' ), __( 'Settings', 'malsift' ), $cap, 'malsift-settings', array( __CLASS__, 'render' ) );
/**
* Fires after Malsift's admin menu is registered, so add-ons can add screens.
*
* @param string $cap Capability required for Malsift screens.
*/
do_action( 'malsift_admin_menu', $cap );
}
/**
* Scripts and styles on Malsift screens only.
*
* @param string $hook Screen hook.
*/
public static function assets( $hook ) {
if ( false === strpos( $hook, 'malsift' ) ) {
return;
}
// The file time is part of the version, so browsers and caches pick up changed files.
$ver = function ( $file ) {
$time = @filemtime( MALSIFT_DIR . $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- missing file falls back to the plugin version.
return MALSIFT_VERSION . ( $time ? '.' . $time : '' );
};
wp_enqueue_style( 'malsift-admin', MALSIFT_URL . 'assets/admin.css', array(), $ver( 'assets/admin.css' ) );
wp_enqueue_script( 'malsift-admin', MALSIFT_URL . 'assets/admin.js', array(), $ver( 'assets/admin.js' ), true );
wp_localize_script(
'malsift-admin',
'malsiftAdmin',
array(
'ajax' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'malsift' ),
'i18n' => array(
'starting' => __( 'Starting scan…', 'malsift' ),
'error' => __( 'Something went wrong:', 'malsift' ),
'fixAll' => __( 'Apply the safe automatic fix to every open threat? Replaced and removed files are kept in quarantine.', 'malsift' ),
'working' => __( 'Working…', 'malsift' ),
'purge' => __( 'Delete these files permanently? This cannot be undone.', 'malsift' ),
'reinstall' => __( 'Reinstall the selected items from WordPress.org? Changed files are backed up to quarantine first. Keep this page open until it finishes.', 'malsift' ),
'reinstalling' => __( 'Reinstalling…', 'malsift' ),
'done' => __( 'Done', 'malsift' ),
'failed' => __( 'Failed', 'malsift' ),
'rescan' => __( 'Reinstall finished. Run a new scan to confirm the site is clean.', 'malsift' ),
),
)
);
}
/**
* Verify AJAX request.
*/
private static function check_ajax() {
check_ajax_referer( 'malsift', 'nonce' );
if ( ! current_user_can( self::cap() ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'malsift' ) ), 403 );
}
}
/**
* AJAX: start/step a scan.
*/
public static function ajax_scan() {
self::check_ajax();
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : 'step';
$progress = 'start' === $op ? Malsift_Scanner::start( 'manual' ) : Malsift_Scanner::run( 8 );
if ( is_wp_error( $progress ) ) {
wp_send_json_error( array( 'message' => $progress->get_error_message() ) );
}
wp_send_json_success( $progress );
}
/**
* AJAX: run an action on a finding.
*/
public static function ajax_action() {
self::check_ajax();
$id = isset( $_POST['id'] ) ? sanitize_key( wp_unslash( $_POST['id'] ) ) : '';
$action = isset( $_POST['act'] ) ? sanitize_key( wp_unslash( $_POST['act'] ) ) : '';
$r = Malsift_Actions::run( $id, $action );
if ( is_wp_error( $r ) ) {
wp_send_json_error( array( 'message' => $r->get_error_message() ) );
}
wp_send_json_success( $r );
}
/**
* AJAX: fix all threats.
*/
public static function ajax_fix_all() {
self::check_ajax();
$r = Malsift_Actions::fix_all();
/* translators: 1: fixed, 2: failed, 3: needing manual review. */
$msg = sprintf( __( 'Fixed %1$d, failed %2$d, %3$d need manual review. Run a new scan to confirm the site is clean.', 'malsift' ), $r['fixed'], $r['failed'], $r['skipped'] );
wp_send_json_success( array( 'message' => trim( $msg . ' ' . implode( ' ', $r['messages'] ) ) ) );
}
/**
* AJAX: restore or purge quarantine items.
*/
public static function ajax_quarantine() {
self::check_ajax();
$group = isset( $_POST['group'] ) ? sanitize_key( wp_unslash( $_POST['group'] ) ) : '';
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
$r = 'restore' === $op ? Malsift_Quarantine::restore( $group ) : Malsift_Quarantine::purge( $group );
if ( is_wp_error( $r ) ) {
wp_send_json_error( array( 'message' => $r->get_error_message() ) );
}
wp_send_json_success( array( 'message' => 'restore' === $op ? __( 'Restored to the original location.', 'malsift' ) : __( 'Deleted permanently.', 'malsift' ) ) );
}
/**
* AJAX: reinstall one item.
*/
public static function ajax_reinstall() {
self::check_ajax();
$type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : '';
$key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
$r = Malsift_Reinstall::run( $type, $key );
if ( is_wp_error( $r ) ) {
wp_send_json_error( array( 'message' => $r->get_error_message() ) );
}
wp_send_json_success( array( 'message' => $r ) );
}
/**
* Save settings.
*/
public static function save_settings() {
if ( ! current_user_can( self::cap() ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'malsift' ) );
}
check_admin_referer( 'malsift_settings' );
$email = isset( $_POST['alert_email'] ) ? sanitize_email( wp_unslash( $_POST['alert_email'] ) ) : '';
$max = isset( $_POST['max_file_mb'] ) ? absint( $_POST['max_file_mb'] ) : 8;
Malsift_Storage::update_settings(
array(
'daily_scan' => empty( $_POST['daily_scan'] ) ? 0 : 1,
'email_alerts' => empty( $_POST['email_alerts'] ) ? 0 : 1,
'alert_email' => $email ? $email : get_option( 'admin_email' ),
'lock_editor' => empty( $_POST['lock_editor'] ) ? 0 : 1,
'max_file_mb' => min( 64, max( 1, $max ) ),
'delete_on_uninstall' => empty( $_POST['delete_on_uninstall'] ) ? 0 : 1,
'spam_delete_permanently' => empty( $_POST['spam_delete_permanently'] ) ? 0 : 1,
)
);
if ( Malsift_Storage::settings()['daily_scan'] ) {
self::schedule();
} else {
wp_clear_scheduled_hook( 'malsift_daily_scan' );
}
wp_safe_redirect( add_query_arg( 'saved', '1', self::page_url( 'malsift-settings' ) ) );
exit;
}
/* ------------------------------------------------------------------ */
/* Rendering */
/* ------------------------------------------------------------------ */
/**
* Render the current screen.
*/
public static function render() {
if ( ! current_user_can( self::cap() ) ) {
return;
}
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : 'malsift'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$result = get_option( Malsift_Storage::OPT_RESULT );
$result = is_array( $result ) ? $result : null;
echo '
';
self::rescue_banner( $result );
self::header( $result, $page );
echo '
';
switch ( $page ) {
case 'malsift-findings':
self::page_findings( $result );
break;
case 'malsift-reinstall':
self::page_reinstall( $result );
break;
case 'malsift-quarantine':
self::page_quarantine();
break;
case 'malsift-history':
self::page_history();
break;
case 'malsift-settings':
self::page_settings();
break;
case 'malsift-firewall':
self::page_firewall();
break;
case 'malsift':
self::page_dashboard( $result );
break;
default:
/**
* Render an add-on screen. Return true when the page was rendered.
*
* @param bool $done Rendered.
* @param string $page Page slug.
* @param array $result Last scan result or null.
*/
if ( ! apply_filters( 'malsift_render_page', false, $page, $result ) ) {
self::page_dashboard( $result );
}
}
echo '
';
}
/**
* Tabs shown at the top of every Malsift screen. Add-ons add theirs with the malsift_tabs filter.
*
* @param array|null $result Last result.
* @return array slug => [label, order, count, count_class, url]
*/
public static function tabs( $result = null ) {
$open = 0;
$threats = 0;
if ( is_array( $result ) ) {
$open = (int) $result['counts']['threat'] + (int) $result['counts']['warning'];
$threats = (int) $result['counts']['threat'];
}
$tabs = array(
'malsift' => array( __( 'Overview', 'malsift' ), 10 ),
'malsift-findings' => array( __( 'Findings', 'malsift' ), 20, $open, $threats ? 'is-bad' : '' ),
'malsift-firewall' => array( __( 'Firewall', 'malsift' ), 30 ),
'malsift-reinstall' => array( __( 'Reinstall', 'malsift' ), 80 ),
'malsift-quarantine' => array( __( 'Quarantine', 'malsift' ), 85 ),
'malsift-history' => array( __( 'History', 'malsift' ), 90 ),
'malsift-settings' => array( __( 'Settings', 'malsift' ), 95 ),
);
/**
* Filter the Malsift tabs. Each tab is [label, order, count, count class, url].
*
* @param array $tabs Tabs keyed by page slug.
* @param array|null $result Last scan result.
*/
$tabs = apply_filters( 'malsift_tabs', $tabs, $result );
uasort(
$tabs,
function ( $a, $b ) {
return ( isset( $a[1] ) ? (int) $a[1] : 50 ) - ( isset( $b[1] ) ? (int) $b[1] : 50 );
}
);
return $tabs;
}
/**
* Hide Malsift's sub-menu items in the WordPress menu: the tabs replace them.
*/
public static function menu_style() {
if ( current_user_can( self::cap() ) ) {
echo '';
}
}
/**
* Header with title, buttons and tabs.
*
* @param array|null $result Last result.
* @param string $page Current page.
*/
private static function header( $result, $page = 'malsift' ) {
$running = Malsift_Scanner::running();
/**
* Short badge next to the Malsift name (for example "PRO").
*
* @param string $badge Badge text.
*/
$badge = (string) apply_filters( 'malsift_header_badge', '' );
?>
$tab ) {
$url = ! empty( $tab[4] ) ? $tab[4] : self::page_url( $slug );
$on = $slug === $page;
printf(
'%s%s ',
$on ? ' is-on' : '',
esc_url( $url ),
$on ? ' aria-current="page"' : '',
esc_html( $tab[0] ),
! empty( $tab[2] ) ? ' ' . esc_html( number_format_i18n( $tab[2] ) ) . ' ' : ''
);
}
?>
'ok',
'warn' => 'warn',
'warning' => 'warn',
'bad' => 'bad',
'threat' => 'bad',
'info' => 'info',
);
printf( '%s ', esc_attr( isset( $map[ $status ] ) ? $map[ $status ] : 'info' ), esc_html( $label ) );
}
/**
* Action buttons for a finding.
*
* @param array $f Finding.
*/
private static function actions( $f ) {
if ( ! empty( $f['resolved'] ) ) {
self::chip( 'ok', __( 'Fixed', 'malsift' ) );
return;
}
if ( ! empty( $f['ignored'] ) ) {
printf( '%s ', esc_attr( $f['id'] ), esc_html__( 'Stop ignoring', 'malsift' ) );
return;
}
$first = true;
foreach ( $f['actions'] as $action ) {
if ( 'link' === $action ) {
if ( ! empty( $f['meta']['link'] ) ) {
printf( '%s ', esc_url( $f['meta']['link'] ), esc_html( Malsift_Actions::label( $action, $f ) ) );
}
continue;
}
$class = 'ignore' === $action ? 'malsift-a malsift-a-g' : ( $first ? 'malsift-a malsift-a-p' : 'malsift-a' );
printf(
'%s ',
esc_attr( $class ),
esc_attr( $f['id'] ),
esc_attr( $action ),
esc_attr( Malsift_Actions::confirm_text( $action, $f ) ),
esc_html( Malsift_Actions::label( $action, $f ) )
);
if ( 'ignore' !== $action ) {
$first = false;
}
}
}
/**
* Target line for a finding.
*
* @param array $f Finding.
* @return string
*/
private static function target_text( $f ) {
if ( in_array( $f['type'], array( 'file', 'dir' ), true ) ) {
return $f['target'] . ( 'dir' === $f['type'] ? '/' : '' );
}
return '';
}
/**
* A finding: one line that opens to show the explanation and the fixes.
*
* @param array $f Finding.
* @param bool $open Open by default.
*/
private static function finding_card( $f, $open = false ) {
$target = self::target_text( $f );
$sub = $target ? $target : strtok( (string) $f['detail'], "\n" );
$labels = array(
'threat' => array( 'bad', __( 'Threat', 'malsift' ) ),
'warning' => array( 'warn', __( 'Review', 'malsift' ) ),
'info' => array( 'info', __( 'Info', 'malsift' ) ),
);
$sev = isset( $labels[ $f['severity'] ] ) ? $labels[ $f['severity'] ] : $labels['info'];
if ( ! empty( $f['resolved'] ) ) {
$sev = array( 'ok', __( 'Fixed', 'malsift' ) );
} elseif ( ! empty( $f['ignored'] ) ) {
$sev = array( 'info', __( 'Known', 'malsift' ) );
}
?>
>
getOffset( new DateTime() ) ) / DAY_IN_SECONDS );
$off = (int) wp_timezone()->getOffset( new DateTime() );
foreach ( Malsift_Firewall::log() as $e ) {
if ( ! in_array( $e['action'], array( 'blocked', 'locked' ), true ) ) {
continue;
}
$i = $days - 1 - ( $today - (int) floor( ( $e['time'] + $off ) / DAY_IN_SECONDS ) );
if ( $i >= 0 && $i < $days ) {
$out[ $i ] += max( 1, (int) $e['count'] );
}
}
return $out;
}
/**
* Small bar chart.
*
* @param int[] $values Values.
* @param string $label Accessible label.
* @return string SVG.
*/
private static function sparkline( $values, $label ) {
$max = max( 1, max( $values ) );
$n = count( $values );
$w = 13;
$gap = 4;
$h = 40;
$svg = '';
foreach ( $values as $i => $v ) {
$bh = max( 3, (int) round( $h * $v / $max ) );
$svg .= ' ';
}
return $svg . ' ';
}
/**
* Protection items for the dashboard. Add-ons add theirs with the malsift_protection_items filter.
*
* @return array[]
*/
public static function protection_items() {
$fw = Malsift_Firewall::settings();
$s = Malsift_Storage::settings();
$stats = Malsift_Firewall::stats( 7 );
$modes = array(
'off' => array( 'bad', __( 'Off', 'malsift' ) ),
'learning' => array( 'warn', __( 'Learning', 'malsift' ) ),
'protect' => array( 'ok', __( 'Protecting', 'malsift' ) ),
);
$ht = Malsift_Firewall::server_supports_htaccess();
$items = array(
'firewall' => array(
'title' => __( 'Firewall', 'malsift' ),
/* translators: %s: number of blocked attacks. */
'detail' => sprintf( __( '%s blocked this week', 'malsift' ), number_format_i18n( $stats['blocked'] ) ),
'chip' => $modes[ $fw['mode'] ],
'link' => self::page_url( 'malsift-firewall' ),
),
'login_limit' => array(
'title' => __( 'Login limits', 'malsift' ),
/* translators: 1: attempts, 2: minutes. */
'detail' => sprintf( __( '%1$d tries, then a %2$d-minute lockout', 'malsift' ), $fw['login_attempts'], $fw['lockout_minutes'] ),
'toggle' => (bool) $fw['login_limit'],
),
'block_enum' => array(
'title' => __( 'Hide usernames', 'malsift' ),
'detail' => __( 'Stops visitors from listing user accounts', 'malsift' ),
'toggle' => (bool) $fw['block_enum'],
),
'uploads_php' => array(
'title' => __( 'Block PHP in uploads', 'malsift' ),
'detail' => $ht ? __( 'Uploaded webshells cannot run', 'malsift' ) : __( 'Ask your host (server does not read .htaccess)', 'malsift' ),
'toggle' => $ht && $fw['uploads_php'],
'disabled' => ! $ht,
),
'lock_editor' => array(
'title' => __( 'Lock the code editor', 'malsift' ),
'detail' => __( 'No PHP edits from the dashboard', 'malsift' ),
'toggle' => (bool) $s['lock_editor'],
),
'daily_scan' => array(
'title' => __( 'Daily scan', 'malsift' ),
'detail' => __( 'Every day at 03:00 site time', 'malsift' ),
'toggle' => (bool) $s['daily_scan'],
),
);
/**
* Filter the protection items on the dashboard. An item with "toggle" shows a switch
* that posts to admin-post.php with "post_action" (default malsift_toggle) and "key".
*
* @param array $items Items keyed by name.
*/
return apply_filters( 'malsift_protection_items', $items );
}
/**
* Render one protection item.
*
* @param string $key Key.
* @param array $item Item.
*/
private static function protection_row( $key, $item ) {
echo '';
}
/**
* Recent activity: scans and firewall events.
*
* @return array[] [time, chip status, chip label, text]
*/
private static function activity() {
$labels = Malsift_Firewall::labels();
$items = array();
foreach ( array_slice( Malsift_Firewall::log(), 0, 6 ) as $e ) {
$names = array(
'blocked' => array( 'bad', __( 'Blocked', 'malsift' ) ),
'logged' => array( 'warn', __( 'Logged', 'malsift' ) ),
'locked' => array( 'warn', __( 'Locked out', 'malsift' ) ),
);
$n = isset( $names[ $e['action'] ] ) ? $names[ $e['action'] ] : $names['logged'];
$text = sprintf(
/* translators: 1: attack type, 2: IP address. */
__( '%1$s from %2$s', 'malsift' ),
isset( $labels[ $e['rule'] ] ) ? $labels[ $e['rule'] ] : $e['rule'],
$e['ip']
);
$items[] = array( (int) $e['time'], $n[0], $n[1], $text . ( $e['count'] > 1 ? ' · ' . sprintf( /* translators: %d: count. */ _n( '%d time', '%d times', $e['count'], 'malsift' ), $e['count'] ) : '' ) );
}
$history = get_option( Malsift_Storage::OPT_HISTORY, array() );
foreach ( array_slice( (array) $history, 0, 4 ) as $h ) {
if ( $h['threats'] ) {
/* translators: %d: count. */
$text = sprintf( _n( 'Scan finished — %d threat found', 'Scan finished — %d threats found', $h['threats'], 'malsift' ), $h['threats'] );
} elseif ( $h['warnings'] ) {
/* translators: %d: count. */
$text = sprintf( _n( 'Scan finished — no malware, %d item to review', 'Scan finished — no malware, %d items to review', $h['warnings'], 'malsift' ), $h['warnings'] );
} else {
$text = __( 'Scan finished — clean', 'malsift' );
}
$items[] = array( (int) $h['time'], $h['threats'] ? 'bad' : 'ok', __( 'Scan', 'malsift' ), $text );
}
/**
* Filter the dashboard activity list. Each item is [time, chip status, chip label, text].
*
* @param array $items Items.
*/
$items = apply_filters( 'malsift_activity', $items );
usort(
$items,
function ( $a, $b ) {
return $b[0] - $a[0];
}
);
return array_slice( $items, 0, 6 );
}
/**
* Dashboard.
*
* @param array|null $result Last result.
*/
private static function page_dashboard( $result ) {
if ( ! $result ) {
?>
6 ) : ?>
' . esc_html__( 'Nothing needs your attention.', 'malsift' ) . '';
}
foreach ( array_slice( $open, 0, 6 ) as $f ) {
self::finding_card( $f );
}
?>
$item ) {
self::protection_row( $key, $item );
}
?>
__( 'Files', 'malsift' ),
'access' => __( 'Access', 'malsift' ),
'database' => __( 'Database', 'malsift' ),
'hardening' => __( 'Hardening', 'malsift' ),
);
$current = '';
foreach ( Malsift_Scanner::checks() as $key => $def ) {
if ( $def[0] !== $current ) {
$current = $def[0];
echo '
' . esc_html( $groups[ $current ] ) . '
';
}
$check = isset( $result['checks'][ $key ] ) ? $result['checks'][ $key ] : array(
'status' => 'info',
'label' => __( 'Not checked yet', 'malsift' ),
);
echo '
' . esc_html( $def[1] ) . ' ' . esc_html( $def[2] ) . '
';
self::chip( $check['status'], $check['label'] );
echo '
';
}
?>
' . esc_html__( 'No scans yet.', 'malsift' ) . '';
return;
}
echo '';
foreach ( array_slice( $history, 0, $limit ) as $h ) {
echo '' . esc_html( wp_date( 'j M, H:i', $h['time'] ) ) . ' ' . esc_html( 'scheduled' === $h['trigger'] ? __( 'Daily scan', 'malsift' ) : __( 'Full scan', 'malsift' ) ) . ' ';
if ( $h['threats'] ) {
/* translators: %d: count. */
self::chip( 'bad', sprintf( _n( '%d threat', '%d threats', $h['threats'], 'malsift' ), $h['threats'] ) );
} elseif ( $h['warnings'] ) {
/* translators: %d: count. */
self::chip( 'warn', sprintf( _n( '%d warning', '%d warnings', $h['warnings'], 'malsift' ), $h['warnings'] ) );
} else {
self::chip( 'ok', __( 'Clean', 'malsift' ) );
}
echo ' ';
}
echo '
';
}
/**
* All findings.
*
* @param array|null $result Last result.
*/
private static function page_findings( $result ) {
if ( ! $result ) {
echo '' . esc_html__( 'No findings yet', 'malsift' ) . ' ' . esc_html__( 'Run a scan to see what Malsift finds.', 'malsift' ) . '
';
return;
}
$filter = isset( $_GET['show'] ) ? sanitize_key( wp_unslash( $_GET['show'] ) ) : 'open'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$match = array(
'open' => function ( $f ) {
return empty( $f['resolved'] ) && empty( $f['ignored'] ) && 'info' !== $f['severity'];
},
'threat' => function ( $f ) {
return empty( $f['resolved'] ) && empty( $f['ignored'] ) && 'threat' === $f['severity'];
},
'warning' => function ( $f ) {
return empty( $f['resolved'] ) && empty( $f['ignored'] ) && 'warning' === $f['severity'];
},
'resolved' => function ( $f ) {
return ! empty( $f['resolved'] );
},
'ignored' => function ( $f ) {
return ! empty( $f['ignored'] ) && empty( $f['resolved'] );
},
'info' => function ( $f ) {
return 'info' === $f['severity'] && empty( $f['resolved'] );
},
);
$names = array(
'open' => __( 'Open', 'malsift' ),
'threat' => __( 'Threats', 'malsift' ),
'warning' => __( 'To review', 'malsift' ),
'resolved' => __( 'Fixed', 'malsift' ),
'ignored' => __( 'Known', 'malsift' ),
'info' => __( 'Info', 'malsift' ),
);
if ( ! isset( $match[ $filter ] ) ) {
$filter = 'open';
}
?>
__( 'Files', 'malsift' ),
'access' => __( 'Access', 'malsift' ),
'database' => __( 'Database', 'malsift' ),
'hardening' => __( 'Settings and updates', 'malsift' ),
);
$checks = Malsift_Scanner::checks();
$by = array();
foreach ( array_filter( $result['findings'], $match[ $filter ] ) as $f ) {
$g = isset( $checks[ $f['check'] ] ) ? $checks[ $f['check'] ][0] : 'files';
$by[ $g ][] = $f;
}
if ( ! $by ) {
echo '' . esc_html__( 'Nothing here.', 'malsift' ) . '
';
}
$first = true;
foreach ( $groups as $g => $title ) {
if ( empty( $by[ $g ] ) ) {
continue;
}
echo '' . esc_html( $title ) . ' ' . esc_html( sprintf( /* translators: %d: items. */ _n( '%d item', '%d items', count( $by[ $g ] ), 'malsift' ), count( $by[ $g ] ) ) ) . ' ';
foreach ( $by[ $g ] as $f ) {
self::finding_card( $f, $first && 'open' === $filter );
$first = false;
}
echo ' ';
}
if ( ! empty( $result['notes'] ) && 'info' === $filter ) {
echo '' . esc_html__( 'Scan notes', 'malsift' ) . ' ';
foreach ( $result['notes'] as $note ) {
echo '' . esc_html( $note ) . ' ';
}
echo ' ';
}
}
/**
* Reinstall screen.
*
* @param array|null $result Last result.
*/
private static function page_reinstall( $result ) {
$blocked = Malsift_Reinstall::blocked();
$items = Malsift_Reinstall::items();
$issues = array();
if ( $result ) {
foreach ( $result['findings'] as $f ) {
if ( in_array( $f['type'], array( 'file', 'dir' ), true ) && empty( $f['resolved'] ) && empty( $f['ignored'] ) && 'info' !== $f['severity'] ) {
foreach ( $items as $id => $item ) {
$in = 'core' === $item['type']
? ( 0 === strpos( $f['target'], 'wp-admin/' ) || 0 === strpos( $f['target'], 'wp-includes/' ) || false === strpos( $f['target'], '/' ) )
: Malsift_Storage::in( $f['target'], $item['rel'] );
if ( $in ) {
$issues[ $id ] = isset( $issues[ $id ] ) ? $issues[ $id ] + 1 : 1;
break;
}
}
}
}
}
if ( $blocked ) {
echo '' . esc_html__( 'File changes are disabled on this site (DISALLOW_FILE_MODS in wp-config.php), so reinstalling is not possible. Remove the setting temporarily, reinstall, then add it back.', 'malsift' ) . '
';
}
$groups = array(
'core' => __( 'WordPress core', 'malsift' ),
'plugin' => __( 'Plugins', 'malsift' ),
'theme' => __( 'Themes', 'malsift' ),
);
?>
>
$item ) {
if ( $item['type'] !== $current ) {
$current = $item['type'];
printf( '%s ', esc_html( $groups[ $current ] ) );
}
$can = $item['available'] && ! $blocked;
echo '';
if ( $can ) {
printf( ' ', esc_attr( $item['type'] ), esc_attr( $item['key'] ), esc_attr( $item['name'] ) );
}
echo ' ' . esc_html( $item['name'] );
if ( $item['note'] ) {
echo '' . esc_html( $item['note'] ) . '
';
}
echo ' ' . esc_html( $item['version'] ) . ' ';
if ( ! $result ) {
echo '—';
} elseif ( isset( $issues[ $id ] ) ) {
/* translators: %d: number of files. */
self::chip( 'bad', sprintf( _n( '%d file flagged', '%d files flagged', $issues[ $id ], 'malsift' ), $issues[ $id ] ) );
} else {
self::chip( 'ok', __( 'Clean', 'malsift' ) );
}
echo ' ' . ( $can ? '' : esc_html__( 'Not available', 'malsift' ) ) . ' ';
}
?>
>
>
' . esc_html__( 'Quarantine', 'malsift' ) . ' ' . esc_html__( 'Files are stored compressed in a protected folder and cannot run.', 'malsift' ) . ' ';
if ( ! $groups ) {
echo '' . esc_html__( 'Quarantine is empty.', 'malsift' ) . '
';
return;
}
echo '' . esc_html__( 'File or folder', 'malsift' ) . ' ' . esc_html__( 'Reason', 'malsift' ) . ' ' . esc_html__( 'Date', 'malsift' ) . ' ' . esc_html__( 'Size', 'malsift' ) . ' ';
foreach ( $groups as $id => $g ) {
printf(
'%s%s%s %s %s %s %s ',
esc_html( $g['label'] ),
$g['count'] > 1 ? ' ' . esc_html( sprintf( /* translators: %d: files. */ _n( '%d file', '%d files', $g['count'], 'malsift' ), $g['count'] ) ) . ' ' : '',
esc_html( $g['reason'] ),
esc_html( wp_date( 'j M Y, H:i', $g['time'] ) ),
esc_html( size_format( $g['size'] ) ),
esc_attr( $id ),
esc_html__( 'Restore', 'malsift' ),
esc_attr( $id ),
esc_html__( 'Delete permanently', 'malsift' )
);
}
echo '
';
}
/**
* History screen.
*/
private static function page_history() {
echo '
' . esc_html__( 'Scan history', 'malsift' ) . ' ' . esc_html__( 'Last 30 scans', 'malsift' ) . ' ';
self::history_table( 30 );
echo '';
}
/**
* Settings screen.
*/
private static function page_settings() {
$s = Malsift_Storage::settings();
if ( isset( $_GET['saved'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '' . esc_html__( 'Settings saved.', 'malsift' ) . '
';
}
$next = wp_next_scheduled( 'malsift_daily_scan' );
?>
$on ) );
break;
case 'uploads_php':
$on = $on && Malsift_Firewall::server_supports_htaccess() ? 1 : 0;
Malsift_Firewall::update( array( 'uploads_php' => $on ) );
Malsift_Firewall::set_uploads_block( (bool) $on );
break;
case 'lock_editor':
Malsift_Storage::update_settings( array( 'lock_editor' => $on ) );
break;
case 'daily_scan':
Malsift_Storage::update_settings( array( 'daily_scan' => $on ) );
if ( $on ) {
self::schedule();
} else {
wp_clear_scheduled_hook( 'malsift_daily_scan' );
}
break;
}
wp_safe_redirect( self::page_url() );
exit;
}
/**
* Firewall mode buttons.
*/
public static function firewall_mode() {
if ( ! current_user_can( self::cap() ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'malsift' ) );
}
check_admin_referer( 'malsift_fw_mode' );
$mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : '';
if ( in_array( $mode, array( 'off', 'learning', 'protect' ), true ) ) {
Malsift_Firewall::update( array( 'mode' => $mode ) );
}
wp_safe_redirect( add_query_arg( 'saved', '1', self::page_url( 'malsift-firewall' ) ) );
exit;
}
/**
* Save firewall settings.
*/
public static function save_firewall() {
if ( ! current_user_can( self::cap() ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'malsift' ) );
}
check_admin_referer( 'malsift_firewall' );
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by the helpers below.
$mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : 'learning';
$source = isset( $_POST['ip_source'] ) ? sanitize_key( wp_unslash( $_POST['ip_source'] ) ) : 'auto';
$xmlrpc = isset( $_POST['xmlrpc'] ) ? sanitize_key( wp_unslash( $_POST['xmlrpc'] ) ) : 'no_multicall';
$enabled = isset( $_POST['rules'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['rules'] ) ) : array();
$allow = isset( $_POST['allow_ips'] ) ? Malsift_Firewall::parse_ip_list( wp_unslash( $_POST['allow_ips'] ) ) : array();
$block = isset( $_POST['block_ips'] ) ? Malsift_Firewall::parse_ip_list( wp_unslash( $_POST['block_ips'] ) ) : array();
// phpcs:enable
$me = Malsift_Firewall::client_ip();
$block = array_values(
array_filter(
$block,
function ( $entry ) use ( $me ) {
return ! Malsift_Firewall::ip_listed( $me, array( $entry ) );
}
)
);
$disabled = array_values( array_diff( array_keys( Malsift_Firewall::rules() ), $enabled ) );
$uploads = ! empty( $_POST['uploads_php'] ) && Malsift_Firewall::server_supports_htaccess();
Malsift_Firewall::update(
array(
'mode' => in_array( $mode, array( 'off', 'learning', 'protect' ), true ) ? $mode : 'learning',
'ip_source' => in_array( $source, array( 'auto', 'remote_addr', 'cloudflare', 'x_forwarded_for', 'x_real_ip' ), true ) ? $source : 'auto',
'xmlrpc' => in_array( $xmlrpc, array( 'allow', 'no_multicall', 'off' ), true ) ? $xmlrpc : 'no_multicall',
'disabled_rules' => $disabled,
'allow_ips' => $allow,
'block_ips' => $block,
'login_limit' => empty( $_POST['login_limit'] ) ? 0 : 1,
'login_attempts' => isset( $_POST['login_attempts'] ) ? min( 50, max( 2, absint( $_POST['login_attempts'] ) ) ) : 5,
'login_window' => isset( $_POST['login_window'] ) ? min( 1440, max( 1, absint( $_POST['login_window'] ) ) ) : 15,
'lockout_minutes' => isset( $_POST['lockout_minutes'] ) ? min( 10080, max( 1, absint( $_POST['lockout_minutes'] ) ) ) : 30,
'generic_errors' => empty( $_POST['generic_errors'] ) ? 0 : 1,
'block_enum' => empty( $_POST['block_enum'] ) ? 0 : 1,
'uploads_php' => $uploads ? 1 : 0,
)
);
Malsift_Firewall::set_uploads_block( $uploads );
wp_safe_redirect( add_query_arg( 'saved', '1', self::page_url( 'malsift-firewall' ) ) );
exit;
}
/**
* Quick actions from the firewall log.
*/
public static function firewall_event() {
if ( ! current_user_can( self::cap() ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'malsift' ) );
}
check_admin_referer( 'malsift_fw_event' );
$op = isset( $_GET['op'] ) ? sanitize_key( wp_unslash( $_GET['op'] ) ) : '';
$val = isset( $_GET['val'] ) ? sanitize_text_field( wp_unslash( $_GET['val'] ) ) : '';
$s = Malsift_Firewall::settings();
$msg = '';
if ( 'allow_rule' === $op && isset( Malsift_Firewall::rules()[ $val ] ) ) {
Malsift_Firewall::update( array( 'disabled_rules' => array_values( array_unique( array_merge( $s['disabled_rules'], array( $val ) ) ) ) ) );
$msg = 'rule';
} elseif ( 'block_ip' === $op && filter_var( $val, FILTER_VALIDATE_IP ) && $val !== Malsift_Firewall::client_ip() ) {
Malsift_Firewall::update( array( 'block_ips' => array_values( array_unique( array_merge( $s['block_ips'], array( $val ) ) ) ) ) );
$msg = 'blocked';
} elseif ( 'allow_ip' === $op && filter_var( $val, FILTER_VALIDATE_IP ) ) {
Malsift_Firewall::update( array( 'allow_ips' => array_values( array_unique( array_merge( $s['allow_ips'], array( $val ) ) ) ) ) );
Malsift_Firewall::unlock( $val );
$msg = 'allowed';
} elseif ( 'clear' === $op ) {
Malsift_Firewall::clear_log();
$msg = 'cleared';
}
wp_safe_redirect( add_query_arg( 'done', $msg, self::page_url( 'malsift-firewall' ) ) );
exit;
}
/**
* Link for a firewall log action.
*
* @param string $op Operation.
* @param string $val Value.
* @return string
*/
private static function fw_link( $op, $val = '' ) {
return wp_nonce_url(
add_query_arg(
array(
'action' => 'malsift_fw_event',
'op' => $op,
'val' => rawurlencode( $val ),
),
admin_url( 'admin-post.php' )
),
'malsift_fw_event'
);
}
/**
* Firewall screen.
*/
private static function page_firewall() {
$s = Malsift_Firewall::settings();
$labels = Malsift_Firewall::labels();
$stats = Malsift_Firewall::stats( 7 );
$log = Malsift_Firewall::log();
$me = Malsift_Firewall::client_ip();
$headers = Malsift_Firewall::proxy_headers();
$htaccess = Malsift_Firewall::server_supports_htaccess();
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only.
if ( isset( $_GET['saved'] ) ) {
echo '' . esc_html__( 'Firewall settings saved.', 'malsift' ) . '
';
}
$done = isset( $_GET['done'] ) ? sanitize_key( wp_unslash( $_GET['done'] ) ) : '';
// phpcs:enable
$messages = array(
'rule' => __( 'That rule is now switched off. You can switch it back on below.', 'malsift' ),
'blocked' => __( 'IP address added to the block list.', 'malsift' ),
'allowed' => __( 'IP address added to the allow list and unlocked.', 'malsift' ),
'cleared' => __( 'Firewall log cleared.', 'malsift' ),
);
if ( isset( $messages[ $done ] ) ) {
echo '' . esc_html( $messages[ $done ] ) . '
';
}
$mode_text = array(
'off' => array( 'warn', __( 'Off', 'malsift' ), __( 'The firewall is off. Login protection and the other options below still work.', 'malsift' ) ),
'learning' => array( 'warn', __( 'Learning mode', 'malsift' ), __( 'Attacks are logged but not blocked. Check the log below for a few days; if nothing legitimate shows up, switch to Protect.', 'malsift' ) ),
'protect' => array( 'ok', __( 'Protecting', 'malsift' ), __( 'Requests that look like attacks are blocked. Logged-in editors and administrators are never blocked.', 'malsift' ) ),
);
$m = $mode_text[ $s['mode'] ];
$colors = array(
'off' => '#9ca3af',
'learning' => '#d97706',
'protect' => '#059669',
);
$blocks = self::daily_blocks( 7 );
$counts = array();
foreach ( $log as $e ) {
if ( $e['time'] >= time() - WEEK_IN_SECONDS && 'login_lockout' !== $e['rule'] ) {
$counts[ $e['rule'] ] = ( isset( $counts[ $e['rule'] ] ) ? $counts[ $e['rule'] ] : 0 ) + (int) $e['count'];
}
}
arsort( $counts );
$top = key( $counts );
?>
$def ) : ?>
'info',
'label' => __( 'Not checked yet', 'malsift' ),
);
?>
%s %s%s %s ',
esc_attr( 'threat' === $f['severity'] ? 'bad' : 'warn' ),
esc_html( 'threat' === $f['severity'] ? __( 'Threat', 'malsift' ) : __( 'Warning', 'malsift' ) ),
esc_html( $f['title'] ),
$target ? '' . esc_html( $target ) . '' : '',
esc_html( $status )
);
}
if ( ! $any ) {
echo '' . esc_html__( 'No issues found.', 'malsift' ) . ' ';
}
?>