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

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