| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Simple functionality. | |
| 3 | + * Responsible for handling audit logs. | |
| 4 | 4 | * |
| 5 | 5 | * @package WP_Defender\Component |
| 6 | 6 | */ |
| 7 | 7 | |
| @@ -6,20 +6,34 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | namespace WP_Defender\Component; |
| 9 | 9 | |
| 10 | +use WP_Error; | |
| 11 | +use DateTime; | |
| 12 | +use Exception; | |
| 13 | +use Countable; | |
| 14 | +use DateInterval; | |
| 15 | +use WP_Defender\Traits\IO; | |
| 10 | 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; | |
| 11 | 22 | |
| 12 | 23 | /** |
| 13 | - * Simple methods. | |
| 24 | + * Provides methods for fetching, querying, and managing audit logs. | |
| 14 | 25 | */ |
| 15 | 26 | class Audit extends Component { |
| 16 | 27 | |
| 28 | + use IO; | |
| 29 | + use Formats; | |
| 30 | + | |
| 17 | 31 | public const AUDIT_LOG = 'audit.log'; |
| 18 | 32 | public const CACHE_LAST_CHECKPOINT = 'wd_audit_fetch_checkpoint'; |
| 19 | 33 | |
| 20 | 34 | /** |
| 21 | - * Dummy. | |
| 35 | + * Fetches audit logs, either from local storage or via API if not available locally. | |
| 22 | 36 | * |
| 23 | 37 | * @param int $date_from Start date for fetching logs. |
| 24 | 38 | * @param int $date_to End date for fetching logs. |
| 25 | 39 | * @param array $events Specific events to fetch. |
| @@ -27,34 +41,313 @@ | ||
| 27 | 41 | * @param string $ip IP address to filter logs. |
| 28 | 42 | * @param int $paged Pagination page number. |
| 29 | 43 | * @param int $per_page Number of logs per page. |
| 30 | 44 | * |
| 31 | - * @return array | |
| 45 | + * @return Audit_Log[]|WP_Error Returns an array of Audit_Log objects or WP_Error on failure. | |
| 46 | + * @throws Exception Throws exception on failure. | |
| 32 | 47 | */ |
| 33 | 48 | public function fetch( $date_from, $date_to, $events = array(), $user_id = '', $ip = '', $paged = 1, $per_page = 10 ) { |
| 34 | - return array(); | |
| 49 | + $internal = Audit_Log::query( $date_from, $date_to, $events, $user_id, $ip, $paged, $per_page ); | |
| 50 | + $this->log( sprintf( 'Found %s from local', count( $internal ) ), self::AUDIT_LOG ); | |
| 51 | + if ( ! wd_di()->get( WPMUDEV::class )->is_pro() ) { | |
| 52 | + return $internal; | |
| 53 | + } | |
| 54 | + $checkpoint = get_site_option( self::CACHE_LAST_CHECKPOINT ); | |
| 55 | + if ( false === $checkpoint ) { | |
| 56 | + // This case where user install the plugin, have some local data but never reach to Logs page, then check point will be today. | |
| 57 | + $checkpoint = time(); | |
| 58 | + } | |
| 59 | + $checkpoint = (int) $checkpoint; | |
| 60 | + $date_from = ! is_int( $date_from ) ? (int) $date_from : $date_from; | |
| 61 | + if ( 0 === count( $internal ) && $checkpoint > $date_from ) { | |
| 62 | + // Have to fetch from API. | |
| 63 | + $this->log( 'fetch from cloud', self::AUDIT_LOG ); | |
| 64 | + // Todo:need $paged as 'nopaging'-arg? | |
| 65 | + $cloud = $this->query_from_api( $date_from, $date_to ); | |
| 66 | + if ( is_wp_error( $cloud ) ) { | |
| 67 | + $this->log( sprintf( 'Fetch error %s', $cloud->get_error_message() ), self::AUDIT_LOG ); | |
| 68 | + | |
| 69 | + return $cloud; | |
| 70 | + } | |
| 71 | + if ( count( $cloud ) ) { | |
| 72 | + // No data from cloud too. | |
| 73 | + Audit_Log::mass_insert( $cloud ); | |
| 74 | + // Because this is roughly fetch, so we have to filter out again using the local data. | |
| 75 | + $internal = Audit_Log::query( $date_from, $date_to, $events, $user_id, $ip, $paged, $per_page ); | |
| 76 | + } | |
| 77 | + // Cache the last time fetch, this will be useful in case of mixed data. | |
| 78 | + update_site_option( self::CACHE_LAST_CHECKPOINT, $date_from ); | |
| 79 | + // This case we have the data, however, maybe it can be out of the cached range, so we have to check. | |
| 80 | + // Note that, the out of range only happen with date_from, as the local always have the newest data. | |
| 81 | + } elseif ( $checkpoint > $date_from ) { | |
| 82 | + // We have some data out of range, fetch and cache. | |
| 83 | + $this->log( | |
| 84 | + sprintf( | |
| 85 | + 'checkpoint %s - date from %s', | |
| 86 | + wp_date( 'Y-m-d H:i:s', $checkpoint ), | |
| 87 | + wp_date( 'Y-m-d H:i:s', $date_from ) | |
| 88 | + ), | |
| 89 | + self::AUDIT_LOG | |
| 90 | + ); | |
| 91 | + $cloud = $this->query_from_api( $date_from, $checkpoint ); | |
| 92 | + if ( is_wp_error( $cloud ) ) { | |
| 93 | + $this->log( sprintf( 'Fetch error %s', $cloud->get_error_message() ), self::AUDIT_LOG ); | |
| 94 | + | |
| 95 | + return $cloud; | |
| 96 | + } | |
| 97 | + if ( is_array( $cloud ) ) { | |
| 98 | + // Silence the error here, as we actually have data. | |
| 99 | + Audit_Log::mass_insert( $cloud ); | |
| 100 | + $internal = Audit_Log::query( $date_from, $date_to, $events, $user_id, $ip, $paged, $per_page ); | |
| 101 | + // Cache the last time fetch, this will be useful in case of mixed data. | |
| 102 | + update_site_option( self::CACHE_LAST_CHECKPOINT, $date_from ); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + return $internal; | |
| 35 | 107 | } |
| 36 | 108 | |
| 37 | 109 | /** |
| 38 | - * Dummy. | |
| 110 | + * Queries audit logs from the API. | |
| 111 | + * | |
| 112 | + * @param int $date_from Start date for the query. | |
| 113 | + * @param int $date_to End date for the query. | |
| 114 | + * | |
| 115 | + * @return array|WP_Error Returns an array of logs or WP_Error on failure. | |
| 116 | + * @throws Exception Throws exception on failure. | |
| 39 | 117 | */ |
| 40 | - public function flush() { | |
| 118 | + public function query_from_api( $date_from, $date_to ) { | |
| 119 | + $date_format = 'Y-m-d H:i:s'; | |
| 120 | + $date_to = wp_date( $date_format, $date_to ); | |
| 121 | + $date_from = wp_date( $date_format, $date_from ); | |
| 122 | + $args = array( | |
| 123 | + 'site_url' => network_site_url(), | |
| 124 | + 'order_by' => 'timestamp', | |
| 125 | + 'order' => 'desc', | |
| 126 | + 'nopaging' => true, | |
| 127 | + 'timezone' => get_option( 'gmt_offset' ), | |
| 128 | + 'date_from' => $date_from, | |
| 129 | + 'date_to' => $date_to, | |
| 130 | + ); | |
| 131 | + | |
| 132 | + $this->attach_behavior( WPMUDEV::class, WPMUDEV::class ); | |
| 133 | + $data = $this->make_wpmu_request( | |
| 134 | + WPMUDEV::API_AUDIT, | |
| 135 | + $args, | |
| 136 | + array( 'method' => 'GET' ), | |
| 137 | + true | |
| 138 | + ); | |
| 139 | + | |
| 140 | + if ( is_wp_error( $data ) ) { | |
| 141 | + $this->log( sprintf( 'Fetch error %s', $data->get_error_message() ), self::AUDIT_LOG ); | |
| 142 | + | |
| 143 | + return $data; | |
| 144 | + } | |
| 145 | + | |
| 146 | + if ( 'success' !== $data['status'] ) { | |
| 147 | + return new WP_Error( | |
| 148 | + Error_Code::API_ERROR, | |
| 149 | + esc_html__( 'Something wrong happen, please try again!', 'defender-security' ) | |
| 150 | + ); | |
| 151 | + } | |
| 152 | + | |
| 153 | + return $data['data']; | |
| 41 | 154 | } |
| 42 | 155 | |
| 156 | + | |
| 43 | 157 | /** |
| 44 | - * Dummy. | |
| 158 | + * Delete all local audit log entries from database and reset checkpoint. | |
| 159 | + * | |
| 160 | + * @return void | |
| 45 | 161 | */ |
| 162 | + public function reset(): void { | |
| 163 | + Audit_Log::truncate(); | |
| 164 | + delete_site_option( self::CACHE_LAST_CHECKPOINT ); | |
| 165 | + } | |
| 166 | + | |
| 167 | + /** | |
| 168 | + * Cleans up old logs based on storage settings. | |
| 169 | + * | |
| 170 | + * @throws Exception When the $duration cannot be parsed as an interval. | |
| 171 | + */ | |
| 46 | 172 | public function audit_clean_up_logs() { |
| 173 | + $audit_settings = wd_di()->get( Audit_Logging::class ); | |
| 174 | + $interval = $this->calculate_date_interval( $audit_settings->storage_days ); | |
| 175 | + // Since v5.7.0. | |
| 176 | + $interval = apply_filters( 'wpdef_audit_logs_store_backward', $interval ); | |
| 177 | + $interval = is_string( $interval ) ? $interval : (string) $interval; | |
| 178 | + | |
| 179 | + try { | |
| 180 | + $interval_obj = new DateInterval( $interval ); | |
| 181 | + } catch ( Exception ) { | |
| 182 | + // Fallback if the filter supplied an incorrect value. | |
| 183 | + $interval_obj = new DateInterval( 'P6M' ); | |
| 184 | + } | |
| 185 | + | |
| 186 | + $date_from = ( new DateTime() )->setTimezone( wp_timezone() ) | |
| 187 | + ->sub( new DateInterval( 'P1Y' ) ) | |
| 188 | + ->setTime( 0, 0, 0 ); | |
| 189 | + $date_to = ( new DateTime() )->setTimezone( wp_timezone() ) | |
| 190 | + ->sub( $interval_obj ); | |
| 191 | + | |
| 192 | + if ( $date_from < $date_to ) { | |
| 193 | + // Count the logs that should be deleted. | |
| 194 | + $logs_count = Audit_Log::count( $date_from->getTimestamp(), $date_to->getTimestamp() ); | |
| 195 | + if ( $logs_count > 0 ) { | |
| 196 | + $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 ); | |
| 197 | + // Since v5.0.0. | |
| 198 | + $delete_count = apply_filters( 'wpdef_audit_limit_deleted_logs', 50 ); | |
| 199 | + Audit_Log::delete_old_logs( | |
| 200 | + $date_from->getTimestamp(), | |
| 201 | + $date_to->getTimestamp(), | |
| 202 | + is_int( $delete_count ) ? $delete_count : (int) $delete_count, | |
| 203 | + ); | |
| 204 | + } | |
| 205 | + } | |
| 47 | 206 | } |
| 48 | 207 | |
| 49 | 208 | /** |
| 50 | - * Dummy. | |
| 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. | |
| 51 | 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 | + * Strips the protocol from a URL. | |
| 234 | + * | |
| 235 | + * @param string $url URL to process. | |
| 236 | + * | |
| 237 | + * @return string Returns the URL without the protocol. | |
| 238 | + */ | |
| 239 | + private function strip_protocol( $url ) { | |
| 240 | + $parts = wp_parse_url( $url ); | |
| 241 | + $host = $parts['host'] . ( $parts['path'] ?? null ); | |
| 242 | + | |
| 243 | + return rtrim( $host, '/' ); | |
| 244 | + } | |
| 245 | + | |
| 246 | + /** | |
| 247 | + * Returns the API endpoint. | |
| 248 | + * | |
| 249 | + * @return string Returns the API endpoint URL. | |
| 250 | + */ | |
| 251 | + private function get_endpoint(): string { | |
| 252 | + return defined( 'WPMUDEV_CUSTOM_AUDIT_SERVER' ) | |
| 253 | + ? constant( 'WPMUDEV_CUSTOM_AUDIT_SERVER' ) | |
| 254 | + : 'https://audit.wpmudev.org/'; | |
| 255 | + } | |
| 256 | + | |
| 257 | + /** | |
| 258 | + * Queue all the events listeners, so we can listen and build log base on user behaviors. | |
| 259 | + * Never catch if it runs from WP CLI or CRON. | |
| 260 | + */ | |
| 52 | 261 | public function enqueue_event_listener() { |
| 262 | + if ( ! wp_doing_cron() && ! defender_is_wp_cli() ) { | |
| 263 | + $events_class = array( | |
| 264 | + new Component\Audit\Comment_Audit(), | |
| 265 | + new Component\Audit\Core_Audit(), | |
| 266 | + new Component\Audit\Media_Audit(), | |
| 267 | + new Component\Audit\Post_Audit(), | |
| 268 | + new Component\Audit\User_Audit(), | |
| 269 | + new Component\Audit\Options_Audit(), | |
| 270 | + new Component\Audit\Menu_Audit(), | |
| 271 | + new Component\Audit\Theme_Audit(), | |
| 272 | + new Component\Audit\Feature_Audit(), | |
| 273 | + new Component\Audit\Password_Audit(), | |
| 274 | + new Component\Audit\Application_Password_Audit(), | |
| 275 | + ); | |
| 276 | + | |
| 277 | + foreach ( $events_class as $class ) { | |
| 278 | + // since 2.4.7. | |
| 279 | + $hooks = apply_filters( 'wp_defender_audit_hooks', $class->get_hooks() ); | |
| 280 | + foreach ( $hooks as $key => $hook ) { | |
| 281 | + $func = function () use ( $key, $hook, $class ) { | |
| 282 | + global $wp_filter; | |
| 283 | + if ( isset( $wp_filter['gettext'] ) ) { | |
| 284 | + $gettext_callbacks = $wp_filter['gettext']->callbacks; | |
| 285 | + | |
| 286 | + // Disable all gettext filters. | |
| 287 | + $wp_filter['gettext']->callbacks = array(); | |
| 288 | + } | |
| 289 | + | |
| 290 | + // This is arguments of the hook. | |
| 291 | + $args = func_get_args(); | |
| 292 | + // This is hook data, defined in each event class. | |
| 293 | + $class->build_log_data( $key, $args, $hook ); | |
| 294 | + | |
| 295 | + // Add all filters back for gettext. | |
| 296 | + if ( isset( $wp_filter['gettext'], $gettext_callbacks ) ) { | |
| 297 | + $wp_filter['gettext']->callbacks = $gettext_callbacks; | |
| 298 | + } | |
| 299 | + }; | |
| 300 | + add_action( | |
| 301 | + $key, | |
| 302 | + $func, | |
| 303 | + 11, | |
| 304 | + is_array( $hook['args'] ) || $hook['args'] instanceof Countable ? count( $hook['args'] ) : 0 | |
| 305 | + ); | |
| 306 | + } | |
| 307 | + } | |
| 308 | + } | |
| 53 | 309 | } |
| 54 | 310 | |
| 55 | 311 | /** |
| 56 | - * Dummy. | |
| 312 | + * Logs audit events. | |
| 57 | 313 | */ |
| 58 | 314 | public function log_audit_events() { |
| 315 | + $events = Array_Cache::get( 'logs', 'audit', array() ); | |
| 316 | + | |
| 317 | + if ( ! ( is_array( $events ) || $events instanceof Countable ? count( $events ) : 0 ) | |
| 318 | + || ! class_exists( Audit_Log::class ) | |
| 319 | + ) { | |
| 320 | + return; | |
| 321 | + } | |
| 322 | + $model = new Audit_Log(); | |
| 323 | + | |
| 324 | + if ( ( is_array( $events ) || $events instanceof Countable ? count( $events ) : 0 ) > 1 ) { | |
| 325 | + if ( $model->has_method( 'mass_insert' ) ) { | |
| 326 | + $model->mass_insert( $events, 0 ); | |
| 327 | + | |
| 328 | + return; | |
| 329 | + } | |
| 330 | + } | |
| 331 | + | |
| 332 | + foreach ( $events as $event ) { | |
| 333 | + $model->import( $event ); | |
| 334 | + $model->synced = 0; | |
| 335 | + $model->save(); | |
| 336 | + } | |
| 337 | + } | |
| 338 | + | |
| 339 | + /** | |
| 340 | + * Get audit events per week. | |
| 341 | + */ | |
| 342 | + public static function get_audit_events_per_week(): int { | |
| 343 | + $timezone = wp_timezone(); | |
| 344 | + $date_to = ( new DateTime( 'now', $timezone ) ) | |
| 345 | + ->setTime( 23, 59, 59 ) | |
| 346 | + ->getTimestamp(); | |
| 347 | + $date_from = ( new DateTime( '-7 days', $timezone ) ) | |
| 348 | + ->setTime( 0, 0, 0 ) | |
| 349 | + ->getTimestamp(); | |
| 350 | + | |
| 351 | + return Audit_Log::count( $date_from, $date_to ); | |
| 59 | 352 | } |
| 60 | 353 | } |