PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.6.0
Defender Security – Malware Scanner, Login Security & Firewall v5.6.0
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.6.0, at src/component/class-audit.php

412 lines 12.2 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 $date_from = ( new DateTime() )->setTimezone( wp_timezone() )
188 ->sub( new DateInterval( 'P1Y' ) )
189 ->setTime( 0, 0, 0 );
190 $date_to = ( new DateTime() )->setTimezone( wp_timezone() )
191 ->sub( new DateInterval( $interval ) );
192
193 if ( $date_from < $date_to ) {
194 // Count the logs that should be deleted.
195 $logs_count = Audit_Log::count( $date_from->getTimestamp(), $date_to->getTimestamp() );
196 if ( $logs_count > 0 ) {
197 $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 );
198 // Since v5.0.0.
199 $delete_count = (int) apply_filters( 'wpdef_audit_limit_deleted_logs', 50 );
200 Audit_Log::delete_old_logs(
201 $date_from->getTimestamp(),
202 $date_to->getTimestamp(),
203 $delete_count,
204 );
205 }
206 }
207 }
208
209 /**
210 * Sends data to the API using cURL.
211 *
212 * @param array $data Data to be sent to the API.
213 *
214 * @return mixed Returns the response from the API.
215 */
216 public function curl_to_api( $data ) {
217 $this->attach_behavior( WPMUDEV::class, WPMUDEV::class );
218 $ret = $this->make_wpmu_request(
219 WPMUDEV::API_AUDIT_ADD,
220 $data,
221 array(
222 'method' => 'POST',
223 'timeout' => 3,
224 'headers' => array(
225 'apikey' => $this->get_apikey(),
226 ),
227 )
228 );
229
230 return $ret;
231 }
232
233 /**
234 * Sends data to the API using a socket connection.
235 *
236 * @param array $data Data to be sent to the API.
237 *
238 * @return bool Returns true on success, false on failure.
239 */
240 public function socket_to_api( $data ) {
241 $sockets = Array_Cache::get( 'sockets', 'audit', array() );
242 // We will need to wait a bit.
243 if ( 0 === ( is_array( $sockets ) || $sockets instanceof Countable ? count( $sockets ) : 0 ) ) {
244 // Fall back.
245 return false;
246 }
247 $this->log(
248 sprintf( 'Flush %s to cloud', is_array( $data ) || $data instanceof Countable ? count( $data ) : 0 ),
249 self::AUDIT_LOG
250 );
251 $start_time = microtime( true );
252 $sks = $sockets;
253 $r = null;
254 $e = null;
255 if ( ( false === stream_select( $r, $sks, $e, 1 ) ) ) {
256 // This case error happen.
257 return false;
258 }
259
260 $fp = array_shift( $sockets );
261
262 $uri = '/logs/add_multiple';
263 $vars = http_build_query( $data );
264 $this->attach_behavior( WPMUDEV::class, WPMUDEV::class );
265 // We're sending data to the server, so we're not manipulating files. Ignore the error.
266 // @codingStandardsIgnoreStart
267 fwrite( $fp, 'POST ' . $uri . " HTTP/1.1\r\n" );
268 fwrite( $fp, 'Host: ' . $this->strip_protocol( $this->get_endpoint() ) . "\r\n" );
269 fwrite( $fp, "Content-Type: application/x-www-form-urlencoded\r\n" );
270 fwrite( $fp, 'Content-Length: ' . strlen( $vars ) . "\r\n" );
271 fwrite( $fp, 'apikey: ' . $this->get_apikey() . "\r\n" );
272 fwrite( $fp, "Connection: close\r\n" );
273 fwrite( $fp, "\r\n" );
274 fwrite( $fp, $vars );
275 stream_set_timeout( $fp, 5 );
276 $res = '';
277 while ( ! feof( $fp ) ) {
278 $res .= fgets( $fp, 1024 );
279 // Check if the transfer has taken too long.
280 $end_time = microtime( true );
281 if ( $end_time - $start_time > 3 ) {
282 fclose( $fp );
283 break;
284 }
285 }
286 // @codingStandardsIgnoreEnd
287 return true;
288 }
289
290 /**
291 * Open a socket to API for faster transmit.
292 */
293 public function open_socket() {
294 $sockets = Array_Cache::get( 'sockets', 'audit', array() );
295 $endpoint = $this->strip_protocol( $this->get_endpoint() );
296 if ( empty( $sockets ) ) {
297 $fp = stream_socket_client(
298 'ssl://' . $endpoint . ':443',
299 $errno,
300 $errstr,
301 5
302 );
303 if ( is_resource( $fp ) ) {
304 Array_Cache::set( 'sockets', array( $fp ), 'audit' );
305 }
306 }
307 }
308
309 /**
310 * Strips the protocol from a URL.
311 *
312 * @param string $url URL to process.
313 *
314 * @return string Returns the URL without the protocol.
315 */
316 private function strip_protocol( $url ) {
317 $parts = wp_parse_url( $url );
318 $host = $parts['host'] . ( $parts['path'] ?? null );
319
320 return rtrim( $host, '/' );
321 }
322
323 /**
324 * Returns the API endpoint.
325 *
326 * @return string Returns the API endpoint URL.
327 */
328 private function get_endpoint(): string {
329 return defined( 'WPMUDEV_CUSTOM_AUDIT_SERVER' )
330 ? constant( 'WPMUDEV_CUSTOM_AUDIT_SERVER' )
331 : 'https://audit.wpmudev.org/';
332 }
333
334 /**
335 * Queue all the events listeners, so we can listen and build log base on user behaviors.
336 * Never catch if it runs from WP CLI or CRON.
337 */
338 public function enqueue_event_listener() {
339 if ( ! wp_doing_cron() && ! defender_is_wp_cli() ) {
340 $events_class = array(
341 new Component\Audit\Comment_Audit(),
342 new Component\Audit\Core_Audit(),
343 new Component\Audit\Media_Audit(),
344 new Component\Audit\Post_Audit(),
345 new Component\Audit\Users_Audit(),
346 new Component\Audit\Options_Audit(),
347 new Component\Audit\Menu_Audit(),
348 );
349
350 foreach ( $events_class as $class ) {
351 // since 2.4.7.
352 $hooks = apply_filters( 'wp_defender_audit_hooks', $class->get_hooks() );
353 foreach ( $hooks as $key => $hook ) {
354 $func = function () use ( $key, $hook, $class ) {
355 global $wp_filter;
356 if ( isset( $wp_filter['gettext'] ) ) {
357 $gettext_callbacks = $wp_filter['gettext']->callbacks;
358
359 // Disable all gettext filters.
360 $wp_filter['gettext']->callbacks = array();
361 }
362
363 // This is arguments of the hook.
364 $args = func_get_args();
365 // This is hook data, defined in each event class.
366 $class->build_log_data( $key, $args, $hook );
367
368 // Add all filters back for gettext.
369 if ( isset( $wp_filter['gettext'], $gettext_callbacks ) ) {
370 $wp_filter['gettext']->callbacks = $gettext_callbacks;
371 }
372 };
373 add_action(
374 $key,
375 $func,
376 11,
377 is_array( $hook['args'] ) || $hook['args'] instanceof Countable ? count( $hook['args'] ) : 0
378 );
379 }
380 }
381 }
382 }
383
384 /**
385 * Logs audit events.
386 */
387 public function log_audit_events() {
388 $events = Array_Cache::get( 'logs', 'audit', array() );
389
390 if ( ! ( is_array( $events ) || $events instanceof Countable ? count( $events ) : 0 )
391 || ! class_exists( Audit_Log::class )
392 ) {
393 return;
394 }
395 $model = new Audit_Log();
396
397 if ( ( is_array( $events ) || $events instanceof Countable ? count( $events ) : 0 ) > 1 ) {
398 if ( $model->has_method( 'mass_insert' ) ) {
399 $model->mass_insert( $events );
400
401 return;
402 }
403 }
404
405 foreach ( $events as $event ) {
406 $model->import( $event );
407 $model->synced = 0;
408 $model->save();
409 }
410 }
411 }
412