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

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