compute_next_run( $frequency, $time, $day ); // Leave the existing event alone if it already runs at the chosen // recurrence and lands on the same wall-clock time (and weekday), so // saving unrelated settings does not push the next scan back. $existing = wp_get_scheduled_event( self::HOOK ); if ( $existing && isset( $existing->schedule ) && $existing->schedule === $recurrence && $this->event_matches( $existing, $frequency, $time, $day ) ) { return; } wp_clear_scheduled_hook( self::HOOK ); wp_schedule_event( $desired, $recurrence, self::HOOK ); } /** * Compute the Unix timestamp of the next run at the chosen local time (and * weekday for weekly), interpreted in the site's configured timezone. * * @param string $frequency 'daily' or 'weekly'. * @param string $time HH:MM, 24-hour, site timezone. * @param string $day Weekday key for weekly scans. * @return int Unix timestamp. */ protected function compute_next_run( $frequency, $time, $day ) { $tz = wp_timezone(); $parts = explode( ':', $time ); $hour = isset( $parts[0] ) ? (int) $parts[0] : 3; $min = isset( $parts[1] ) ? (int) $parts[1] : 0; try { $now = new DateTime( 'now', $tz ); $next = new DateTime( 'now', $tz ); } catch ( Exception $e ) { // Fall back to a simple offset if DateTime is somehow unavailable. return time() + HOUR_IN_SECONDS; } $next->setTime( $hour, $min, 0 ); if ( 'weekly' === $frequency ) { $days = Aardwolf_Security_Scanner::schedule_days(); $target = isset( $days[ $day ]['iso'] ) ? (int) $days[ $day ]['iso'] : 1; // Advance day-by-day (at most 7 steps) to the next matching weekday // that is strictly in the future at the chosen time. $guard = 0; while ( ( (int) $next->format( 'N' ) !== $target || $next <= $now ) && $guard < 8 ) { $next->modify( '+1 day' ); $next->setTime( $hour, $min, 0 ); $guard++; } } elseif ( $next <= $now ) { // Daily: if today's time has already passed, run tomorrow. $next->modify( '+1 day' ); $next->setTime( $hour, $min, 0 ); } return $next->getTimestamp(); } /** * Whether an already-scheduled event lands on the desired wall-clock time * (and weekday, for weekly), so we can avoid needless rescheduling. * * @param object $event Scheduled event from wp_get_scheduled_event(). * @param string $frequency 'daily' or 'weekly'. * @param string $time HH:MM in site timezone. * @param string $day Weekday key. * @return bool */ protected function event_matches( $event, $frequency, $time, $day ) { if ( empty( $event->timestamp ) ) { return false; } try { $dt = new DateTime( '@' . (int) $event->timestamp ); $dt->setTimezone( wp_timezone() ); } catch ( Exception $e ) { return false; } if ( $dt->format( 'H:i' ) !== $time ) { return false; } if ( 'weekly' === $frequency ) { $days = Aardwolf_Security_Scanner::schedule_days(); $target = isset( $days[ $day ]['iso'] ) ? (int) $days[ $day ]['iso'] : 1; if ( (int) $dt->format( 'N' ) !== $target ) { return false; } } return true; } /** * WP-Cron callback: run a scan and notify if the posture regressed. */ public function run_scheduled_scan() { $settings = Aardwolf_Security_Scanner::get_settings(); // Capture the previous result before it is overwritten so we can // detect a regression. $previous = Aardwolf_Scanner::get_last_scan(); $scanner = new Aardwolf_Scanner(); $current = $scanner->run(); $this->maybe_notify( $previous, $current, $settings ); } /** * Decide whether to email the admin, and do so. * * @param array|null $previous Previous scan payload. * @param array $current Current scan payload. * @param array $settings Plugin settings. */ protected function maybe_notify( $previous, $current, $settings ) { $to = ! empty( $settings['notify_email'] ) ? $settings['notify_email'] : get_option( 'admin_email' ); if ( ! is_email( $to ) ) { return; } $curr_score = isset( $current['summary']['score'] ) ? (int) $current['summary']['score'] : 0; $curr_probs = isset( $current['summary']['problems_total'] ) ? (int) $current['summary']['problems_total'] : 0; $curr_high = isset( $current['summary']['high_problems'] ) ? (int) $current['summary']['high_problems'] : 0; $notify_when = isset( $settings['notify_when'] ) ? $settings['notify_when'] : 'worse'; $should_notify = false; if ( 'always' === $notify_when ) { $should_notify = true; } else { // 'worse': notify when the score dropped, problems increased, or a // high-severity failure is present. if ( $previous ) { $prev_score = isset( $previous['summary']['score'] ) ? (int) $previous['summary']['score'] : 100; $prev_probs = isset( $previous['summary']['problems_total'] ) ? (int) $previous['summary']['problems_total'] : 0; if ( $curr_score < $prev_score || $curr_probs > $prev_probs ) { $should_notify = true; } } // Always alert on high-severity failures, even on a first run. if ( $curr_high > 0 ) { $should_notify = true; } } if ( ! $should_notify ) { return; } $this->send_email( $to, $previous, $current ); } /** * Build and send the notification email. * * @param string $to Recipient. * @param array|null $previous Previous scan payload. * @param array $current Current scan payload. */ protected function send_email( $to, $previous, $current ) { $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); $score = isset( $current['summary']['score'] ) ? (int) $current['summary']['score'] : 0; $grade = isset( $current['summary']['grade'] ) ? $current['summary']['grade'] : ''; $fails = isset( $current['summary']['fail'] ) ? (int) $current['summary']['fail'] : 0; $warns = isset( $current['summary']['warning'] ) ? (int) $current['summary']['warning'] : 0; /* translators: %s: site name. */ $subject = sprintf( __( '[%s] Aardwolf Security scan results', 'aardwolf-security-scanner' ), $site_name ); $lines = array(); $lines[] = sprintf( /* translators: 1: site name, 2: score, 3: grade. */ __( 'The scheduled security scan for %1$s finished with a score of %2$d/100 (grade %3$s).', 'aardwolf-security-scanner' ), $site_name, $score, $grade ); $lines[] = ''; $lines[] = sprintf( /* translators: 1: number failed, 2: number of warnings. */ __( 'Failed checks: %1$d Warnings: %2$d', 'aardwolf-security-scanner' ), $fails, $warns ); if ( $previous && isset( $previous['summary']['score'] ) ) { $lines[] = sprintf( /* translators: %d: previous score. */ __( 'Previous score: %d/100', 'aardwolf-security-scanner' ), (int) $previous['summary']['score'] ); } // List the highest-priority open findings. $problems = array(); if ( ! empty( $current['results'] ) ) { foreach ( $current['results'] as $r ) { if ( in_array( $r['status'], array( Aardwolf_Check_Result::STATUS_FAIL, Aardwolf_Check_Result::STATUS_WARN ), true ) ) { $problems[] = $r; } } } // Sort fails (high severity) first. usort( $problems, function ( $a, $b ) { $rank = array( 'high' => 0, 'medium' => 1, 'low' => 2, 'info' => 3 ); $sa = isset( $rank[ $a['severity'] ] ) ? $rank[ $a['severity'] ] : 4; $sb = isset( $rank[ $b['severity'] ] ) ? $rank[ $b['severity'] ] : 4; return $sa <=> $sb; } ); if ( ! empty( $problems ) ) { $lines[] = ''; $lines[] = __( 'Issues to review:', 'aardwolf-security-scanner' ); foreach ( array_slice( $problems, 0, 15 ) as $r ) { $lines[] = sprintf( '- [%s] %s', strtoupper( $r['severity'] ), wp_strip_all_tags( $r['label'] ) ); } } $lines[] = ''; $lines[] = __( 'Open the full report and remediation steps here:', 'aardwolf-security-scanner' ); $lines[] = admin_url( 'admin.php?page=aardwolf-security-scanner' ); $lines[] = ''; $lines[] = __( '— Aardwolf Security Scanner', 'aardwolf-security-scanner' ); $body = implode( "\n", $lines ); wp_mail( $to, $subject, $body ); } }