PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.7.2
Defender Security – Malware Scanner, Login Security & Firewall v5.7.2
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / component / class-audit.php

class-audit.php in Defender Security – Malware Scanner, Login Security & Firewall 5.7.2, at src/component/class-audit.php

422 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Responsible for handling audit logs.
4 *
5 * @package WP_Defender\Component
6 */
7
8 namespace WP_Defender\Component;
9
10 use WP_Error;
11 use DateTime;
12 use Exception;
13 use Countable;
14 use DateInterval;
15 use WP_Defender\Traits\IO;
16 use WP_Defender\Component;
17 use WP_Defender\Traits\Formats;
18 use Calotes\Helper\Array_Cache;
19 use WP_Defender\Model\Audit_Log;
20 use WP_Defender\Behavior\WPMUDEV;
21 use WP_Defender\Model\Setting\Audit_Logging;
22
23 /**
24 * Provides methods for fetching, querying, and managing audit logs.
25 */
26 class Audit extends Component {
27
28 use IO;
29 use Formats;
30
31 public const AUDIT_LOG = 'audit.log';
32 public const CACHE_LAST_CHECKPOINT = 'wd_audit_fetch_checkpoint';
33
34 /**
35 * Fetches audit logs, either from local storage or via API if not available locally.
36 *
37 * @param int $date_from Start date for fetching logs.
38 * @param int $date_to End date for fetching logs.
39 * @param array $events Specific events to fetch.
40 * @param string $user_id User ID to filter logs.
41 * @param string $ip IP address to filter logs.
42 * @param int $paged Pagination page number.
43 *
44 * @return Audit_Log[]|WP_Error Returns an array of Audit_Log objects or WP_Error on failure.
45 * @throws Exception Throws exception on failure.
46 */
47 public function fetch( $date_from, $date_to, $events = array(), $user_id = '', $ip = '', $paged = 1 ) {
48 $internal = Audit_Log::query( $date_from, $date_to, $events, $user_id, $ip, $paged );
49 $this->log( sprintf( 'Found %s from local', count( $internal ) ), self::AUDIT_LOG );
50 $checkpoint = get_site_option( self::CACHE_LAST_CHECKPOINT );
51 if ( false === $checkpoint ) {
52 // This case where user install the plugin, have some local data but never reach to Logs page, then check point will be today.
53 $checkpoint = time();
54 }
55 $checkpoint = (int) $checkpoint;
56 $date_from = (int) $date_from;
57 if ( 0 === count( $internal ) && $checkpoint > $date_from ) {
58 // Have to fetch from API.
59 $this->log( 'fetch from cloud', self::AUDIT_LOG );
60 // Todo:need $paged as 'nopaging'-arg?
61 $cloud = $this->query_from_api( $date_from, $date_to );
62 if ( is_wp_error( $cloud ) ) {
63 $this->log( sprintf( 'Fetch error %s', $cloud->get_error_message() ), self::AUDIT_LOG );
64
65 return $cloud;
66 }
67 if ( count( $cloud ) ) {
68 // No data from cloud too.
69 Audit_Log::mass_insert( $cloud );
70 // Because this is roughly fetch, so we have to filter out again using the local data.
71 $internal = Audit_Log::query( $date_from, $date_to, $events, $user_id, $ip, $paged );
72 }
73 // Cache the last time fetch, this will be useful in case of mixed data.
74 update_site_option( self::CACHE_LAST_CHECKPOINT, $date_from );
75 // This case we have the data, however, maybe it can be out of the cached range, so we have to check.
76 // Note that, the out of range only happen with date_from, as the local always have the newest data.
77 } elseif ( $checkpoint > $date_from ) {
78 // We have some data out of range, fetch and cache.
79 $this->log(
80 sprintf(
81 'checkpoint %s - date from %s',
82 wp_date( 'Y-m-d H:i:s', $checkpoint ),
83 wp_date( 'Y-m-d H:i:s', $date_from )
84 ),
85 self::AUDIT_LOG
86 );
87 $cloud = $this->query_from_api( $date_from, $checkpoint );
88 if ( is_wp_error( $cloud ) ) {
89 $this->log( sprintf( 'Fetch error %s', $cloud->get_error_message() ), self::AUDIT_LOG );
90
91 return $cloud;
92 }
93 if ( is_array( $cloud ) ) {
94 // Silence the error here, as we actually have data.
95 Audit_Log::mass_insert( $cloud );
96 $internal = Audit_Log::query( $date_from, $date_to, $events, $user_id, $ip, $paged );
97 // Cache the last time fetch, this will be useful in case of mixed data.
98 update_site_option( self::CACHE_LAST_CHECKPOINT, $date_from );
99 }
100 }
101
102 return $internal;
103 }
104
105 /**
106 * Queries audit logs from the API.
107 *
108 * @param int $date_from Start date for the query.
109 * @param int $date_to End date for the query.
110 *
111 * @return array|WP_Error Returns an array of logs or WP_Error on failure.
112 * @throws Exception Throws exception on failure.
113 */
114 public function query_from_api( $date_from, $date_to ) {
115 $date_format = 'Y-m-d H:i:s';
116 $date_to = wp_date( $date_format, $date_to );
117 $date_from = wp_date( $date_format, $date_from );
118 $args = array(
119 'site_url' => network_site_url(),
120 'order_by' => 'timestamp',
121 'order' => 'desc',
122 'nopaging' => true,
123 'timezone' => get_option( 'gmt_offset' ),
124 'date_from' => $date_from,
125 'date_to' => $date_to,
126 );
127
128 $this->attach_behavior( WPMUDEV::class, WPMUDEV::class );
129 $data = $this->make_wpmu_request(
130 WPMUDEV::API_AUDIT,
131 $args,
132 array( 'method' => 'GET' ),
133 true
134 );
135
136 if ( is_wp_error( $data ) ) {
137 $this->log( sprintf( 'Fetch error %s', $data->get_error_message() ), self::AUDIT_LOG );
138
139 return $data;
140 }
141
142 if ( 'success' !== $data['status'] ) {
143 return new WP_Error(
144 Error_Code::API_ERROR,
145 esc_html__( 'Something wrong happen, please try again!', 'defender-security' )
146 );
147 }
148
149 return $data['data'];
150 }
151
152 /**
153 * Flushes logs that need to be synced with the cloud.
154 */
155 public function flush() {
156 $logs = Audit_Log::get_logs_need_flush();
157 // Build the data.
158 $data = array();
159 foreach ( $logs as $log ) {
160 $item = $log->export();
161 unset( $item['synced'] );
162 unset( $item['safe'] );
163 unset( $item['id'] );
164 $item['msg'] = addslashes( $item['msg'] );
165 $data[] = $item;
166 }
167 if ( count( $data ) ) {
168 $ret = $this->curl_to_api( $data );
169 if ( ! is_wp_error( $ret ) ) {
170 $this->log( sprintf( 'API sync successful, marking %s logs as synced', count( $data ) ), self::AUDIT_LOG );
171 foreach ( $logs as $log ) {
172 $log->synced = 1;
173 $log->save();
174 }
175 }
176 }
177 }
178
179 /**
180 * Cleans up old logs based on storage settings.
181 *
182 * @throws Exception When the $duration cannot be parsed as an interval.
183 */
184 public function audit_clean_up_logs() {
185 $audit_settings = wd_di()->get( Audit_Logging::class );
186 $interval = $this->calculate_date_interval( $audit_settings->storage_days );
187 // Since v5.7.0.
188 $interval = (string) apply_filters( 'wpdef_audit_logs_store_backward', $interval );
189
190 try {
191 $interval_obj = new DateInterval( $interval );
192 } catch ( Exception ) {
193 // Fallback if the filter supplied an incorrect value.
194 $interval_obj = new DateInterval( 'P6M' );
195 }
196
197 $date_from = ( new DateTime() )->setTimezone( wp_timezone() )
198 ->sub( new DateInterval( 'P1Y' ) )
199 ->setTime( 0, 0, 0 );
200 $date_to = ( new DateTime() )->setTimezone( wp_timezone() )
201 ->sub( $interval_obj );
202
203 if ( $date_from < $date_to ) {
204 // Count the logs that should be deleted.
205 $logs_count = Audit_Log::count( $date_from->getTimestamp(), $date_to->getTimestamp() );
206 if ( $logs_count > 0 ) {
207 $this->log( 'Cleaning up old logs from ' . $date_from->format( 'Y-m-d H:i:s' ) . ' to ' . $date_to->format( 'Y-m-d H:i:s' ), self::AUDIT_LOG );
208 // Since v5.0.0.
209 $delete_count = (int) apply_filters( 'wpdef_audit_limit_deleted_logs', 50 );
210 Audit_Log::delete_old_logs(
211 $date_from->getTimestamp(),
212 $date_to->getTimestamp(),
213 $delete_count,
214 );
215 }
216 }
217 }
218
219 /**
220 * Sends data to the API using cURL.
221 *
222 * @param array $data Data to be sent to the API.
223 *
224 * @return mixed Returns the response from the API.
225 */
226 public function curl_to_api( $data ) {
227 $this->attach_behavior( WPMUDEV::class, WPMUDEV::class );
228 $ret = $this->make_wpmu_request(
229 WPMUDEV::API_AUDIT_ADD,
230 $data,
231 array(
232 'method' => 'POST',
233 'timeout' => 3,
234 'headers' => array(
235 'apikey' => $this->get_apikey(),
236 ),
237 )
238 );
239
240 return $ret;
241 }
242
243 /**
244 * Sends data to the API using a socket connection.
245 *
246 * @param array $data Data to be sent to the API.
247 *
248 * @return bool Returns true on success, false on failure.
249 */
250 public function socket_to_api( $data ) {
251 $sockets = Array_Cache::get( 'sockets', 'audit', array() );
252 // We will need to wait a bit.
253 if ( 0 === ( is_array( $sockets ) || $sockets instanceof Countable ? count( $sockets ) : 0 ) ) {
254 // Fall back.
255 return false;
256 }
257 $this->log(
258 sprintf( 'Flush %s to cloud', is_array( $data ) || $data instanceof Countable ? count( $data ) : 0 ),
259 self::AUDIT_LOG
260 );
261 $start_time = microtime( true );
262 $sks = $sockets;
263 $r = null;
264 $e = null;
265 if ( ( false === stream_select( $r, $sks, $e, 1 ) ) ) {
266 // This case error happen.
267 return false;
268 }
269
270 $fp = array_shift( $sockets );
271
272 $uri = '/logs/add_multiple';
273 $vars = http_build_query( $data );
274 $this->attach_behavior( WPMUDEV::class, WPMUDEV::class );
275 // We're sending data to the server, so we're not manipulating files. Ignore the error.
276 // @codingStandardsIgnoreStart
277 fwrite( $fp, 'POST ' . $uri . " HTTP/1.1\r\n" );
278 fwrite( $fp, 'Host: ' . $this->strip_protocol( $this->get_endpoint() ) . "\r\n" );
279 fwrite( $fp, "Content-Type: application/x-www-form-urlencoded\r\n" );
280 fwrite( $fp, 'Content-Length: ' . strlen( $vars ) . "\r\n" );
281 fwrite( $fp, 'apikey: ' . $this->get_apikey() . "\r\n" );
282 fwrite( $fp, "Connection: close\r\n" );
283 fwrite( $fp, "\r\n" );
284 fwrite( $fp, $vars );
285 stream_set_timeout( $fp, 5 );
286 $res = '';
287 while ( ! feof( $fp ) ) {
288 $res .= fgets( $fp, 1024 );
289 // Check if the transfer has taken too long.
290 $end_time = microtime( true );
291 if ( $end_time - $start_time > 3 ) {
292 fclose( $fp );
293 break;
294 }
295 }
296 // @codingStandardsIgnoreEnd
297 return true;
298 }
299
300 /**
301 * Open a socket to API for faster transmit.
302 */
303 public function open_socket() {
304 $sockets = Array_Cache::get( 'sockets', 'audit', array() );
305 $endpoint = $this->strip_protocol( $this->get_endpoint() );
306 if ( empty( $sockets ) ) {
307 $fp = stream_socket_client(
308 'ssl://' . $endpoint . ':443',
309 $errno,
310 $errstr,
311 5
312 );
313 if ( is_resource( $fp ) ) {
314 Array_Cache::set( 'sockets', array( $fp ), 'audit' );
315 }
316 }
317 }
318
319 /**
320 * Strips the protocol from a URL.
321 *
322 * @param string $url URL to process.
323 *
324 * @return string Returns the URL without the protocol.
325 */
326 private function strip_protocol( $url ) {
327 $parts = wp_parse_url( $url );
328 $host = $parts['host'] . ( $parts['path'] ?? null );
329
330 return rtrim( $host, '/' );
331 }
332
333 /**
334 * Returns the API endpoint.
335 *
336 * @return string Returns the API endpoint URL.
337 */
338 private function get_endpoint(): string {
339 return defined( 'WPMUDEV_CUSTOM_AUDIT_SERVER' )
340 ? constant( 'WPMUDEV_CUSTOM_AUDIT_SERVER' )
341 : 'https://audit.wpmudev.org/';
342 }
343
344 /**
345 * Queue all the events listeners, so we can listen and build log base on user behaviors.
346 * Never catch if it runs from WP CLI or CRON.
347 */
348 public function enqueue_event_listener() {
349 if ( ! wp_doing_cron() && ! defender_is_wp_cli() ) {
350 $events_class = array(
351 new Component\Audit\Comment_Audit(),
352 new Component\Audit\Core_Audit(),
353 new Component\Audit\Media_Audit(),
354 new Component\Audit\Post_Audit(),
355 new Component\Audit\Users_Audit(),
356 new Component\Audit\Options_Audit(),
357 new Component\Audit\Menu_Audit(),
358 );
359
360 foreach ( $events_class as $class ) {
361 // since 2.4.7.
362 $hooks = apply_filters( 'wp_defender_audit_hooks', $class->get_hooks() );
363 foreach ( $hooks as $key => $hook ) {
364 $func = function () use ( $key, $hook, $class ) {
365 global $wp_filter;
366 if ( isset( $wp_filter['gettext'] ) ) {
367 $gettext_callbacks = $wp_filter['gettext']->callbacks;
368
369 // Disable all gettext filters.
370 $wp_filter['gettext']->callbacks = array();
371 }
372
373 // This is arguments of the hook.
374 $args = func_get_args();
375 // This is hook data, defined in each event class.
376 $class->build_log_data( $key, $args, $hook );
377
378 // Add all filters back for gettext.
379 if ( isset( $wp_filter['gettext'], $gettext_callbacks ) ) {
380 $wp_filter['gettext']->callbacks = $gettext_callbacks;
381 }
382 };
383 add_action(
384 $key,
385 $func,
386 11,
387 is_array( $hook['args'] ) || $hook['args'] instanceof Countable ? count( $hook['args'] ) : 0
388 );
389 }
390 }
391 }
392 }
393
394 /**
395 * Logs audit events.
396 */
397 public function log_audit_events() {
398 $events = Array_Cache::get( 'logs', 'audit', array() );
399
400 if ( ! ( is_array( $events ) || $events instanceof Countable ? count( $events ) : 0 )
401 || ! class_exists( Audit_Log::class )
402 ) {
403 return;
404 }
405 $model = new Audit_Log();
406
407 if ( ( is_array( $events ) || $events instanceof Countable ? count( $events ) : 0 ) > 1 ) {
408 if ( $model->has_method( 'mass_insert' ) ) {
409 $model->mass_insert( $events );
410
411 return;
412 }
413 }
414
415 foreach ( $events as $event ) {
416 $model->import( $event );
417 $model->synced = 0;
418 $model->save();
419 }
420 }
421 }
422