array( finding type, signature id ). The signature decides, never the * status code. The names the older root probe guessed are all here. * * @var array */ const ROOT_FILES = array( '.git/HEAD' => array( 'vcs', 'git_head' ), '.git/config' => array( 'vcs', 'git_config' ), '.svn/wc.db' => array( 'vcs', 'sqlite' ), '.svn/entries' => array( 'vcs', 'svn_entries' ), '.hg/requires' => array( 'vcs', 'hg_requires' ), '.env' => array( 'secret', 'dotenv' ), '.env.local' => array( 'secret', 'dotenv' ), '.env.production' => array( 'secret', 'dotenv' ), '.env.backup' => array( 'secret', 'dotenv' ), '.env.bak' => array( 'secret', 'dotenv' ), '.env.old' => array( 'secret', 'dotenv' ), 'wp-config.php.bak' => array( 'secret', 'wp_config' ), 'wp-config.php~' => array( 'secret', 'wp_config' ), 'wp-config.php.old' => array( 'secret', 'wp_config' ), 'wp-config.php.save' => array( 'secret', 'wp_config' ), 'wp-config.php.orig' => array( 'secret', 'wp_config' ), 'wp-config.php.txt' => array( 'secret', 'wp_config' ), 'wp-config.bak' => array( 'secret', 'wp_config' ), 'wp-config.old' => array( 'secret', 'wp_config' ), 'wp-config.txt' => array( 'secret', 'wp_config' ), '.wp-config.php.swp' => array( 'secret', 'wp_config' ), '.aws/credentials' => array( 'secret', 'aws' ), '.ssh/id_rsa' => array( 'secret', 'private_key' ), '.ssh/id_ed25519' => array( 'secret', 'private_key' ), '.ssh/authorized_keys' => array( 'secret', 'ssh_public' ), '.vscode/sftp.json' => array( 'secret', 'sftp_json' ), 'phpmyadmin/' => array( 'db_tool', 'phpmyadmin' ), 'phpMyAdmin/' => array( 'db_tool', 'phpmyadmin' ), 'pma/' => array( 'db_tool', 'phpmyadmin' ), 'myadmin/' => array( 'db_tool', 'phpmyadmin' ), 'dbadmin/' => array( 'db_tool', 'phpmyadmin' ), 'sqladmin/' => array( 'db_tool', 'phpmyadmin' ), 'mysql/' => array( 'db_tool', 'phpmyadmin' ), 'db/' => array( 'db_tool', 'phpmyadmin' ), 'adminer/' => array( 'db_tool', 'adminer' ), 'adminer.php' => array( 'db_tool', 'adminer' ), 'phpinfo.php' => array( 'phpinfo', 'phpinfo' ), 'info.php' => array( 'phpinfo', 'phpinfo' ), 'php.php' => array( 'phpinfo', 'phpinfo' ), 'test.php' => array( 'phpinfo', 'phpinfo' ), 'i.php' => array( 'phpinfo', 'phpinfo' ), 'server-status' => array( 'server_status', 'server_status' ), 'server-info' => array( 'server_status', 'server_info' ), '.htaccess' => array( 'dev', 'htaccess' ), 'error_log' => array( 'dev', 'php_log' ), 'php_errors.log' => array( 'dev', 'php_log' ), 'php_error.log' => array( 'dev', 'php_log' ), 'composer.json' => array( 'dev', 'composer_json' ), 'composer.lock' => array( 'dev', 'composer_installed' ), 'package.json' => array( 'dev', 'package_json' ), 'vendor/composer/installed.json' => array( 'dev', 'composer_installed' ), 'node_modules/.package-lock.json' => array( 'dev', 'npm_lock' ), '.idea/workspace.xml' => array( 'dev', 'idea' ), '.DS_Store' => array( 'dev', 'ds_store' ), 'readme.html' => array( 'info_file', 'wp_readme' ), 'license.txt' => array( 'info_file', 'gpl_license' ), ); /** * Backup and dump names tried at the site root. The site's own host name, with * the dots removed, is added at run time. * * @var string[] */ const ARCHIVE_NAMES = array( 'backup.zip', 'backup.tar.gz', 'backup.sql', 'backup.bak', 'site.zip', 'site.tar.gz', 'wordpress.zip', 'wordpress.tar.gz', 'wp-backup.zip', 'db.sql', 'database.sql', 'dump.sql', ); /** * The same, for the content and uploads directories. * * @var string[] */ const ARCHIVE_NAMES_DEEP = array( 'backup.zip', 'backup.sql', 'db.sql', 'site.zip', 'wordpress.zip' ); /** * Files that run whatever is posted to them, relative to the site root. * * PHPUnit's eval-stdin.php (CVE-2017-9841) is still one of the most scanned for * paths on the web. It answers with an empty body, so there is no signature to * read: it is a hit only when it answers 200 and a made-up file beside it does not. * * @var string[] */ const ROOT_EXEC_FILES = array( 'vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php', 'vendor/phpunit/src/Util/PHP/eval-stdin.php', 'vendor/phpunit/phpunit/Util/PHP/eval-stdin.php', 'lib/phpunit/phpunit/src/Util/PHP/eval-stdin.php', ); /** * Which real folders count as leftovers when they answer and list nothing. * * A real /assets/ or /shop/ folder that answers is simply the site. A real folder * whose name says old, backup, test, dump or carries a year is something somebody * meant to remove. */ const LEFTOVER_PATTERN = '#(^|[-_.~])(old|bak|bkp|bck|backups?|copy|copies|clone|tmp|temp|tests?|testing|dev|devel|staging|stage|stg|dumps?|sql|db|database|logs?|archives?|exports?|imports?|private|secret|deleted|legacy|orig|original|snapshots?|restore|migration)\d*($|[-_.~])|^(old|bak|backup)|(old|bak|backup)\d*$|(19|20)\d{2}$#i'; /** * A clock the tests can replace. Null means microtime(). * * @var callable|null */ public static $clock = null; // ── Scheduling ─────────────────────────────────────────────────────── /** * Arms the daily run. Idempotent, and one cheap read on every later request. * * The daily run starts at about 03:00 site time. The first time the task is armed * a single run is also queued a few minutes out, so a site does not wait a day for * its first result. */ public static function sync_schedule(): void { if ( ! function_exists( 'wp_next_scheduled' ) ) { return; } $hook = self::CRON_HOOK; if ( ! self::enabled() ) { // Off means off: nothing queued, nothing left half run. if ( wp_next_scheduled( $hook ) ) { wp_clear_scheduled_hook( $hook ); } if ( wp_next_scheduled( $hook . '_continue' ) ) { wp_clear_scheduled_hook( $hook . '_continue' ); } return; } if ( wp_next_scheduled( $hook ) ) { return; } $first = time() + DAY_IN_SECONDS; try { $first = ( new \DateTimeImmutable( 'tomorrow 03:00', wp_timezone() ) )->getTimestamp(); } catch ( \Throwable $e ) { unset( $e ); // Keep the fallback. } wp_schedule_event( $first, 'daily', $hook ); if ( ! is_array( get_option( self::OPTION_RESULT ) ) ) { self::queue_batch( 300 ); } } /** * Queues the next batch, unless one is already queued. * * @param int $delay Seconds from now. */ private static function queue_batch( int $delay ): void { if ( ! wp_next_scheduled( self::CRON_HOOK . '_continue' ) ) { wp_schedule_single_event( time() + $delay, self::CRON_HOOK . '_continue' ); } } /** * One batch. Hooked to the daily task and to its follow-on. Never throws. * * @return string What happened: standby|locked|retry|finished|inconclusive|continued|error. */ public static function cron_run(): string { try { // A restored copy must not scan, and must not report: its folders are not the // live site's, and its alerts would name the live site. if ( class_exists( 'CSDT_Site_Role' ) && CSDT_Site_Role::is_standby() ) { return 'standby'; } if ( ! self::enabled() ) { return 'off'; } return self::run_batch(); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return 'error'; } } /** * The batch itself. * * @return string */ private static function run_batch(): string { $now = time(); $state = get_option( self::OPTION_STATE ); if ( is_array( $state ) && ( $now - (int) ( $state['started'] ?? 0 ) ) > self::RUN_MAX_AGE ) { $state = null; // Stuck. Start again rather than resume something hours old. } if ( is_array( $state ) && (int) ( $state['lock'] ?? 0 ) > $now ) { return 'locked'; } $deadline = self::now() + self::BATCH_SECONDS; $made = 0; // One request, counted, or null when this batch has used its share. $get = static function ( string $url ) use ( &$made, $deadline ) { if ( $made >= self::BATCH_REQUESTS || self::now() >= $deadline ) { return null; } ++$made; return self::fetch( $url ); }; // ── Control: every batch proves the fetch path still works ── $control_url = home_url( '/' ); $control = $get( $control_url ); $code = (int) ( $control['code'] ?? 0 ); if ( 200 !== $code ) { $state = is_array( $state ) ? $state : self::new_state( $now ); // One failure can be a restart or a deploy. Try once more before giving up. if ( empty( $state['control_failed'] ) ) { $state['control_failed'] = true; $state['checked'] = (int) $state['checked'] + $made; update_option( self::OPTION_STATE, $state, false ); self::queue_batch( 5 * self::BATCH_GAP ); return 'retry'; } // Refused twice running. What was found stands; nothing else does. self::finish( $state, $made, false, $code ); return 'inconclusive'; } $fresh = ! is_array( $state ) || ( 0 === (int) ( $state['cursor'] ?? 0 ) && empty( $state['total'] ) ); if ( is_array( $state ) ) { $state['control_failed'] = false; } if ( $fresh ) { $state = is_array( $state ) ? $state : self::new_state( $now ); // ── Baseline: what this site says for a path that cannot exist ── $base = $get( rtrim( (string) site_url(), '/' ) . '/' . self::random_name() . '/' ); $body = (string) ( $base['body'] ?? '' ); // A site that answers 200 to anything. Signatures still work; a status code does not. $state['catch_all'] = 200 === (int) ( $base['code'] ?? 0 ); // A signature the "not found" page itself carries proves nothing on this site. foreach ( self::signature_ids() as $id ) { if ( self::matches( $id, $body ) ) { $state['base_sigs'][] = $id; } } $state['dirs_on_disk'] = count( self::disk_dirs() ); } $state['lock'] = $now + self::LOCK_SECONDS; update_option( self::OPTION_STATE, $state, false ); $tasks = self::tasks(); $state['total'] = count( $tasks ); $cursor = (int) $state['cursor']; while ( $cursor < $state['total'] ) { $hit = self::run_task( $tasks[ $cursor ], $get, $state ); if ( false === $hit ) { break; // Out of budget. This task runs again first in the next batch. } if ( is_array( $hit ) ) { $state['hits'][ $hit['type'] . '|' . $hit['path'] ] = $hit; } ++$cursor; } $state['cursor'] = $cursor; if ( $cursor >= $state['total'] ) { // ── Control again: a scanner blocked at the very end reads as clean ── $after = self::fetch( $control_url ); self::finish( $state, $made + 1, true, (int) $after['code'] ); return 200 === (int) $after['code'] ? 'finished' : 'inconclusive'; } $state['checked'] = (int) $state['checked'] + $made; $state['lock'] = 0; update_option( self::OPTION_STATE, $state, false ); self::queue_batch( self::BATCH_GAP ); return 'continued'; } /** * The state a run starts with. * * @param int $now Timestamp. * @return array */ private static function new_state( int $now ): array { return array( 'started' => $now, 'cursor' => 0, 'total' => 0, 'checked' => 0, 'hits' => array(), 'catch_all' => false, 'base_sigs' => array(), 'dirs_on_disk' => 0, 'lock' => 0, ); } /** * Stores a finished run and clears the working state. * * @param array $state The run. * @param int $made Requests this batch made. * @param bool $complete Whether every task ran. * @param int $control_code The last control request's status. */ private static function finish( array $state, int $made, bool $complete, int $control_code ): void { update_option( self::OPTION_RESULT, array( 'started' => (int) $state['started'], 'finished' => time(), 'checked' => (int) $state['checked'] + $made, 'total' => (int) $state['total'], 'complete' => $complete, 'control_code' => $control_code, // Clean only when every task ran AND the site still answered at the end. 'inconclusive' => ! $complete || 200 !== $control_code, 'catch_all' => ! empty( $state['catch_all'] ), 'base_sigs' => array_values( (array) $state['base_sigs'] ), 'dirs_on_disk' => (int) $state['dirs_on_disk'], 'hits' => array_values( (array) $state['hits'] ), ), false ); delete_option( self::OPTION_STATE ); } // ── What the audit reads ───────────────────────────────────────────── /** * The last finished run, with its hits re-tested now. Memoised per request. * * Not cached beyond the request, for the same reason as the archive scan: the * per-finding re-check re-runs this, and a stale "still exposed" would accuse the * owner of a leak they had just closed. * * @param bool $reset Discard the memo. Tests only. * @return array{status:string,checked:int,hits:array} */ public static function latest( bool $reset = false ): array { static $memo = null; if ( $reset ) { $memo = null; } if ( is_array( $memo ) ) { return $memo; } try { $memo = self::read_latest(); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } $memo = array( 'status' => 'pending', 'checked' => 0, 'hits' => array(), ); } return $memo; } /** * Reads the stored result and re-tests its hits. * * @return array */ private static function read_latest(): array { if ( ! self::enabled() ) { // Off: no stored result is read back and nothing is asked for again. return array( 'status' => 'off', 'checked' => 0, 'hits' => array(), 'retested' => false, ); } $result = get_option( self::OPTION_RESULT ); $state = get_option( self::OPTION_STATE ); if ( ! is_array( $result ) ) { // Nothing has finished yet. Make sure something is on its way. if ( ! is_array( $state ) && function_exists( 'wp_next_scheduled' ) ) { self::queue_batch( 60 ); } return array( 'status' => is_array( $state ) ? 'running' : 'pending', 'checked' => is_array( $state ) ? (int) ( $state['checked'] ?? 0 ) : 0, 'total' => is_array( $state ) ? (int) ( $state['total'] ?? 0 ) : 0, 'hits' => array(), 'retested' => false, ); } $result['status'] = ( time() - (int) ( $result['finished'] ?? 0 ) ) > self::STALE_AFTER ? 'stale' : 'complete'; $result['retested'] = false; $hits = (array) ( $result['hits'] ?? array() ); if ( empty( $hits ) ) { return $result; } // ── Re-test the stored hits, and only those ── $deadline = self::now() + self::RETEST_SECONDS; $get = static function ( string $url ) use ( $deadline ) { return self::now() >= $deadline ? null : self::fetch( $url ); }; $control = $get( home_url( '/' ) ); if ( 200 !== (int) ( $control['code'] ?? 0 ) ) { return $result; // Cannot reach the site, so cannot clear anything either. } $kept = array(); $changed = false; foreach ( $hits as $i => $h ) { $still = $i < self::RETEST_MAX && ! empty( $h['task'] ) ? self::run_task( (array) $h['task'], $get, $result ) : false; if ( null === $still ) { $changed = true; // Closed since the run. Drop it. continue; } $kept[] = $h; // Still proves, or was not reached: either way it stands. } $result['hits'] = $kept; $result['retested'] = true; if ( $changed ) { $store = $result; unset( $store['status'], $store['retested'] ); update_option( self::OPTION_RESULT, $store, false ); } return $result; } // ── The work list ──────────────────────────────────────────────────── /** * Every question one run asks, most serious first. * * Rebuilt for each batch from constants and the disk, so a run in progress keeps no * list in the database. A folder created between batches can shift the tail by one * place; the next daily run asks everything again. * * @return array */ public static function tasks(): array { $site_url = rtrim( (string) site_url(), '/' ); $content_url = rtrim( (string) content_url(), '/' ); $site_root = rtrim( CSDT_Paths::site_root(), '/' ); $uploads = wp_get_upload_dir(); $uploads_url = isset( $uploads['baseurl'] ) ? rtrim( (string) $uploads['baseurl'], '/' ) : ''; $tasks = array(); $add = static function ( string $url, string $kind, string $type, string $sig, string $source, bool $on_disk = false ) use ( &$tasks ) { $tasks[ $url ] = array( 'url' => $url, 'kind' => $kind, 'type' => $type, 'sig' => $sig, 'source' => $source, 'on_disk' => $on_disk, ); }; // 1. Files whose content proves a leak. foreach ( self::ROOT_FILES as $rel => $spec ) { $add( $site_url . '/' . $rel, 'sig', $spec[0], $spec[1], 'wordlist' ); } foreach ( self::vcs_in_packages() as $url ) { $add( $url, 'sig', 'vcs', 'git_head', 'disk' ); } // 2. Backups and dumps at guessable names. $names = self::ARCHIVE_NAMES; $slug = str_replace( '.', '', (string) wp_parse_url( $site_url, PHP_URL_HOST ) ); if ( '' !== $slug ) { $names[] = $slug . '.zip'; $names[] = $slug . '.sql'; } foreach ( $names as $name ) { $add( $site_url . '/' . $name, 'sig', 'archive', 'archive', 'wordlist' ); } foreach ( array_filter( array( $content_url, $uploads_url ) ) as $deep ) { foreach ( self::ARCHIVE_NAMES_DEEP as $name ) { $add( $deep . '/' . $name, 'sig', 'archive', 'archive', 'wordlist' ); } } // 3. Files that execute what is posted to them. foreach ( self::ROOT_EXEC_FILES as $rel ) { $add( $site_url . '/' . $rel, 'exec', 'rce_file', '', 'wordlist' ); } foreach ( self::exec_files_in_packages() as $url ) { $add( $url, 'exec', 'rce_file', '', 'disk' ); } // 4. Real directories, by their real URL. The three big trees once each, at the top. foreach ( self::package_roots() as $root ) { $add( $root[1] . '/', 'folder', 'listing', 'listing', 'disk' ); } if ( '' !== $uploads_url ) { $add( $uploads_url . '/', 'folder', 'listing', 'listing', 'disk' ); } foreach ( self::disk_dirs() as $d ) { // Real by construction. One with a leftover's name is worth a low note even // when it lists nothing; an ordinary one is only a finding if it lists. $add( $d['url'], 'folder', 'listing', 'listing', 'disk', 1 === preg_match( self::LEFTOVER_PATTERN, rawurldecode( basename( $d['url'] ) ) ) ); } // 5. A second WordPress install in a leftover folder. foreach ( self::INSTALL_NAMES as $name ) { $add( $site_url . '/' . $name . '/wp-login.php', 'sig', 'second_install', 'wp_login', 'wordlist' ); } // 6. The name list. foreach ( self::names() as $name ) { if ( 0 === strpos( $name, '@content/' ) ) { $rest = substr( $name, 9 ); $url = $content_url . '/' . $rest . '/'; $real = false; // Plugin data folders are meant to exist. Only a listing counts. } else { $url = $site_url . '/' . $name . '/'; $real = '' !== $site_root && 1 === preg_match( self::LEFTOVER_PATTERN, $name ) && is_dir( $site_root . '/' . $name ); } if ( ! isset( $tasks[ $url ] ) ) { $add( $url, 'folder', 'listing', 'listing', 'wordlist', $real ); } } return array_values( $tasks ); } /** Whether the owner has switched the scan on. */ public static function enabled(): bool { return '1' === (string) get_option( self::OPTION_ENABLED, '0' ); } /** * Saves the switch. On starts a run within minutes; off clears the schedule and the stored result. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $on = isset( $_POST['enabled'] ) && '1' === sanitize_key( wp_unslash( $_POST['enabled'] ) ); update_option( self::OPTION_ENABLED, $on ? '1' : '0', false ); delete_option( self::OPTION_STATE ); if ( ! $on ) { delete_option( self::OPTION_RESULT ); } self::sync_schedule(); wp_send_json_success( array( 'enabled' => $on ) ); } /** * The switch, for the AI Cyber Audit panel. */ public static function render_switch(): void { $count = self::round_names(); ?>
✓ 'Off until you switch it on', 'rec' => 'Optional', 'html' => '

