__( 'Loading reference checksums', 'siteaudithub' ),
'enumerate' => __( 'Listing files', 'siteaudithub' ),
'inspect' => __( 'Inspecting files', 'siteaudithub' ),
'database' => __( 'Checking the database for injected code', 'siteaudithub' ),
'finalize' => __( 'Comparing with baseline and finalizing', 'siteaudithub' ),
'done' => __( 'Complete', 'siteaudithub' ),
);
return isset( $labels[ $stage ] ) ? $labels[ $stage ] : $stage;
}
/**
* Progress estimate.
*
* @param object $scan Scan.
* @return int
*/
public function progress( $scan ) {
switch ( $scan->stage ) {
case 'prepare':
return 1;
case 'enumerate':
return 3;
case 'inspect':
return $scan->total_items > 0 ? (int) floor( 5 + 90 * min( 1, $scan->processed_items / $scan->total_items ) ) : 5;
case 'database':
return 95;
case 'finalize':
return 97;
}
return 0;
}
/**
* One unit of work.
*
* @param object $scan Scan.
* @param float $deadline Deadline.
* @return bool
*/
public function step( $scan, $deadline ) {
switch ( $scan->stage ) {
case 'prepare':
$this->prepare( $scan );
return true;
case 'enumerate':
$this->enumerate( $scan, $deadline );
return true;
case 'inspect':
$this->inspect( $scan, $deadline );
return true;
case 'database':
$this->database( $scan );
return true;
case 'finalize':
return $this->finalize( $scan, $deadline );
}
return false;
}
/**
* Move to a stage.
*
* @param object $scan Scan.
* @param string $stage Stage.
* @param array $state State.
*/
private function go( $scan, $stage, array $state ) {
Repository::update_scan(
$scan->id,
array(
'stage' => $stage,
'state' => $state,
)
);
}
/* ------------------------------------------------------------------ */
/**
* Map a path to its display key (relative to the install root where possible).
*
* @param string $abs Absolute path.
* @return string
*/
public static function key_for( $abs ) {
$abs = wp_normalize_path( $abs );
$root = Paths::root();
if ( 0 === strpos( $abs, $root ) ) {
return substr( $abs, strlen( $root ) );
}
$content = Paths::content();
if ( 0 === strpos( $abs, $content ) ) {
return '[wp-content]/' . substr( $abs, strlen( $content ) );
}
return $abs;
}
/**
* Map a key back to an absolute path, confined to the WordPress roots.
*
* @param string $key Key.
* @return string|false
*/
public static function path_for( $key ) {
if ( 0 === strpos( $key, '[wp-content]/' ) ) {
$abs = Paths::content() . substr( $key, strlen( '[wp-content]/' ) );
} else {
$abs = Paths::root() . $key;
}
return Security::safe_path( $abs, array( Paths::root(), Paths::content() ) );
}
/**
* Content-relative path ("plugins/foo/bar.php") for a key, or ''.
*
* @param string $key Key.
* @return string
*/
private static function content_rel( $key ) {
if ( 0 === strpos( $key, '[wp-content]/' ) ) {
return substr( $key, strlen( '[wp-content]/' ) );
}
$abs = wp_normalize_path( Paths::root() . $key );
$content = Paths::content();
return 0 === strpos( $abs, $content ) ? substr( $abs, strlen( $content ) ) : '';
}
/**
* Load checksums.
*
* @param object $scan Scan.
*/
private function prepare( $scan ) {
global $wp_version;
$state = $scan->state;
$state['core_checksums'] = 0;
if ( ! empty( $scan->config['integrity_wporg_checksums'] ) ) {
$sums = self::core_checksums();
if ( $sums ) {
$state['core_checksums'] = count( $sums );
} else {
$state['core_error'] = __( 'Official core checksums could not be downloaded.', 'siteaudithub' );
}
}
$state['wp_version'] = $wp_version;
// New-file detection needs a previous complete scan to compare with.
$state['has_baseline'] = self::has_baseline() ? 1 : 0;
// Versions now and at the previous scan: a file that changed while its
// plugin / theme / WordPress version stayed the same was not updated.
$state['versions'] = self::versions();
$previous = Repository::latest_completed( 'integrity' );
$state['prev_versions'] = ( $previous && isset( $previous->summary['versions'] ) && is_array( $previous->summary['versions'] ) ) ? $previous->summary['versions'] : array();
$state['dirs'] = array(
array( untrailingslashit( Paths::root() ), 0 ),
array( untrailingslashit( Paths::core( 'wp-admin' ) ), 1 ),
array( untrailingslashit( Paths::core( 'wp-includes' ) ), 1 ),
array( untrailingslashit( Paths::content() ), 1 ),
);
$state['files'] = 0;
$state['limit_hit'] = 0;
$state['symlinks'] = 0;
$state['pcs_fetch'] = 0;
$this->go( $scan, 'enumerate', $state );
}
/**
* Official core checksums (cached one day).
*
* @return array path => md5
*/
public static function core_checksums() {
global $wp_version;
$locale = get_locale();
$key = 'siteaudithub_core_sums_' . md5( $wp_version . $locale );
$cached = get_transient( $key );
if ( is_array( $cached ) && $cached ) {
return $cached;
}
if ( ! function_exists( 'get_core_checksums' ) ) {
require_once ABSPATH . 'wp-admin/includes/update.php';
}
$sums = get_core_checksums( $wp_version, $locale );
if ( ! is_array( $sums ) && 'en_US' !== $locale ) {
$sums = get_core_checksums( $wp_version, 'en_US' );
}
if ( ! is_array( $sums ) || ! $sums ) {
return array();
}
set_transient( $key, $sums, DAY_IN_SECONDS );
return $sums;
}
/**
* Walk directories (resumable stack) and register files.
*
* @param object $scan Scan.
* @param float $deadline Deadline.
*/
private function enumerate( $scan, $deadline ) {
$state = $scan->state;
$max = (int) $scan->config['integrity_max_files'];
$updir = Paths::uploads();
$own = trailingslashit( wp_normalize_path( SITEAUDITHUB_DIR ) );
$core = array( Paths::core( 'wp-admin' ), Paths::core( 'wp-includes' ) );
$root = Paths::root();
$content = Paths::content();
$batch = array();
$dirs = 0;
while ( ! empty( $state['dirs'] ) && microtime( true ) < $deadline - 1 && $dirs < 400 ) {
list( $dir, $recursive ) = array_pop( $state['dirs'] );
++$dirs;
$entries = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false === $entries ) {
$batch[] = array( self::key_for( $dir ) . '/', 'unreadable-dir' );
continue;
}
foreach ( $entries as $name ) {
if ( '.' === $name || '..' === $name ) {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $name;
$norm = wp_normalize_path( $path );
if ( is_link( $path ) ) {
++$state['symlinks'];
continue; // Never follow symlinks (loops, paths outside the site).
}
if ( is_dir( $path ) ) {
if ( in_array( $name, self::SKIP_DIRS, true ) ) {
continue;
}
if ( ! $recursive ) {
// A folder directly in the site root. WordPress's own folders are queued
// explicitly; any other folder is reported and its files are inspected too.
if ( ! in_array( strtolower( $name ), self::ROOT_DIRS_ALLOWED, true ) && trailingslashit( $norm ) !== $content ) {
$known = in_array( strtolower( $name ), self::ROOT_DIRS_KNOWN, true );
Repository::add_finding( $scan->id, Catalog::finding( $known ? 'int_root_dir_known' : 'int_root_dir', self::key_for( $path ) . '/', __( 'Folder in the WordPress root that is not part of WordPress.', 'siteaudithub' ) ) );
$state['dirs'][] = array( $path, 1 );
}
continue;
}
// SiteAuditHub never reports on its own files.
if ( trailingslashit( $norm ) === $own ) {
continue;
}
if ( $updir && empty( $scan->config['integrity_include_uploads'] ) && trailingslashit( $norm ) === $updir ) {
continue;
}
// The install root is walked non-recursively; core and content roots are queued explicitly.
$state['dirs'][] = array( $path, 1 );
continue;
}
$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
$is_core = false;
foreach ( $core as $c ) {
if ( 0 === strpos( $norm, $c ) ) {
$is_core = true;
}
}
$is_root = trailingslashit( wp_normalize_path( dirname( $path ) ) ) === $root;
$in_upload = $updir && 0 === strpos( $norm, $updir );
$special = in_array( strtolower( $name ), array( '.htaccess', '.user.ini', 'php.ini' ), true );
if ( $is_core || $is_root ) {
$include = true;
$source = $is_core ? 'core' : 'root';
} elseif ( $in_upload ) {
if ( empty( $scan->config['integrity_include_uploads'] ) ) {
continue;
}
$include = $special || in_array( $ext, array_merge( self::UPLOAD_EXEC, self::SCRIPT_EXT, array( 'ico', 'htm', 'html', 'svg' ) ), true );
$source = 'uploads';
} else {
$include = $special || in_array( $ext, array_merge( self::PHP_EXT, self::SCRIPT_EXT, array( 'ico' ) ), true );
if ( 0 === strpos( $norm, $own ) ) {
continue; // Own files are excluded from the scan.
}
$source = 'content';
}
if ( ! $include ) {
continue;
}
if ( $state['files'] >= $max ) {
$state['limit_hit'] = 1;
$state['dirs'] = array();
break 2;
}
++$state['files'];
$batch[] = array( self::key_for( $path ), $source );
if ( count( $batch ) >= 200 ) {
$this->insert_files( $scan->id, $batch );
$batch = array();
}
}
}
$this->insert_files( $scan->id, $batch );
$total = Repository::count_items( $scan->id, 'file' );
Repository::update_scan(
$scan->id,
array(
'state' => $state,
'total_items' => $total,
)
);
if ( empty( $state['dirs'] ) ) {
$this->go( $scan, 'inspect', $state );
}
}
/**
* Bulk insert file items.
*
* @param int $scan_id Scan.
* @param array $batch [key, source].
*/
private function insert_files( $scan_id, array $batch ) {
global $wpdb;
if ( ! $batch ) {
return;
}
$args = array( Db::table( 'items' ) );
foreach ( $batch as $row ) {
$status = 'unreadable-dir' === $row[1] ? 'failed' : 'pending';
array_push( $args, (int) $scan_id, sha1( $row[0] ), $row[0], $row[1], $status );
}
$rows = implode( ', ', array_fill( 0, count( $batch ), "(%d, %s, %s, 'file', %s, %s)" ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $rows holds only placeholder groups, one per file.
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (scan_id, item_hash, item_key, item_type, source, status) VALUES $rows", $args ) );
}
/**
* Inspect a batch of files.
*
* @param object $scan Scan.
* @param float $deadline Deadline.
*/
private function inspect( $scan, $deadline ) {
global $wpdb;
$table = Db::table( 'items' );
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, item_key, source, attempts FROM %i WHERE scan_id = %d AND item_type = 'file' AND status = 'pending' ORDER BY id ASC LIMIT 150", $table, $scan->id ) );
if ( ! $rows ) {
if ( 0 === Repository::count_items( $scan->id, 'file', array( 'processing' ) ) ) {
$this->go( $scan, 'database', $scan->state );
}
return;
}
$ids = array_map( 'intval', wp_list_pluck( $rows, 'id' ) );
$in = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in holds only %d placeholders, one per ID.
$wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'processing', claimed_at = %s, attempts = attempts + 1 WHERE id IN ( $in )", array_merge( array( $table, Db::now() ), $ids ) ) );
$state = $scan->state;
$core_sums = ! empty( $state['core_checksums'] ) ? self::core_checksums() : array();
$plugins = $this->plugin_versions();
foreach ( $rows as $row ) {
if ( microtime( true ) > $deadline ) {
$wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'pending' WHERE id = %d", $table, $row->id ) );
continue;
}
$this->inspect_file( $scan, $row, $core_sums, $plugins, $state );
}
Repository::update_scan( $scan->id, array( 'state' => $state ) );
$counts = Repository::item_status_counts( $scan->id, 'file' );
$total = array_sum( $counts );
Repository::update_scan(
$scan->id,
array(
'total_items' => $total,
'processed_items' => $total - ( isset( $counts['pending'] ) ? $counts['pending'] : 0 ) - ( isset( $counts['processing'] ) ? $counts['processing'] : 0 ),
'failed_items' => isset( $counts['failed'] ) ? $counts['failed'] : 0,
)
);
}
/**
* Installed plugin versions keyed by folder (or single-file name).
*
* @return array
*/
private function plugin_versions() {
$out = array();
foreach ( Paths::plugins() as $file => $data ) {
$slug = false !== strpos( $file, '/' ) ? dirname( $file ) : $file;
$out[ $slug ] = $data['Version'];
}
return $out;
}
/**
* Published checksums for a WordPress.org plugin version (cached).
*
* @param string $slug Slug.
* @param string $version Version.
* @param array $state State (fetch counter, by reference).
* @return array|null files map, or null when unavailable.
*/
private function plugin_checksums( $slug, $version, array &$state ) {
$key = 'siteaudithub_pcs_' . md5( $slug . '@' . $version );
$cached = get_transient( $key );
if ( is_array( $cached ) ) {
return empty( $cached['files'] ) ? null : $cached['files'];
}
if ( $state['pcs_fetch'] >= 40 ) {
return null; // Bound external requests per scan.
}
++$state['pcs_fetch'];
$url = sprintf( 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json', rawurlencode( $slug ), rawurlencode( $version ) );
$response = wp_safe_remote_get(
$url,
array(
'timeout' => 10,
'limit_response_size' => 8 * 1024 * 1024,
)
);
$files = array();
if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
$json = json_decode( wp_remote_retrieve_body( $response ), true );
if ( is_array( $json ) && isset( $json['files'] ) && is_array( $json['files'] ) ) {
foreach ( $json['files'] as $path => $sums ) {
$files[ (string) $path ] = array(
'md5' => isset( $sums['md5'] ) ? (array) $sums['md5'] : array(),
'sha256' => isset( $sums['sha256'] ) ? (array) $sums['sha256'] : array(),
);
}
}
}
set_transient( $key, array( 'files' => $files ), $files ? WEEK_IN_SECONDS : DAY_IN_SECONDS );
return $files ? $files : null;
}
/**
* Inspect one file. Read-only.
*
* @param object $scan Scan.
* @param object $row Item.
* @param array $core_sums Core checksums.
* @param array $plugins Plugin versions.
* @param array $state State (by reference).
*/
private function inspect_file( $scan, $row, array $core_sums, array $plugins, array &$state ) {
$key = $row->item_key;
$findings = array();
$data = array();
$status = 'done';
$abs = self::path_for( $key );
$name = basename( $key );
$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
$max = (int) $scan->config['integrity_max_file_kb'] * 1024;
$is_php = in_array( $ext, self::PHP_EXT, true );
if ( false === $abs || ! is_file( $abs ) || ! is_readable( $abs ) ) {
Repository::add_finding( $scan->id, Catalog::finding( 'int_unreadable', $key, __( 'File could not be opened for reading.', 'siteaudithub' ) ) );
Repository::update_item(
$row->id,
array(
'status' => 'failed',
'processed_at' => Db::now(),
)
);
return;
}
$size = (int) @filesize( $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
$hash = @hash_file( 'sha256', $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
$verified = '';
$data = array(
'h' => $hash ? $hash : '',
's' => $size,
);
// Core verification.
if ( 'core' === $row->source || 'root' === $row->source ) {
if ( $core_sums ) {
if ( isset( $core_sums[ $key ] ) ) {
$md5 = @md5_file( $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( $md5 === $core_sums[ $key ] ) {
$verified = 'core';
} else {
$findings[] = Catalog::finding( 'int_core_mismatch', $key, sprintf( 'md5 %s ≠ %s', $md5, $core_sums[ $key ] ) );
}
} elseif ( 'core' === $row->source ) {
$findings[] = Catalog::finding( 'int_core_unexpected', $key, size_format( $size ), array( 'severity' => $is_php ? 'high' : 'low' ) );
} elseif ( $is_php && ! in_array( strtolower( $name ), self::CORE_ROOT_FILES, true ) ) {
$findings[] = Catalog::finding( 'int_root_unexpected', $key, size_format( $size ) );
} elseif ( ! $is_php && self::root_file_unknown( $name ) ) {
$findings[] = Catalog::finding( 'int_root_file', $key, size_format( $size ) );
}
} elseif ( 'root' === $row->source ) {
// Without checksums: compare with the files WordPress ships in the root.
if ( $is_php && ! in_array( strtolower( $name ), self::CORE_ROOT_FILES, true ) ) {
$findings[] = Catalog::finding( 'int_root_unexpected', $key, size_format( $size ) );
} elseif ( ! $is_php && self::root_file_unknown( $name ) ) {
$findings[] = Catalog::finding( 'int_root_file', $key, size_format( $size ) );
}
}
}
// Plugin verification.
$rel = self::content_rel( $key );
if ( ! $verified && $rel && 0 === strpos( $rel, 'plugins/' ) && ! empty( $scan->config['integrity_wporg_checksums'] ) ) {
$parts = explode( '/', substr( $rel, strlen( 'plugins/' ) ), 2 );
$slug = $parts[0];
if ( isset( $parts[1] ) && isset( $plugins[ $slug ] ) && $plugins[ $slug ] && 'self' !== $row->source ) {
$sums = $this->plugin_checksums( $slug, $plugins[ $slug ], $state );
if ( null !== $sums ) {
$inner = $parts[1];
if ( isset( $sums[ $inner ] ) ) {
$ok = in_array( $hash, $sums[ $inner ]['sha256'], true );
if ( ! $ok && $sums[ $inner ]['md5'] ) {
$ok = in_array( @md5_file( $abs ), $sums[ $inner ]['md5'], true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
}
if ( $ok ) {
$verified = 'plugin';
} elseif ( $is_php || in_array( $ext, self::SCRIPT_EXT, true ) ) {
$findings[] = Catalog::finding( 'int_plugin_mismatch', $key, $slug . ' ' . $plugins[ $slug ] );
}
// Non-code files (.htaccess, .txt…) sometimes differ from WordPress.org's own
// checksum list even in the official zip; their content is still inspected below.
} elseif ( $is_php ) {
$findings[] = Catalog::finding( 'int_plugin_extra', $key, $slug . ' ' . $plugins[ $slug ] );
}
}
}
}
$data['v'] = $verified;
// Suspicious names and locations.
if ( preg_match( '/\.(?:jpe?g|png|gif|webp|ico|txt|pdf|zip|bak|css|js)\.(?:php\d?|phtml|phar|pht)$/i', $name ) ) {
$findings[] = Catalog::finding( 'int_disguised', $key, $name );
}
// Well-known developer tool configs (bundled by many libraries) are not
// reported by name outside uploads; their content is still inspected.
$dev_config = 'uploads' !== $row->source && in_array( strtolower( $name ), array( '.php-cs-fixer.dist.php', '.php-cs-fixer.php', '.php_cs.dist.php', '.phpstorm.meta.php', '.phan.php', '.rector.php' ), true );
if ( ! $dev_config && '.' === substr( $name, 0, 1 ) && in_array( $ext, self::PHP_EXT, true ) ) {
$findings[] = Catalog::finding( 'int_hidden_php', $key, $name );
}
if ( $rel && false === strpos( $rel, '/' ) && $is_php && ! in_array( strtolower( $name ), array( 'index.php', 'advanced-cache.php', 'object-cache.php', 'db.php', 'db-error.php', 'install.php', 'maintenance.php', 'sunrise.php', 'blog-deleted.php', 'blog-inactive.php', 'blog-suspended.php', 'php-error.php', 'fatal-error-handler.php' ), true ) ) {
$findings[] = Catalog::finding( 'int_content_php', $key, size_format( $size ) );
}
// Uploads: executables do not belong there.
if ( 'uploads' === $row->source && in_array( $ext, self::UPLOAD_EXEC, true ) ) {
$findings[] = Catalog::finding( 'int_uploads_exec', $key, size_format( $size ) );
}
// Content inspection for everything not verified against an official checksum.
$inspect = ! $verified && 'self' !== $row->source && ( $is_php || in_array( $ext, array_merge( self::SCRIPT_EXT, array( 'ico', 'htm', 'html', 'svg', 'cgi', 'pl', 'py', 'sh' ) ), true ) || in_array( strtolower( $name ), array( '.htaccess', '.user.ini', 'php.ini' ), true ) );
if ( $inspect ) {
if ( $size > $max ) {
// Reported once per scan as a grouped summary (see finalize()).
$data['big'] = 1;
} else {
$content = @file_get_contents( $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false === $content ) {
$findings[] = Catalog::finding( 'int_unreadable', $key, __( 'Read error.', 'siteaudithub' ) );
$status = 'failed';
} else {
$findings = array_merge( $findings, self::match_content( $key, $name, $ext, $content ) );
}
}
}
// Baseline comparison for files without an official reference.
if ( ! $verified && $hash ) {
$base = $this->baseline_hash( $key );
$updated = self::owner_updated( $key, $state );
if ( null !== $base && $base !== $hash ) {
if ( false === $updated ) {
// Same version as last scan, different contents: edited, not updated.
$findings[] = Catalog::finding( 'int_modified', $key, self::owner_label( $key, $state ) );
$data['hold'] = 1; // Keep the old hash so the change is reported until it is fixed.
} else {
$findings[] = Catalog::finding( 'int_changed', $key, __( 'SHA-256 differs from the previous integrity scan.', 'siteaudithub' ) );
}
} elseif ( null === $base && ! empty( $state['has_baseline'] ) && 'root' !== $row->source ) {
// Root files have their own check (unknown names are always reported there).
// Not there last time: reported in finalize(), grouped per plugin/theme.
$data['new'] = 1;
if ( true === $updated ) {
$data['upd'] = 1; // Explained by an update or a new install.
}
}
}
foreach ( $findings as $finding ) {
Repository::add_finding( $scan->id, $finding );
}
Repository::update_item(
$row->id,
array(
'status' => $status,
'data' => $data,
'findings_count' => count( $findings ),
'processed_at' => Db::now(),
)
);
}
/**
* Run the signature sets that apply to a file.
*
* @param string $key Key.
* @param string $name File name.
* @param string $ext Extension.
* @param string $content Content.
* @return array Findings.
*/
public static function match_content( $key, $name, $ext, $content ) {
$findings = array();
$lname = strtolower( $name );
$is_conf = in_array( $lname, array( '.htaccess', '.user.ini', 'php.ini' ), true );
$has_php = false !== stripos( $content, ' 'high', 'fp_extra' => 'SAH-H304' ) );
}
foreach ( Signatures::htaccess() as $id => $sig ) {
if ( preg_match( $sig[0], $content, $m ) ) {
// Known security-plugin firewall bootstrap (e.g. Wordfence WAF) is expected.
if ( 'SAH-H303' === $id && preg_match( '/wordfence-waf\.php|ninjafirewall|sucuri/i', $m[0] ) ) {
continue;
}
$findings[] = Catalog::finding( 'int_htaccess', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'severity' => $sig[2], 'fp_extra' => $id ) );
}
}
return $findings;
}
$php_like = in_array( $ext, self::PHP_EXT, true ) || ( $has_php && in_array( $ext, array( 'ico', 'png', 'jpg', 'gif', 'svg', 'htm', 'html', 'txt' ), true ) );
if ( $php_like ) {
if ( ! in_array( $ext, self::PHP_EXT, true ) ) {
$findings[] = Catalog::finding( 'int_suspicious', $key, __( 'PHP code inside a non-PHP file type.', 'siteaudithub' ), array( 'severity' => 'high', 'fp_extra' => 'php-in-' . $ext ) );
}
foreach ( Signatures::known() as $id => $sig ) {
if ( preg_match( $sig[0], $content, $m ) ) {
$findings[] = Catalog::finding( 'int_signature', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'fp_extra' => $id ) );
}
}
// Stand-alone upload forms that skip WordPress' permission checks (uploader shells).
if ( strlen( $content ) < 30000 && false !== stripos( $content, 'move_uploaded_file' ) && false !== strpos( $content, '$_FILES' ) && preg_match( '/type\s*=\s*[\'"]?file/i', $content ) && ! preg_match( '/current_user_can|wp_verify_nonce|check_admin_referer|check_ajax_referer|wp_handle_upload|is_user_logged_in/', $content ) ) {
$findings[] = Catalog::finding( 'int_uploader', $key, __( 'File upload form with move_uploaded_file() and no WordPress permission or nonce check.', 'siteaudithub' ) );
}
foreach ( Signatures::suspicious_php() as $id => $sig ) {
if ( preg_match( $sig[0], $content, $m ) ) {
$findings[] = Catalog::finding( 'int_suspicious', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'severity' => $sig[2], 'fp_extra' => $id ) );
}
}
}
if ( in_array( $ext, self::SCRIPT_EXT, true ) || in_array( $ext, array( 'htm', 'html', 'svg' ), true ) ) {
foreach ( Signatures::suspicious_js() as $id => $sig ) {
if ( preg_match( $sig[0], $content, $m ) ) {
$findings[] = Catalog::finding( 'int_suspicious', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'severity' => $sig[2], 'fp_extra' => $id ) );
}
}
}
return $findings;
}
/**
* Safe short excerpt of matched code (escaped on output).
*
* @param string $match Match.
* @return string
*/
private static function excerpt( $match ) {
$match = preg_replace( '/[^\x20-\x7E]/', '?', (string) $match );
return strlen( $match ) > 160 ? substr( $match, 0, 157 ) . '...' : $match;
}
/**
* Report files that did not exist during the previous scan.
*
* A handful of new files in one place is listed file by file (that is how
* a dropped backdoor looks). Many new files inside one plugin or theme
* usually mean it was installed or updated, so they become one note.
*
* @param object $scan Scan.
*/
private function report_new_files( $scan ) {
global $wpdb;
$items = Db::table( 'items' );
$keys = $wpdb->get_col( $wpdb->prepare( "SELECT item_key FROM %i WHERE scan_id = %d AND item_type = 'file' AND data LIKE %s ORDER BY item_key ASC LIMIT 5000", $items, $scan->id, '%"new":1%' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from Db::table().
if ( ! $keys ) {
return;
}
$updated = array_flip( (array) $wpdb->get_col( $wpdb->prepare( "SELECT item_key FROM %i WHERE scan_id = %d AND item_type = 'file' AND data LIKE %s LIMIT 5000", $items, $scan->id, '%"upd":1%' ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from Db::table().
$groups = array();
foreach ( $keys as $key ) {
$rel = self::content_rel( $key );
$parts = explode( '/', $rel );
if ( $rel && in_array( $parts[0], array( 'plugins', 'themes' ), true ) && count( $parts ) > 2 ) {
$group = $parts[0] . '/' . $parts[1];
} else {
$group = ''; // Single files: always listed one by one.
}
$groups[ $group ][] = $key;
}
foreach ( $groups as $group => $list ) {
// Files added by an update/new install of their plugin or theme are one note, never a threat.
$explained = '' !== $group && count( array_intersect_key( array_flip( $list ), $updated ) ) === count( $list );
if ( '' !== $group && ( count( $list ) > 5 || $explained ) ) {
$text = sprintf(
/* translators: 1: number of files, 2: folder */
__( '%1$d new code files in wp-content/%2$s since the previous scan:', 'siteaudithub' ),
count( $list ),
$group
);
Repository::add_finding( $scan->id, Catalog::finding( 'int_new_group', 'wp-content/' . $group . '/', $text . "\n" . implode( "\n", array_slice( $list, 0, 30 ) ) ) );
continue;
}
foreach ( $list as $key ) {
self::hold( $scan->id, $key );
$ext = strtolower( pathinfo( $key, PATHINFO_EXTENSION ) );
Repository::add_finding( $scan->id, Catalog::finding( 'int_new_file', $key, __( 'Not present in the previous integrity scan.', 'siteaudithub' ), array( 'severity' => in_array( $ext, self::PHP_EXT, true ) ? 'high' : 'medium' ) ) );
}
}
}
/**
* Whether a non-PHP file in the site root is unknown (not shipped by
* WordPress and not a common server / SEO / icon file).
*
* @param string $name File name.
* @return bool
*/
private static function root_file_unknown( $name ) {
$lower = strtolower( $name );
return ! in_array( $lower, self::CORE_ROOT_FILES, true ) && ! in_array( $lower, self::ROOT_ALLOWED, true ) && ! preg_match( self::ROOT_ALLOWED_PATTERN, $lower );
}
/**
* Keep a reported file out of the next baseline refresh.
*
* @param int $scan_id Scan.
* @param string $key File key.
*/
private static function hold( $scan_id, $key ) {
$row = Repository::find_item( $scan_id, $key );
if ( $row ) {
$data = Db::decode( $row->data );
$data['hold'] = 1;
Repository::update_item( (int) $row->id, array( 'data' => $data ) );
}
}
/**
* Installed versions: WordPress core, every plugin and every theme.
*
* @return array owner => version
*/
private static function versions() {
global $wp_version;
$out = array( 'core' => (string) $wp_version );
foreach ( Paths::plugins() as $file => $data ) {
$slug = false !== strpos( $file, '/' ) ? dirname( $file ) : $file;
$out[ 'plugins/' . $slug ] = (string) $data['Version'];
}
foreach ( wp_get_themes() as $slug => $theme ) {
$out[ 'themes/' . $slug ] = (string) $theme->get( 'Version' );
}
return $out;
}
/**
* Which installed package a file belongs to.
*
* @param string $key File key.
* @return string 'core', 'plugins/{slug}', 'themes/{slug}' or '' (no package).
*/
private static function owner( $key ) {
$rel = self::content_rel( $key );
if ( '' === $rel ) {
return 0 === strpos( $key, '[wp-content]/' ) ? '' : 'core'; // Root, wp-admin, wp-includes.
}
$parts = explode( '/', $rel );
if ( isset( $parts[1] ) && in_array( $parts[0], array( 'plugins', 'themes' ), true ) ) {
// Single-file plugins (hello.php) are their own package.
return count( $parts ) > 2 || 'plugins' === $parts[0] ? $parts[0] . '/' . $parts[1] : '';
}
return '';
}
/**
* Whether the file's package was updated (or newly installed) since the
* previous scan.
*
* @param string $key File key.
* @param array $state Scan state.
* @return bool|null true = updated, false = same version, null = unknown (no earlier version data).
*/
private static function owner_updated( $key, array $state ) {
if ( empty( $state['prev_versions'] ) || empty( $state['versions'] ) ) {
return null;
}
$owner = self::owner( $key );
if ( '' === $owner ) {
return false; // Loose files (uploads, wp-content root, mu-plugins…) are never "updated".
}
if ( ! isset( $state['prev_versions'][ $owner ] ) ) {
return true; // Newly installed since the previous scan.
}
return ! isset( $state['versions'][ $owner ] ) || $state['versions'][ $owner ] !== $state['prev_versions'][ $owner ];
}
/**
* Evidence text for a file changed without an update.
*
* @param string $key File key.
* @param array $state Scan state.
* @return string
*/
private static function owner_label( $key, array $state ) {
$owner = self::owner( $key );
if ( '' !== $owner && isset( $state['versions'][ $owner ] ) ) {
/* translators: 1: plugin/theme folder or "core", 2: version */
return sprintf( __( 'Changed since the previous scan while %1$s stayed at version %2$s.', 'siteaudithub' ), 'core' === $owner ? 'WordPress' : $owner, $state['versions'][ $owner ] );
}
return __( 'Changed since the previous scan, and no update explains it.', 'siteaudithub' );
}
/**
* Whether a previous complete scan left a baseline.
*
* @return bool
*/
private static function has_baseline() {
global $wpdb;
$table = Db::table( 'baseline' );
return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i LIMIT 1', $table ) );
}
/**
* Stored hash of a file from the previous complete scan.
*
* @param string $key Key.
* @return string|null
*/
private function baseline_hash( $key ) {
global $wpdb;
$table = Db::table( 'baseline' );
$hash = $wpdb->get_var( $wpdb->prepare( 'SELECT file_hash FROM %i WHERE path_hash = %s', $table, sha1( $key ) ) );
return null === $hash ? null : (string) $hash;
}
/**
* Database checks: injected scripts/iframes in content, code in options,
* recently created administrators. Read-only and bounded.
*
* @param object $scan Scan.
*/
private function database( $scan ) {
global $wpdb;
$id = (int) $scan->id;
$host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
$decod = '/\b(?:eval|atob|unescape|String\.fromCharCode|document\.write)\s*\(/i';
// 1. Posts and pages.
$posts = $wpdb->get_results( "SELECT ID, post_title, post_type, post_content FROM {$wpdb->posts} WHERE post_type NOT IN ('revision','customize_changeset','oembed_cache','user_request','wp_global_styles') AND post_status IN ('publish','private','draft','pending','future') AND ( post_content LIKE '%#i', $content, $scripts, PREG_SET_ORDER ) ) {
foreach ( $scripts as $sc ) {
if ( preg_match( $decod, $sc[1] ) ) {
Repository::add_finding( $id, Catalog::finding( 'int_db_script', $key, $label . "\n" . self::excerpt( $sc[0] ) ) );
break;
}
}
}
if ( preg_match_all( '##i', $value, $sc ) && preg_match( $decod, $sc[1] ) );
if ( $bad ) {
Repository::add_finding( $id, Catalog::finding( 'int_db_option', 'option:' . $opt->option_name, self::excerpt( $value ) ) );
} elseif ( false !== stripos( $value, '