PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-audit-alerts.php

class-audit-alerts.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.8, at includes/class-audit-alerts.php

474 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Audit Alerts engine
4 *
5 * Alerting layer that sits on top of Security Audit. It subscribes to the
6 * `vigilante_event_logged` action (fired by Vigilante_Activity_Log::log()
7 * after an event passes every gate) and emails the configured recipients when:
8 *
9 * - #38 Immediate: a logged event reaches the configured minimum severity.
10 * - #10 Threshold: a category exceeds its event count within a time window.
11 *
12 * It is a passive subscriber: it never reaches into the security modules, so
13 * the per-module emails that already exist (User Security admin monitoring,
14 * Plugin Status, Login Security, File Integrity) keep working untouched. The
15 * engine only runs when the Security Audit (activity_log) module is enabled.
16 *
17 * Throttling: a per-category cooldown turns an event storm into a single
18 * email, and an anti-duplicate cooldown groups identical immediate events.
19 * Counters and cooldowns live in per-site transients, never in
20 * vigilante_options, keeping the export/import settings feature clean.
21 *
22 * @package Vigilante
23 */
24
25 // Prevent direct access.
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 /**
31 * Class Vigilante_Audit_Alerts
32 */
33 class Vigilante_Audit_Alerts {
34
35 /**
36 * Shared prefix for every transient this engine writes.
37 */
38 const TRANSIENT_PREFIX = 'vigilante_aa_';
39
40 /**
41 * Categories the threshold leg can watch. These are Activity Log
42 * `event_type` values. Used for defaults, the UI and uninstall cleanup.
43 *
44 * @return array Map of category slug => translated label.
45 */
46 public static function category_labels() {
47 return array(
48 // Security-facing categories first.
49 'firewall' => __( 'Firewall', 'vigilante' ),
50 'login' => __( 'Login', 'vigilante' ),
51 'user' => __( 'Users', 'vigilante' ),
52 'plugin' => __( 'Plugins', 'vigilante' ),
53 'file' => __( 'File integrity', 'vigilante' ),
54 'security' => __( 'Security', 'vigilante' ),
55 'system' => __( 'System', 'vigilante' ),
56 'settings' => __( 'Settings', 'vigilante' ),
57 // Content/activity categories that can also log warnings.
58 'theme' => __( 'Themes', 'vigilante' ),
59 'content' => __( 'Content', 'vigilante' ),
60 'comment' => __( 'Comments', 'vigilante' ),
61 'media' => __( 'Media', 'vigilante' ),
62 );
63 }
64
65 /**
66 * Severity ranking for the immediate-alert minimum-severity comparison.
67 *
68 * @var array
69 */
70 private static $severity_rank = array(
71 'info' => 1,
72 'warning' => 2,
73 'critical' => 3,
74 );
75
76 /**
77 * Settings instance.
78 *
79 * @var Vigilante_Settings
80 */
81 private $settings;
82
83 /**
84 * Activity log instance (kept for parity with other modules; unused here).
85 *
86 * @var Vigilante_Activity_Log|null
87 */
88 private $activity_log;
89
90 /**
91 * Constructor.
92 *
93 * @param Vigilante_Settings $settings Settings instance.
94 * @param Vigilante_Activity_Log|null $activity_log Activity log instance.
95 */
96 public function __construct( $settings, $activity_log = null ) {
97 $this->settings = $settings;
98 $this->activity_log = $activity_log;
99
100 add_action( 'vigilante_event_logged', array( $this, 'on_event_logged' ), 10, 4 );
101 }
102
103 // =========================================================================
104 // Event handling
105 // =========================================================================
106
107 /**
108 * Single entry point for every logged security event.
109 *
110 * @param string $type Event type (login, user, plugin, firewall, ...).
111 * @param string $action Event action.
112 * @param string $severity Severity: info, warning, critical.
113 * @param array $context Event context from the activity log.
114 */
115 public function on_event_logged( $type, $action, $severity, $context ) {
116 // System/maintenance noise never triggers an alert.
117 if ( 'system' === $type ) {
118 return;
119 }
120
121 $config = (array) $this->settings->get_section( 'audit_alerts' );
122 if ( empty( $config ) ) {
123 return;
124 }
125
126 // Shared anti-repeat cooldown applied to both legs.
127 $cooldown = isset( $config['cooldown_minutes'] ) ? max( 0, (int) $config['cooldown_minutes'] ) : 60;
128
129 $immediate = isset( $config['immediate'] ) ? (array) $config['immediate'] : array();
130 if ( ! empty( $immediate['enabled'] ) ) {
131 $this->maybe_immediate_alert( $type, $action, $severity, $context, $immediate, $cooldown );
132 }
133
134 $threshold = isset( $config['threshold'] ) ? (array) $config['threshold'] : array();
135 if ( ! empty( $threshold['enabled'] ) ) {
136 $this->record_and_check_threshold( $type, $severity, $threshold, $cooldown );
137 }
138 }
139
140 /**
141 * #38 Immediate alert: email right away if the event is severe enough.
142 *
143 * @param string $type Event type.
144 * @param string $action Event action.
145 * @param string $severity Event severity.
146 * @param array $context Event context.
147 * @param array $immediate Immediate-leg settings.
148 * @param int $cooldown Shared anti-repeat cooldown, in minutes.
149 */
150 private function maybe_immediate_alert( $type, $action, $severity, $context, $immediate, $cooldown ) {
151 $min = isset( $immediate['min_severity'] ) ? $immediate['min_severity'] : 'critical';
152 $event_rank = isset( self::$severity_rank[ $severity ] ) ? self::$severity_rank[ $severity ] : 1;
153 $min_rank = isset( self::$severity_rank[ $min ] ) ? self::$severity_rank[ $min ] : 3;
154
155 if ( $event_rank < $min_rank ) {
156 return;
157 }
158
159 // Don't repeat the same event type+action within the cooldown, so a
160 // burst of the same kind of event becomes one email, not a storm.
161 $cooldown = max( 0, (int) $cooldown );
162 $key = self::TRANSIENT_PREFIX . 'imm_' . md5( $type . ':' . $action );
163
164 if ( $cooldown > 0 && get_transient( $key ) ) {
165 return;
166 }
167
168 $this->send_immediate_email( $type, $action, $severity, $context );
169
170 if ( $cooldown > 0 ) {
171 set_transient( $key, 1, $cooldown * MINUTE_IN_SECONDS );
172 }
173 }
174
175 /**
176 * #10 Threshold alert: count warning/critical events per category and email
177 * when a category exceeds its configured count within the window, respecting
178 * the cooldown. Info-level events (a normal login, a saved post) are ignored
179 * so routine activity does not trip the alert.
180 *
181 * @param string $type Event type (acts as the category).
182 * @param string $severity Event severity (only warning/critical is counted).
183 * @param array $threshold Threshold-leg settings.
184 * @param int $cooldown Shared anti-repeat cooldown, in minutes.
185 */
186 private function record_and_check_threshold( $type, $severity, $threshold, $cooldown ) {
187 // Count only meaningful events; routine info-level activity is not a spike.
188 if ( 'warning' !== $severity && 'critical' !== $severity ) {
189 return;
190 }
191
192 $categories = isset( $threshold['categories'] ) ? (array) $threshold['categories'] : array();
193
194 // Only watch categories the user configured with a positive limit.
195 if ( ! isset( $categories[ $type ] ) ) {
196 return;
197 }
198 $limit = (int) $categories[ $type ];
199 if ( $limit <= 0 ) {
200 return;
201 }
202
203 $window = isset( $threshold['window'] ) ? $threshold['window'] : '1h';
204 $window_seconds = $this->window_to_seconds( $window );
205
206 // Sliding window via the transient TTL: each event renews the TTL, so a
207 // sustained burst keeps counting; if events stop, the window expires.
208 $count_key = self::TRANSIENT_PREFIX . 'count_' . $type;
209 $count = (int) get_transient( $count_key ) + 1;
210 set_transient( $count_key, $count, $window_seconds );
211
212 if ( $count < $limit ) {
213 return;
214 }
215
216 // Threshold reached. Honour the shared cooldown for this category so one
217 // storm is one email even though the counter keeps climbing.
218 $cooldown = max( 0, (int) $cooldown );
219 $cooldown_key = self::TRANSIENT_PREFIX . 'cooldown_' . $type;
220
221 if ( $cooldown > 0 && get_transient( $cooldown_key ) ) {
222 return;
223 }
224
225 $this->send_threshold_email( $type, $count, $limit, $window );
226
227 // Reset the counter so the next window starts clean, then start cooldown.
228 delete_transient( $count_key );
229 if ( $cooldown > 0 ) {
230 set_transient( $cooldown_key, 1, $cooldown * MINUTE_IN_SECONDS );
231 }
232 }
233
234 // =========================================================================
235 // Email builders
236 // =========================================================================
237
238 /**
239 * Build and send the immediate-alert email.
240 *
241 * @param string $type Event type.
242 * @param string $action Event action.
243 * @param string $severity Event severity.
244 * @param array $context Event context.
245 */
246 private function send_immediate_email( $type, $action, $severity, $context ) {
247 $recipients = Vigilante_Email_Template::get_admin_recipients();
248 if ( empty( $recipients ) ) {
249 return;
250 }
251
252 $type_label = $this->type_label( $type );
253 $message = isset( $context['message'] ) ? (string) $context['message'] : '';
254
255 $subject = sprintf(
256 /* translators: 1: site name, 2: event category label */
257 __( '[Vigilant] Security alert on %1$s: %2$s', 'vigilante' ),
258 wp_specialchars_decode( get_bloginfo( 'name' ) ),
259 $type_label
260 );
261
262 $body = Vigilante_Email_Template::alert_box( '' !== $message ? $message : $type_label );
263
264 $rows = array(
265 __( 'Event', 'vigilante' ) => $type_label . ( '' !== $action ? ' / ' . $action : '' ),
266 __( 'Severity', 'vigilante' ) => $this->severity_label( $severity ),
267 __( 'When', 'vigilante' ) => $this->now_label(),
268 );
269 if ( ! empty( $context['ip'] ) ) {
270 $rows[ __( 'IP address', 'vigilante' ) ] = $context['ip'];
271 }
272 if ( ! empty( $context['object_name'] ) ) {
273 $rows[ __( 'Subject', 'vigilante' ) ] = $context['object_name'];
274 }
275 $body .= Vigilante_Email_Template::data_table( $rows );
276
277 $body .= Vigilante_Email_Template::small(
278 __( 'This event matched your immediate alert rule. Similar events within the cooldown window are grouped into this notice, so check the full audit for the complete picture.', 'vigilante' )
279 );
280 $body .= Vigilante_Email_Template::button( $this->audit_url(), __( 'View Security Audit', 'vigilante' ) );
281
282 Vigilante_Email_Template::send( $recipients, $subject, __( 'Security alert', 'vigilante' ), $body, true );
283 }
284
285 /**
286 * Build and send the threshold-alert email.
287 *
288 * @param string $type Event type (category).
289 * @param int $count Events counted in the window.
290 * @param int $limit Configured threshold.
291 * @param string $window Window slug (1h/6h/24h).
292 */
293 private function send_threshold_email( $type, $count, $limit, $window ) {
294 $recipients = Vigilante_Email_Template::get_admin_recipients();
295 if ( empty( $recipients ) ) {
296 return;
297 }
298
299 $type_label = $this->type_label( $type );
300 $window_label = $this->window_label( $window );
301
302 $subject = sprintf(
303 /* translators: 1: site name, 2: event category label */
304 __( '[Vigilant] Unusual activity on %1$s: %2$s', 'vigilante' ),
305 wp_specialchars_decode( get_bloginfo( 'name' ) ),
306 $type_label
307 );
308
309 $body = Vigilante_Email_Template::warning_box(
310 sprintf(
311 /* translators: 1: number of events, 2: category label, 3: time window */
312 __( '%1$d %2$s events were recorded within %3$s, above your alert threshold.', 'vigilante' ),
313 $count,
314 $type_label,
315 $window_label
316 )
317 );
318
319 $body .= Vigilante_Email_Template::data_table(
320 array(
321 __( 'Category', 'vigilante' ) => $type_label,
322 __( 'Events', 'vigilante' ) => $count,
323 __( 'Threshold', 'vigilante' ) => $limit,
324 __( 'Window', 'vigilante' ) => $window_label,
325 __( 'When', 'vigilante' ) => $this->now_label(),
326 )
327 );
328
329 $body .= Vigilante_Email_Template::small(
330 __( 'Further events in this category will not re-alert until the cooldown passes.', 'vigilante' )
331 );
332 $body .= Vigilante_Email_Template::button( $this->audit_url(), __( 'View Security Audit', 'vigilante' ) );
333
334 Vigilante_Email_Template::send( $recipients, $subject, __( 'Unusual activity detected', 'vigilante' ), $body, true );
335 }
336
337 // =========================================================================
338 // Active-state helpers (used by Dashboard, Configuration Score, Analyzer)
339 // =========================================================================
340
341 /**
342 * Whether the immediate-alert leg is active.
343 *
344 * @param array $config The audit_alerts settings section.
345 * @return bool
346 */
347 public static function immediate_is_active( array $config ) {
348 return ! empty( $config['immediate']['enabled'] );
349 }
350
351 /**
352 * Whether the threshold leg is active with at least one category watched.
353 *
354 * @param array $config The audit_alerts settings section.
355 * @return bool
356 */
357 public static function threshold_is_active( array $config ) {
358 if ( empty( $config['threshold']['enabled'] ) ) {
359 return false;
360 }
361 $categories = isset( $config['threshold']['categories'] ) ? (array) $config['threshold']['categories'] : array();
362 foreach ( $categories as $limit ) {
363 if ( (int) $limit > 0 ) {
364 return true;
365 }
366 }
367 return false;
368 }
369
370 /**
371 * Whether any alert leg is active.
372 *
373 * @param array $config The audit_alerts settings section.
374 * @return bool
375 */
376 public static function has_active_alerts( array $config ) {
377 return self::immediate_is_active( $config ) || self::threshold_is_active( $config );
378 }
379
380 // =========================================================================
381 // Small helpers
382 // =========================================================================
383
384 /**
385 * Translate a window slug to seconds.
386 *
387 * @param string $window 1h | 6h | 24h.
388 * @return int Seconds.
389 */
390 private function window_to_seconds( $window ) {
391 switch ( $window ) {
392 case '30m':
393 return 30 * MINUTE_IN_SECONDS;
394 case '6h':
395 return 6 * HOUR_IN_SECONDS;
396 case '24h':
397 return DAY_IN_SECONDS;
398 case '1h':
399 default:
400 return HOUR_IN_SECONDS;
401 }
402 }
403
404 /**
405 * Human label for a window slug.
406 *
407 * @param string $window 1h | 6h | 24h.
408 * @return string
409 */
410 private function window_label( $window ) {
411 switch ( $window ) {
412 case '30m':
413 return __( '30 minutes', 'vigilante' );
414 case '6h':
415 return __( '6 hours', 'vigilante' );
416 case '24h':
417 return __( '24 hours', 'vigilante' );
418 case '1h':
419 default:
420 return __( '1 hour', 'vigilante' );
421 }
422 }
423
424 /**
425 * Human label for an event category / type.
426 *
427 * @param string $type Event type.
428 * @return string
429 */
430 private function type_label( $type ) {
431 $labels = self::category_labels();
432 if ( isset( $labels[ $type ] ) ) {
433 return $labels[ $type ];
434 }
435 return ucfirst( str_replace( '_', ' ', $type ) );
436 }
437
438 /**
439 * Human label for a severity.
440 *
441 * @param string $severity info | warning | critical.
442 * @return string
443 */
444 private function severity_label( $severity ) {
445 switch ( $severity ) {
446 case 'critical':
447 return __( 'Critical', 'vigilante' );
448 case 'warning':
449 return __( 'Warning', 'vigilante' );
450 case 'info':
451 default:
452 return __( 'Info', 'vigilante' );
453 }
454 }
455
456 /**
457 * Localized "now" timestamp for the email body.
458 *
459 * @return string
460 */
461 private function now_label() {
462 return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );
463 }
464
465 /**
466 * URL to the Security Audit recent-activity feed.
467 *
468 * @return string
469 */
470 private function audit_url() {
471 return admin_url( 'admin.php?page=vigilante&tab=activity-log#vigilante-section-audit-recent' );
472 }
473 }
474