Nothing runs until this box is ticked. Unticked, the plugin makes no requests for this check, schedules nothing, and the audit says the check is off.

Unticking it later stops the daily run and removes the stored result.

', ), array( 'name' => 'What it does when on', 'rec' => 'Info', 'html' => '

Once a day, in the background, the plugin asks this site, and only this site, for the real folders on its own server, for the files that prove a leak (source control data, copies of wp-config.php, .env files, backups, database tools) and for about ' . esc_html( $count ) . ' common leftover, backup and staging folder names. It works in batches of ' . (int) self::BATCH_REQUESTS . ' requests and a full run takes about half an hour of light background work.

A page that only answers 200 is never a finding. The content of the answer has to prove it. There is no target field, and the result stays in your own database for the audit to read.

', ), ), '', true ); ?>
$type, 'path' => $path, 'evidence' => $evidence, 'source' => (string) ( $task['source'] ?? '' ), // Kept so an audit can ask this one question again. Never leaves the site. 'task' => $task, ); } /** * One GET of a URL on this site. * * wp_remote_get() and not wp_safe_remote_get(): the URL is built from this site's own * addresses, never from input, and the safe variant refuses private and loopback * addresses, which is where most of the hosts this runs on resolve themselves to. * * @param string $url Absolute URL on this site. * @return array{code:int,body:string} */ private static function fetch( string $url ): array { $resp = wp_remote_get( $url, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => self::REQUEST_TIMEOUT, 'redirection' => 0, 'limit_response_size' => self::BODY_LIMIT, 'sslverify' => csdt_loopback_ssl_verify(), ) ); if ( is_wp_error( $resp ) ) { return array( 'code' => 0, 'body' => '', ); } return array( 'code' => (int) wp_remote_retrieve_response_code( $resp ), 'body' => (string) wp_remote_retrieve_body( $resp ), ); } // ── Signatures ─────────────────────────────────────────────────────── /** * What each signature means, for the finding text. The keys are the full set. * * @return array */ private static function signature_labels(): array { return array( 'listing' => 'the answer is a file listing', 'git_head' => 'the answer is a git HEAD reference', 'git_config' => 'the answer is a git config file', 'sqlite' => 'the answer is a Subversion working copy database', 'svn_entries' => 'the answer is a Subversion entries file', 'hg_requires' => 'the answer is a Mercurial requires file', 'dotenv' => 'the answer is an environment file of NAME=value settings', 'wp_config' => 'the answer is a copy of wp-config.php with the database password', 'aws' => 'the answer contains AWS access keys', 'private_key' => 'the answer is a private key', 'ssh_public' => 'the answer is an SSH authorized_keys file', 'sftp_json' => 'the answer is an editor deployment file with credentials', 'idea' => 'the answer is an editor workspace file', 'ds_store' => 'the answer is a macOS folder index, which lists file names', 'composer_installed' => 'the answer is the Composer package list with exact versions', 'composer_json' => 'the answer is a composer.json file', 'package_json' => 'the answer is a package.json file', 'npm_lock' => 'the answer is the npm package list with exact versions', 'htaccess' => 'the answer is the web server rules file', 'php_log' => 'the answer is a PHP error log', 'phpinfo' => 'the answer is a phpinfo() page', 'server_status' => 'the answer is the Apache server status page', 'server_info' => 'the answer is the Apache server information page', 'phpmyadmin' => 'the answer is a phpMyAdmin page', 'adminer' => 'the answer is an Adminer page', 'wp_login' => 'the answer is a WordPress login form', 'wp_readme' => 'the answer is the WordPress readme', 'gpl_license' => 'the answer is the WordPress licence file', 'archive' => 'the answer starts with the bytes of a zip, gzip or SQL dump', ); } /** Every signature id. */ private static function signature_ids(): array { return array_keys( self::signature_labels() ); } /** What a matched signature means. */ private static function signature_label( string $id ): string { return self::signature_labels()[ $id ] ?? $id; } /** * Whether a body carries the mark only the real thing produces. * * @param string $id Signature id. * @param string $body Response body, already capped at BODY_LIMIT. * @return bool */ public static function matches( string $id, string $body ): bool { if ( '' === $body ) { return false; } $html = 1 === preg_match( '#\s*Index of |

