<?php
final class ITSEC_Log {
/* Critical issues are very important events that administrators should be notified about, such as finding malware
* on the site or detecting a security breach.
*/
public static function add_critical_issue( $module, $code, $data = false, $overrides = array() ) {
return self::add( $module, $code, $data, 'critical-issue', 0, $overrides );
}
/* Actions are noteworthy automated events that change the functionality of the site based upon certain criteria,
* such as locking out an IP address due to bruteforce attempts.
*/
public static function add_action( $module, $code, $data = false, $overrides = array() ) {
return self::add( $module, $code, $data, 'action', 0, $overrides );
}
/* Fatal errors are critical problems detected in the code that could and should be reserved for very rare but
* highly problematic situations, such as a catch handler in a try/catch block or a shutdown handler running before
* a process finishes.
*/
public static function add_fatal_error( $module, $code, $data = false, $overrides = array() ) {
return self::add( $module, $code, $data, 'fatal', 0, $overrides );
}
/* Errors are events that indicate a failure of some sort, such as failure to write to a file or an inability to
* request a remote URL.
*/
public static function add_error( $module, $code, $data = false, $overrides = array() ) {
return self::add( $module, $code, $data, 'error', 0, $overrides );
}
/* Warnings are noteworthy events that might indicate an issue, such as finding changed files.
*/
public static function add_warning( $module, $code, $data = false, $overrides = array() ) {
return self::add( $module, $code, $data, 'warning', 0, $overrides );
}
/* Notices keep track of events that should be tracked but do not necessarily indicate an issue, such as requests
* for files that do not exist and completed scans that did not find any issues.
*/
public static function add_notice( $module, $code, $data = false, $overrides = array() ) {
return self::add( $module, $code, $data, 'notice', 0, $overrides );
}
/* Debug events are to be used in situations where extra information about a specific process could be helpful to
* have when investigating an issue but the information would typically be uninteresting to the user, such as
* noting the use of a compatibility function.
*/
public static function add_debug( $module, $code, $data = false, $overrides = array() ) {
return self::add( $module, $code, $data, 'debug', 0, $overrides );
}
/* Process events allow for creating single entries that have a start, zero or more updates, and a stopping point.
* This allows for benchmarking performance of long-running code in addition to finding issues such as terminated
* execution due to the missing process-stop entry.
*/
public static function add_process_start( $module, $code, $data = false, $overrides = array() ) {
$id = self::add( $module, $code, $data, 'process-start', 0, $overrides );
return compact( 'module', 'code', 'id' );
}
public static function add_process_update( $reference, $data = false, $overrides = array() ) {
self::add( $reference['module'], $reference['code'], $data, 'process-update', $reference['id'], $overrides );
}
public static function add_process_stop( $reference, $data = false, $overrides = array() ) {
self::add( $reference['module'], $reference['code'], $data, 'process-stop', $reference['id'], $overrides );
}
private static function add( $module, $code, $data, $type, $parent_id = 0, $overrides = array() ) {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
$url = 'wp-cli';
} elseif ( ( is_callable( 'wp_doing_cron' ) && wp_doing_cron() ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
$url = 'wp-cron';
} elseif ( isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) {
$url = ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
} else {
$url = 'unknown';
}
$data = array_merge( array(
'parent_id' => $parent_id,
'module' => $module,
'code' => $code,
'data' => $data,
'type' => $type,
'timestamp' => gmdate( 'Y-m-d H:i:s' ),
'init_timestamp' => gmdate( 'Y-m-d H:i:s', ITSEC_Core::get_current_time_gmt() ),
'memory_current' => memory_get_usage(),
'memory_peak' => memory_get_peak_usage(),
'url' => $url,
'blog_id' => get_current_blog_id(),
'user_id' => get_current_user_id(),
'remote_ip' => ITSEC_Lib::get_ip(),
), $overrides );
$log_type = ITSEC_Modules::get_setting( 'global', 'log_type' );
if ( 'database' === $log_type ) {
$id = self::add_to_db( $data );
} elseif ( 'file' === $log_type ) {
$id = self::add_to_file( $data );
} else {
$id = self::add_to_db( $data );
self::add_to_file( $data, $id );
}
do_action( 'itsec_log_add', $data, $id, $log_type );
return $id;
}
private static function add_to_db( $data ) {
global $wpdb;
$format = array();
foreach ( $data as $key => $value ) {
if ( is_int( $value ) ) {
$format[] = '%d';
} else {
$format[] = '%s';
if ( ! is_string( $value ) ) {
$data[ $key ] = serialize( $value );
}
}
$col_length = $wpdb->get_col_length( "{$wpdb->base_prefix}itsec_logs", $key );
if ( is_array( $col_length ) ) {
$truncate_to = $col_length['length'] - 3;
if ( is_string( $value ) && strlen( $value ) > $truncate_to ) {
$truncated_value = substr( $value, 0, $truncate_to ) . '...';
$data[ $key ] = $truncated_value;
}
}
}
$result = $wpdb->insert( "{$wpdb->base_prefix}itsec_logs", $data, $format );
if ( false === $result ) {
error_log( "Failed to insert log entry: {$wpdb->last_error}" );
return new WP_Error( 'itsec-log-failed-db-insert', sprintf( esc_html__( 'Failed to insert log entry: %s', 'better-wp-security' ), $wpdb->last_error ) );
}
return $wpdb->insert_id;
}
private static function add_to_file( $data, $id = false ) {
if ( false === $id ) {
$id = microtime( true );
}
$file = self::get_log_file_path();
if ( is_wp_error( $file ) ) {
return $file;
}
$entries = array();
foreach ( $data as $value ) {
if ( is_object( $value ) || is_array( $value ) ) {
$value = serialize( $value );
} else {
$value = (string) $value;
}
$value = str_replace( '"', '""', $value );
if ( preg_match( '/[", ]/', $value ) ) {
$value = "\"$value\"";
}
$entries[] = $value;
}
$entry = implode( ',', $entries ) . "\n";
$result = file_put_contents( $file, $entry, FILE_APPEND );
if ( false === $result ) {
return new WP_Error( 'itsec-log-failed-to-write-to-file', __( 'Unable to write to the log file. This could indicate that there is no space available, that there is a permissions issue, or that the server is not configured properly.', 'better-wp-security' ) );
}
return $id;
}
public static function get_log_file_path() {
static $log_file = false;
if ( false !== $log_file ) {
return $log_file;
}
$log_location = ITSEC_Modules::get_setting( 'global', 'log_location' );
$log_info = ITSEC_Modules::get_setting( 'global', 'log_info' );
if ( empty( $log_info ) ) {
$log_info = substr( sanitize_title( get_bloginfo( 'name' ) ), 0, 20 ) . '-' . wp_generate_password( 30, false );
ITSEC_Modules::set_setting( 'global', 'log_info', $log_info );
}
$log_file = "$log_location/event-log-$log_info.log";
if ( ! file_exists( $log_file ) ) {
$header = "parent_id,module,code,data,type,timestamp,init_timestamp,memory_current,memory_peak,user_id,remote_ip\n";
file_put_contents( $log_file, $header );
}
return $log_file;
}
public static function get_entries( $filters = array(), $limit = 0, $page = 1, $sort_by_column = 'id', $sort_direction = 'DESC', $columns = false ) {
require_once( dirname( __FILE__ ) . '/log-util.php' );
return ITSEC_Log_Util::get_entries( $filters, $limit, $page, $sort_by_column, $sort_direction, $columns );
}
public static function get_entry( $id ) {
require_once( dirname( __FILE__ ) . '/log-util.php' );
$entries = ITSEC_Log_Util::get_entries( array( 'id' => $id ), 0, 1, 'id', 'DESC', 'all' );
return isset( $entries[0] ) ? $entries[0] : array();
}
public static function get_number_of_entries( $filters = array() ) {
$filters['__get_count'] = true;
return self::get_entries( $filters );
}
public static function get_type_counts( $min_timestamp = 0 ) {
require_once( dirname( __FILE__ ) . '/log-util.php' );
return ITSEC_Log_Util::get_type_counts( $min_timestamp );
}
public static function get_types_for_display() {
return array(
'critical-issue' => esc_html__( 'Critical Issue', 'better-wp-security' ),
'action' => esc_html__( 'Action', 'better-wp-security' ),
'fatal' => esc_html__( 'Fatal Error', 'better-wp-security' ),
'error' => esc_html__( 'Error', 'better-wp-security' ),
'warning' => esc_html__( 'Warning', 'better-wp-security' ),
'notice' => esc_html__( 'Notice', 'better-wp-security' ),
'debug' => esc_html__( 'Debug', 'better-wp-security' ),
'process-start' => esc_html__( 'Process', 'better-wp-security' ),
'process-update' => esc_html__( 'Process Update', 'better-wp-security' ),
'process-stop' => esc_html__( 'Process Stop', 'better-wp-security' ),
);
}
public static function register_events( $scheduler ) {
$scheduler->schedule( ITSEC_Scheduler::S_DAILY, 'purge-log-entries' );
}
public static function purge_entries() {
global $wpdb;
$database_entry_expiration = date( 'Y-m-d H:i:s', ITSEC_Core::get_current_time_gmt() - ( ITSEC_Modules::get_setting( 'global', 'log_rotation' ) * DAY_IN_SECONDS ) );
$query = $wpdb->prepare( "DELETE FROM `{$wpdb->base_prefix}itsec_logs` WHERE timestamp<%s", $database_entry_expiration );
$wpdb->query( $query );
$log_type = ITSEC_Modules::get_setting( 'global', 'log_type' );
if ( 'database' !== $log_type ) {
self::rotate_log_files();
}
}
public static function rotate_log_files() {
if ( $days_to_keep = ITSEC_Modules::get_setting( 'global', 'file_log_rotation' ) ) {
self::delete_old_logs( $days_to_keep );
}
$log = self::get_log_file_path();
$max_file_size = 10 * 1024 * 1024; // 10MiB
if ( ! file_exists( $log ) || filesize( $log ) < $max_file_size ) {
return;
}
$files = glob( "$log.*" );
foreach ( $files as $index => $file ) {
if ( ! preg_match( '/^' . preg_quote( $log, '/' ) . '\.\d+$/', $file ) ) {
unset( $files[ $index ] );
}
}
natsort( $files );
$files = array_values( $files );
$files_to_delete = array();
$files_to_rotate = array();
$max_files = apply_filters( 'itsec_log_max_log_files', 100 );
foreach ( $files as $index => $file ) {
$number = intval( pathinfo( $file, PATHINFO_EXTENSION ) );
if ( $number > $max_files ) {
$files_to_delete[] = $file;
} elseif ( $number === $index + 1 && $number !== $max_files ) {
$files_to_rotate[] = $file;
}
}
array_unshift( $files_to_rotate, $log );
krsort( $files_to_rotate );
foreach ( $files_to_rotate as $index => $file ) {
rename( $file, "$log." . ( $index + 1 ) );
}
touch( $log );
foreach ( $files_to_delete as $file ) {
unlink( $file );
}
}
private static function delete_old_logs( $days_to_keep ) {
$log = self::get_log_file_path();
if ( ! file_exists( $log ) ) {
return;
}
$seconds = $days_to_keep * DAY_IN_SECONDS;
$files = glob( "$log.*" );
foreach ( $files as $file ) {
if ( ! $time = self::get_latest_write_for_file( $file ) ) {
continue;
}
if ( $time + $seconds > ITSEC_Core::get_current_time_gmt() ) {
continue;
}
unlink( $file );
}
}
private static function get_latest_write_for_file( $file ) {
$line = self::tail( $file );
if ( ! $line ) {
return false;
}
return self::get_date( $line );
}
private static function get_date( $line ) {
if ( ! $parsed = self::parse_csv_line( $line ) ) {
return false;
}
if ( ! isset( $parsed[5] ) ) {
return false;
}
return strtotime( $parsed[5] );
}
private static function parse_csv_line( $line ) {
if ( function_exists( 'str_getcsv' ) ) {
return str_getcsv( $line );
}
require_once( ITSEC_Core::get_core_dir() . '/lib/class-itsec-lib-file.php' );
if ( ! function_exists( 'wp_tempnam' ) ) {
require_once( ABSPATH . 'wp-admin/includes/file.php' );
}
$temp = wp_tempnam();
$success = ITSEC_Lib_File::write( $temp, $line );
if ( true !== $success ) {
return false;
}
if ( ! $fh = fopen( $temp, 'rb' ) ) {
return false;
}
$parsed = fgetcsv( $fh );
if ( ! is_array( $parsed ) ) {
return false;
}
return $parsed;
}
/**
* Get the last n lines of a file.
*
* @link https://www.geekality.net/2011/05/28/php-tail-tackling-large-files/
*
* @param string $filename
* @param int $lines
* @param int $buffer
*
* @return bool|string
*/
private static function tail( $filename, $lines = 1, $buffer = 4096 ) {
// Open the file
$f = fopen( $filename, "rb" );
// Jump to last character
fseek( $f, - 1, SEEK_END );
// Read it and adjust line number if necessary
// (Otherwise the result would be wrong if file doesn't end with a blank line)
if ( fread( $f, 1 ) !== "\n" ) {
-- $lines;
}
// Start reading
$output = '';
$chunk = '';
// While we would like more
while ( ftell( $f ) > 0 && $lines >= 0 ) {
// Figure out how far back we should jump
$seek = min( ftell( $f ), $buffer );
// Do the jump (backwards, relative to where we are)
fseek( $f, - $seek, SEEK_CUR );
// Read a chunk and prepend it to our output
$output = ( $chunk = fread( $f, $seek ) ) . $output;
// Jump back to where we started reading
fseek( $f, - mb_strlen( $chunk, '8bit' ), SEEK_CUR );
// Decrease our line counter
$lines -= substr_count( $chunk, "\n" );
}
// While we have too many lines
// (Because of buffer size we might have read too many)
while ( $lines ++ < 0 ) {
// Find first newline and remove all text before that
$output = substr( $output, strpos( $output, "\n" ) + 1 );
}
// Close file and return
fclose( $f );
return $output;
}
}
add_action( 'itsec_scheduler_register_events', array( 'ITSEC_Log', 'register_events' ) );
add_action( 'itsec_scheduled_purge-log-entries', array( 'ITSEC_Log', 'purge_entries' ) );