\s*Index of |Parent Directory|\[To Parent Directory\]#i', $body ); case 'git_head': return 1 === preg_match( '#^(ref: refs/[\w./-]+|[0-9a-f]{40})\s*$#', trim( $body ) ); case 'git_config': return false !== strpos( $body, '[core]' ) && false !== strpos( $body, 'repositoryformatversion' ); case 'sqlite': return 0 === strpos( $body, 'SQLite format 3' ); case 'svn_entries': return 1 === preg_match( '#^(8|9|10|12)\s*\n#', $body ); case 'hg_requires': return ! $html && strlen( $body ) < 400 && 1 === preg_match( '#^(revlogv1|store|fncache|dotencode)\s*$#m', $body ); case 'dotenv': return ! $html && 1 === preg_match( '#^[A-Z][A-Z0-9_]{2,}\s*=\s*\S#m', $body ); case 'wp_config': return 1 === preg_match( '#define\s*\(\s*[\'"]DB_PASSWORD[\'"]#', $body ); case 'aws': return 1 === preg_match( '#aws_(access_key_id|secret_access_key)\s*=#i', $body ); case 'private_key': return 1 === preg_match( '#-----BEGIN [A-Z ]*PRIVATE KEY-----#', $body ); case 'ssh_public': return 1 === preg_match( '#^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\w+) AAAA#m', $body ); case 'sftp_json': return ! $html && 1 === preg_match( '#"(password|privateKeyPath|passphrase)"\s*:#', $body ); case 'idea': return false !== strpos( $body, '[^<]*phpinfo\(\)|

PHP Version#i', $body ); case 'server_status': return 1 === preg_match( '#Apache Server Status for|Server uptime:#i', $body ); case 'server_info': return 1 === preg_match( '#Apache Server Information|Server Settings#', $body ); case 'phpmyadmin': return 1 === preg_match( '#pma_username|pmahomme|[^<]*phpMyAdmin#i', $body ); case 'adminer': return 1 === preg_match( '#adminer\.org|<title>[^<]*Adminer#i', $body ); case 'wp_login': return 1 === preg_match( '#id=["\']loginform["\']|name=["\']wp-submit["\']#', $body ); case 'wp_readme': return 1 === preg_match( '#<title>\s*WordPress[^<]*ReadMe#i', $body ); case 'gpl_license': return ! $html && false !== strpos( $body, 'GNU GENERAL PUBLIC LICENSE' ); case 'archive': return 0 === strpos( $body, "PK\x03\x04" ) || 0 === strpos( $body, "\x1f\x8b" ) || ( ! $html && 1 === preg_match( '#^(-- MySQL dump|-- phpMyAdmin SQL Dump|-- MariaDB dump|CREATE TABLE|INSERT INTO|DROP TABLE)#m', substr( $body, 0, 4096 ) ) ); } return false; } // ── The disk ───────────────────────────────────────────────────────── /** * The real directories under the web root, each with the URL a visitor would use. * * Three roots, because their URLs are independent settings: the install root * (site_url), the content directory (content_url, which can be relocated) and the * uploads directory (which can be relocated again). Dated upload folders are * skipped: there are hundreds, they are all served by the same rule, and one * answer for a sibling says everything they would. * * @return array<int,array{url:string}> */ public static function disk_dirs(): array { $site_root = rtrim( CSDT_Paths::site_root(), '/' ); $content_dir = rtrim( CSDT_Paths::content_dir(), '/' ); $uploads = wp_get_upload_dir(); $uploads_dir = isset( $uploads['basedir'] ) ? rtrim( (string) $uploads['basedir'], '/' ) : ''; $uploads_url = isset( $uploads['baseurl'] ) ? rtrim( (string) $uploads['baseurl'], '/' ) : ''; $roots = array(); if ( '' !== $site_root && is_dir( $site_root ) ) { $roots[] = array( $site_root, rtrim( (string) site_url(), '/' ), 1 ); } if ( '' !== $content_dir && is_dir( $content_dir ) ) { $roots[] = array( $content_dir, rtrim( (string) content_url(), '/' ), 2 ); } if ( '' !== $uploads_dir && '' !== $uploads_url && is_dir( $uploads_dir ) ) { $roots[] = array( $uploads_dir, $uploads_url, 2 ); } $root_paths = array( $site_root, $content_dir, $uploads_dir ); $out = array(); foreach ( $roots as $root ) { $queue = array( array( $root[0], $root[1], 0 ) ); while ( ! empty( $queue ) ) { list( $dir, $url, $depth ) = array_shift( $queue ); $names = scandir( $dir ); foreach ( is_array( $names ) ? $names : array() as $name ) { if ( '.' === $name || '..' === $name ) { continue; } $path = $dir . '/' . $name; if ( ! is_dir( $path ) || is_link( $path ) ) { continue; } // Another root owns this tree and its URL; walking it from here would // guess the URL instead of reading it. if ( in_array( $path, $root_paths, true ) ) { continue; } if ( in_array( strtolower( $name ), self::SKIP_DIRS, true ) ) { continue; } if ( $dir === $uploads_dir && 1 === preg_match( '#^(19|20)\d{2}$#', $name ) ) { continue; } if ( count( $out ) >= self::DISK_MAX_DIRS ) { return $out; } $child_url = $url . '/' . rawurlencode( $name ); $out[] = array( 'url' => $child_url . '/' ); if ( $depth + 1 < $root[2] ) { $queue[] = array( $path, $child_url, $depth + 1 ); } } } } return $out; } /** * Plugin and theme folders that still carry a .git directory. * * A package deployed by cloning leaves its whole history beside the code. The * walk above does not enter these trees, so this looks one level in, on disk, * and returns a URL only for a .git that is really there. * * @return string[] URLs of .git/HEAD files to ask for. */ private static function vcs_in_packages(): array { $out = array(); foreach ( self::package_roots() as $root ) { foreach ( self::children( $root[0] ) as $name ) { if ( is_dir( $root[0] . '/' . $name . '/.git' ) ) { $out[] = $root[1] . '/' . rawurlencode( $name ) . '/.git/HEAD'; } if ( count( $out ) >= 10 ) { return $out; } } } return $out; } /** * PHPUnit's eval-stdin.php where a plugin or theme really ships it. * * @return string[] URLs to ask for. */ private static function exec_files_in_packages(): array { $out = array(); $rel = 'vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php'; foreach ( self::package_roots() as $root ) { foreach ( self::children( $root[0] ) as $name ) { if ( is_file( $root[0] . '/' . $name . '/' . $rel ) ) { $out[] = $root[1] . '/' . rawurlencode( $name ) . '/' . $rel; } if ( count( $out ) >= 10 ) { return $out; } } } return $out; } /** * The plugins and themes directories, each with its URL. * * @return array<int,array{0:string,1:string}> */ private static function package_roots(): array { $roots = array(); $dir = rtrim( CSDT_Paths::plugins_dir(), '/' ); if ( '' !== $dir && is_dir( $dir ) ) { $roots[] = array( $dir, rtrim( CSDT_Paths::plugins_url_base(), '/' ) ); } if ( function_exists( 'get_theme_root' ) && function_exists( 'get_theme_root_uri' ) ) { $dir = rtrim( (string) get_theme_root(), '/' ); if ( '' !== $dir && is_dir( $dir ) ) { $roots[] = array( $dir, rtrim( (string) get_theme_root_uri(), '/' ) ); } } return $roots; } /** * Names of the sub-directories of a directory, capped. * * @param string $dir Absolute path. * @return string[] */ private static function children( string $dir ): array { $out = array(); $names = scandir( $dir ); foreach ( is_array( $names ) ? $names : array() as $name ) { if ( '.' === $name || '..' === $name || ! is_dir( $dir . '/' . $name ) ) { continue; } $out[] = $name; if ( count( $out ) >= 300 ) { break; } } return $out; } /** A path segment that cannot exist on this site. */ private static function random_name(): string { return 'csdt-probe-' . strtolower( wp_generate_password( 16, false ) ); } /** Now, in seconds. */ private static function now(): float { return is_callable( self::$clock ) ? (float) call_user_func( self::$clock ) : microtime( true ); } // ── What the audit does with it ────────────────────────────────────── /** * Bounded summary for the AI fact set. * * Paths only, never full URLs: a report that is emailed or handed to a model should * not carry a working link to the thing it warns about. * * @param mixed $scan latest() output. * @return array */ public static function summarise( $scan ): array { if ( ! is_array( $scan ) ) { return array( 'status' => 'pending', 'checked' => 0, ); } $hits = array(); foreach ( array_slice( (array) ( $scan['hits'] ?? array() ), 0, 25 ) as $h ) { $hits[] = array( 'type' => (string) ( $h['type'] ?? '' ), 'path' => (string) ( $h['path'] ?? '' ), 'evidence' => (string) ( $h['evidence'] ?? '' ), ); } $finished = (int) ( $scan['finished'] ?? 0 ); return array( 'status' => (string) ( $scan['status'] ?? 'pending' ), 'view' => 'as served to this server; a proxy in front of the site may be stricter', 'finished_utc' => $finished > 0 ? gmdate( 'Y-m-d H:i', $finished ) : '', 'checked' => (int) ( $scan['checked'] ?? 0 ), 'dirs_on_disk' => (int) ( $scan['dirs_on_disk'] ?? 0 ), // true means every "nothing found" in this run is unproven. 'inconclusive' => ! empty( $scan['inconclusive'] ), 'catch_all' => ! empty( $scan['catch_all'] ), 'hits_retested_now' => ! empty( $scan['retested'] ), 'hits' => $hits, ); } /** * The facts the external checks used to gather by guessing, now from proven hits. * * The AI fact set and its prompts read these keys. They keep their names so the * model's inventory keeps its shape; what changed is that an entry now means a * signature matched, where it used to mean a 200. * * @param mixed $scan latest() output. * @return array{exposed_files:array,db_tools_exposed:array,backup_files_exposed:array,server_status_exposed:bool,server_info_exposed:bool,uploads_listing:?bool,plugins_listing:?bool,themes_listing:?bool} */ public static function legacy_facts( $scan ): array { $known = is_array( $scan ) && in_array( (string) ( $scan['status'] ?? '' ), array( 'complete', 'stale' ), true ); $out = array( 'exposed_files' => array(), 'db_tools_exposed' => array(), 'backup_files_exposed' => array(), 'server_status_exposed' => false, 'server_info_exposed' => false, // null, not false, until a run has finished: "not listed" must be something seen. 'uploads_listing' => $known ? false : null, 'plugins_listing' => $known ? false : null, 'themes_listing' => $known ? false : null, ); if ( ! $known ) { return $out; } $path_of = static function ( string $url ): string { return trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ) . '/'; }; $uploads = wp_get_upload_dir(); $tops = array( 'uploads_listing' => isset( $uploads['baseurl'] ) ? $path_of( (string) $uploads['baseurl'] ) : '', 'plugins_listing' => $path_of( CSDT_Paths::plugins_url_base() ), 'themes_listing' => function_exists( 'get_theme_root_uri' ) ? $path_of( (string) get_theme_root_uri() ) : '', ); foreach ( (array) ( $scan['hits'] ?? array() ) as $h ) { $type = (string) ( $h['type'] ?? '' ); $path = (string) ( $h['path'] ?? '' ); if ( 'db_tool' === $type ) { $out['db_tools_exposed'][] = $path; } elseif ( 'archive' === $type ) { $out['backup_files_exposed'][] = $path; } elseif ( 'server_status' === $type ) { $out[ 'server-info' === $path ? 'server_info_exposed' : 'server_status_exposed' ] = true; } elseif ( 'listing' === $type ) { foreach ( $tops as $key => $top ) { if ( '' !== $top && $path === $top ) { $out[ $key ] = true; } } } elseif ( in_array( $type, array( 'vcs', 'secret', 'phpinfo', 'dev', 'info_file' ), true ) ) { $out['exposed_files'][] = $path; } } return $out; } /** * Turns the latest result into audit findings. * * Rule based on purpose. A git repository that answers is a fact, and severity is * set by what an attacker gains from it, not by a model's reading of the report. * * @param mixed $scan latest() output. * @return array<int,array<string,string>> */ public static function findings( $scan ): array { if ( ! is_array( $scan ) || empty( $scan ) ) { return array(); } $status = (string) ( $scan['status'] ?? 'pending' ); if ( 'off' === $status ) { return array( array( 'key' => 'exposed_folders_off', 'category' => 'Security', 'severity' => 'info', 'title' => 'The exposed folders check is switched off', 'detail' => 'This audit did not look for folders and files served to a visitor who is not signed in, such as a .git folder, a copy of wp-config.php, a backup at a common name or a folder that lists its files. That check is optional and off until you switch it on.', 'fix' => 'To include it, tick "Exposed folders check" in the AI Cyber Audit panel. It then runs once a day in the background, on this site only, and the result appears in the next audit.', ), ); } if ( 'pending' === $status || 'running' === $status ) { $progress = 'running' === $status && (int) ( $scan['total'] ?? 0 ) > 0 ? ' It has made ' . (int) ( $scan['checked'] ?? 0 ) . ' requests so far.' : ''; return array( array( 'key' => 'exposed_folders_pending', 'category' => 'Security', 'severity' => 'info', 'title' => 'running' === $status ? 'The exposed folders scan is running' : 'The exposed folders scan has not run yet', 'detail' => 'This scan asks the site for its own real folders and for about ' . self::round_names() . ' leftover, backup and tool names, to find anything served to a visitor who is not signed in. It runs in the background once a day, in small batches, and its first run on this site has not finished.' . $progress, 'fix' => 'Nothing to do. Run the audit again in about an hour. If this message is still here tomorrow, WordPress scheduled tasks are not running on this site.', ), ); } $by_type = array(); foreach ( (array) ( $scan['hits'] ?? array() ) as $h ) { $by_type[ (string) ( $h['type'] ?? '' ) ][] = $h; } $checked = (int) ( $scan['checked'] ?? 0 ); $age = function_exists( 'human_time_diff' ) ? human_time_diff( (int) ( $scan['finished'] ?? 0 ) ) : ''; $when = '' !== $age ? " The last full run finished {$age} ago" . ( ! empty( $scan['retested'] ) ? ', and this path was asked for again just now.' : '.' ) : ''; $deny = ' Refuse it at the web server. nginx: location ~ /\.(git|svn|hg|env|aws|ssh|vscode|idea) { return 404; } Apache: RedirectMatch 404 /\.(git|svn|hg|env|aws|ssh|vscode|idea). Then run this audit again: only a refusal proves it.'; $specs = array( 'vcs' => array( 'key' => 'exposed_vcs', 'severity' => 'critical', 'Source control data is readable from the internet: %s', '%d source control paths are readable from the internet, first %s', 'Anyone can read %s without signing in (%s). From a readable .git, .svn or .hg folder the whole repository can be rebuilt file by file: every PHP file, wp-config.php if it was ever committed, and every password or key in the history, including ones removed since.', 'Remove the folder from the server if the site is not deployed from it. If it is, keep it and refuse it.' . $deny . ' Change any password or key that was ever committed.', ), 'secret' => array( 'key' => 'exposed_secret_files', 'severity' => 'critical', 'A file of passwords or keys is readable from the internet: %s', '%d files of passwords or keys are readable from the internet, first %s', 'Anyone can read %s without signing in (%s). These are working credentials: the database password and login salts, or keys to a cloud account, a server or a deployment target. Removing the file does not un-leak them.', 'Delete the file from the web root and replace every password and key in it today: one that was readable must be treated as copied. For a wp-config.php copy that means the database password and all eight salts.' . $deny, ), 'archive' => array( 'key' => 'exposed_root_archives', 'severity' => 'critical', 'A backup or database dump is downloadable: %s', '%d backups or database dumps are downloadable, first %s', 'Anyone can download %s without signing in (%s). A site archive carries wp-config.php and a database dump carries every user password hash, and these file names are the first ones every scanner tries.', 'Delete it or move it out of the web root now, then change the database password and the salts. Refuse archive types at the web server so the next one is not served. nginx: location ~* \.(zip|sql|gz|tar|bak)$ { return 404; } Apache: RedirectMatch 404 \.(zip|sql|gz|tar|bak)$. Narrow the rule if the site offers downloads of those types.', ), 'db_tool' => array( 'key' => 'exposed_db_tool', 'severity' => 'critical', 'A database administration tool answers on the internet: %s', '%d database administration tools answer on the internet, first %s', 'Anyone can open %s (%s). It is a login form for the database itself, outside every WordPress control: no two factor step, no login limit, no hidden login address. Old versions of these tools also carry their own remote code execution flaws.', 'Delete it. If it is needed, put it behind the web server: an IP allow list or HTTP authentication on that path (nginx: allow and deny, or auth_basic; Apache: Require ip). Never leave it reachable between uses.', ), 'rce_file' => array( 'key' => 'exposed_rce_file', 'severity' => 'critical', 'A file that runs posted code is reachable: %s', '%d files that run posted code are reachable, first %s', '%s answers on the internet (%s). PHPUnit\'s eval-stdin.php runs whatever PHP is posted to it (CVE-2017-9841). It is a test tool that was never meant to be deployed.', 'Delete the vendor/phpunit folder from the server: production needs no test tools (composer install --no-dev). Until then refuse the path. nginx: location ~ /vendor/ { return 404; } Apache: RedirectMatch 404 /vendor/. Then check the access log for POSTs to that file.', ), 'phpinfo' => array( 'key' => 'exposed_phpinfo', 'severity' => 'high', 'A phpinfo() page is public: %s', '%d phpinfo() pages are public, first %s', 'Anyone can open %s (%s). It prints the full server configuration: file paths, loaded modules with versions, and environment variables, which on many hosts include passwords and API keys.', 'Delete the file. It is a diagnostic that should exist only for the minutes it is being read.', ), 'server_status' => array( 'key' => 'exposed_server_status', 'severity' => 'high', 'The web server status page is public: %s', '%d web server status pages are public, first %s', 'Anyone can open %s (%s). It shows every request being served right now, with visitor addresses and full URLs, including password reset links and anything else carried in a query string.', 'Restrict it to the server itself. Apache: inside the <Location /server-status> block use Require local. Or switch mod_status and mod_info off if nobody reads them.', ), 'listing' => array( 'key' => 'exposed_dir_listing', 'severity' => 'high', 'A folder shows its file listing to anyone: %s', '%d folders show their file listing to anyone, first %s', 'The web server lists the contents of %s (%s). A listing removes the only protection a file with an unguessable name has: the visitor no longer has to guess. Logs, exports, form uploads and backups are found this way.', 'Turn listings off for the whole site. nginx: remove the autoindex on; line (off is the default). Apache: Options -Indexes. Then refuse the folders that should not be served at all, and run this audit again.', ), 'second_install' => array( 'key' => 'exposed_leftover_install', 'severity' => 'high', 'A second WordPress install answers at %s', '%d other WordPress installs answer on this site, first %s', '%s serves a WordPress login form (%s). A forgotten copy gets no updates and none of this site\'s protection, shares its server and often its database password, and is a usual way in to an otherwise patched site.', 'Remove the copy, files and database. If it is a staging site that must stay, move it off the public server or put HTTP authentication on the whole folder, and keep it updated like the live site.', ), 'dev' => array( 'key' => 'exposed_dev_files', 'severity' => 'medium', 'A development or server file is readable: %s', '%d development or server files are readable, first %s', 'Anyone can read %s (%s). These files give away server paths, rewrite and access rules, or the exact version of every bundled library, which turns a search for a vulnerable component into a lookup.', 'Delete what the site does not need (.DS_Store, editor folders, old logs) and refuse the rest. nginx: location ~ /(vendor|node_modules)/|/(composer\.(json|lock)|package\.json|error_log)$ { return 404; } Apache: RedirectMatch 404 /(vendor|node_modules)/ and a FilesMatch block that denies composer.json, composer.lock, package.json and error_log. Apache already refuses .htaccess unless that default was removed.', ), 'info_file' => array( 'key' => 'exposed_info_files', 'severity' => 'low', 'A WordPress information file is readable: %s', '%d WordPress information files are readable, first %s', 'Anyone can read %s (%s). The harm is small: it confirms the site runs WordPress, and readme.html used to name the version.', 'Optional. Delete readme.html and license.txt, or refuse them at the web server. WordPress puts them back on every core update.', ), 'leftover' => array( 'key' => 'leftover_folders', 'severity' => 'low', 'A leftover folder is served from the web root: %s', '%d leftover folders are served from the web root, first %s', '%s is a real folder on this server and answers requests (%s). Nothing in it was shown to this scan, but a folder with a name every scanner tries is where the next forgotten file gets found.', 'Move it out of the web root or delete it. If it has to stay, refuse the whole folder at the web server.', ), ); $findings = array(); foreach ( $specs as $type => $spec ) { $hits = $by_type[ $type ] ?? array(); if ( empty( $hits ) ) { continue; } $count = count( $hits ); $first = (string) $hits[0]['path']; $paths = array(); foreach ( array_slice( $hits, 0, 6 ) as $h ) { $paths[] = (string) $h['path']; } $more = $count > 1 ? ' Also: ' . implode( ', ', array_slice( $paths, 1 ) ) . ( $count > 6 ? ', and ' . ( $count - 6 ) . ' more' : '' ) . '.' : ''; $findings[] = array( 'key' => $spec['key'], 'category' => 'Security', 'severity' => $spec['severity'], 'title' => 1 === $count ? sprintf( $spec[0], $first ) : sprintf( $spec[1], $count, $first ), 'detail' => sprintf( $spec[2], $first, (string) $hits[0]['evidence'] ) . $more . $when . ' Checked as this server reaches the site; a proxy in front of it may refuse what the server itself allows.', 'fix' => $spec[3], 'affected' => implode( ', ', $paths ), ); } if ( ! empty( $scan['inconclusive'] ) ) { $code = (int) ( $scan['control_code'] ?? 0 ); $findings[] = array( 'key' => 'exposed_folders_inconclusive', 'category' => 'Security', 'severity' => 'low', 'title' => 'The exposed folders scan could not verify itself', 'detail' => "Every batch of this scan first fetches the home page, which is meant to be public, and the run ends with the same request. The last one answered HTTP {$code}, so something refused this server and the run stopped after {$checked} requests. Anything it found is reported above; everything it did not find is unproven, not clean." . ' Usual causes: bot protection challenging requests from the server, a firewall or rate limit refusing its address, or the site name resolving to an address this host cannot use.', 'fix' => 'Allow this server to request its own pages (allow its address in the firewall, proxy or rate limit in front of the site). The scan tries again at its next daily run. Until then, check the folders by hand from outside.', ); } elseif ( 'stale' === $status ) { $findings[] = array( 'key' => 'exposed_folders_stale', 'category' => 'Security', 'severity' => 'low', 'title' => "The exposed folders scan last finished {$age} ago", 'detail' => 'This scan runs in the background once a day. It has not finished a run for more than three days, so a folder exposed since then would not be reported here.', 'fix' => 'Check that WordPress scheduled tasks are running. If DISABLE_WP_CRON is set, a system cron must run them (wp cron event run --due-now, from the WordPress directory).', ); } elseif ( empty( $findings ) ) { $dirs = (int) ( $scan['dirs_on_disk'] ?? 0 ); $findings[] = array( 'key' => 'exposed_folders_ok', 'category' => 'Security', 'severity' => 'info', 'title' => "No exposed folders found ({$checked} requests, {$dirs} real folders checked)", 'detail' => 'No source control data, credentials file, backup, database tool or file listing answered, and the home page was reachable at the start of every batch and at the end, so those refusals are real.' . $when . ( ! empty( $scan['catch_all'] ) ? ' This site answers 200 for addresses that do not exist, so only content was trusted, never a status code.' : '' ) . ' Checked as this server reaches the site.', 'affected' => "{$checked} requests", ); } return $findings; } /** The size of the name list, rounded down to the hundred, for prose. */ private static function round_names(): string { $n = count( self::names() ); return number_format( $n >= 100 ? $n - ( $n % 100 ) : $n ); } }