array( 1, Level::ANOMALY, 'md5 of %1$s compared to the WordPress.org checksums of WordPress %2$s (%3$s): different.', 'Replace this file with the one from the official WordPress %2$s package, or show the difference to your host.' ), 'core_missing' => array( 1, Level::TO_IDENTIFY, 'Listed in the WordPress.org checksums of WordPress %2$s (%3$s), not found on disk.', 'Check whether the file was removed on purpose; reinstalling WordPress %2$s puts it back.' ), 'core_unknown_file' => array( 1, Level::ANOMALY, 'Not listed in the WordPress.org checksums of WordPress %2$s (%3$s), although it sits in a core folder.', 'Find out who put this file there: core folders only hold files from the official package.' ), 'core_root_unknown_php' => array( 1, Level::SUSPECT, 'PHP file at the site root, not listed in the WordPress.org checksums of WordPress %2$s (%3$s).', 'Identify this file; if nobody on your side created it, show it to your host.' ), // args: path, slug (DECISIONS 2026-10-24, D-02, D-03). 'core_root_plugin_file' => array( 1, Level::TO_IDENTIFY, 'PHP file at the site root, not listed in the WordPress.org checksums; known as the firewall file of %2$s, which is installed.', 'Make sure the firewall of %2$s was set up on this site: its setup writes this file at the root.' ), // args: path, folder (Q-01). 'other_dir_suspect_php' => array( 1, Level::SUSPECT, 'PHP file in the %2$s folder, which has no reason to hold PHP on a WordPress site.', 'Identify this file; if nobody on your side put it there, keep a copy for your host, then remove it from the site.' ), // Member code, folded into other_dir_php_by_folder. args: path, folder. 'other_dir_php' => array( 1, Level::TO_IDENTIFY, '%1$s', '' ), // Summary. args: folder, count. 'other_dir_php_by_folder' => array( 1, Level::TO_IDENTIFY, 'Folder %1$s at the site root, outside WordPress: %2$s PHP files, not compared to any reference.', 'Make sure you know what this folder holds (often another application); its PHP files were not judged.' ), // args: marker. 'config_marker' => array( 1, Level::ANOMALY, 'wp-config.php compared to the markers it should not hold; found: %1$s.', 'Compare wp-config.php with wp-config-sample.php and ask your host about every line you cannot explain.' ), // No args (DECISIONS 2026-10-24). 'config_docker_extra' => array( 1, Level::TO_IDENTIFY, 'wp-config.php compared to the block written by the official WordPress Docker image: identical, its eval() runs the WORDPRESS_CONFIG_EXTRA variable.', 'If your site does not run on the official WordPress Docker image, ask your host who added this block.' ), // args: statement. 'config_uploads_include' => array( 1, Level::ANOMALY, 'wp-config.php loads a file from the uploads folder: %1$s', 'Compare wp-config.php with wp-config-sample.php and show this line to your host.' ), // args: statement (D-12). 'config_escaped_include' => array( 1, Level::ANOMALY, 'wp-config.php loads a file whose path is written in escape sequences: %1$s', 'Compare wp-config.php with wp-config-sample.php and show this line to your host.' ), // Control 2 — plugins. args: path, slug, version, algorithm. 'plugin_modified' => array( 2, Level::ANOMALY, '%4$s of %1$s compared to the WordPress.org checksums of %2$s %3$s: different.', 'Reinstall %2$s %3$s from WordPress.org, or show the difference to your host.' ), // args: path, slug, version. 'plugin_unknown_php' => array( 2, Level::SUSPECT, 'PHP file not listed in the WordPress.org checksums of %2$s %3$s.', 'Identify this file: the published version of %2$s does not contain it.' ), 'plugin_extra_file' => array( 2, Level::COUNTED, 'File other than PHP not listed in the WordPress.org checksums of %2$s %3$s.', '' ), // args: path (D-04). 'plugin_silence_index' => array( 2, Level::COUNTED, 'index.php of at most 200 bytes in a plugin folder, running no code.', '' ), // args: path, version, locale. 'plugin_hello_modified' => array( 2, Level::ANOMALY, 'md5 of %1$s compared to the entry wp-content/plugins/hello.php of the WordPress.org checksums of WordPress %2$s (%3$s): different.', 'Replace hello.php with the one from the official WordPress %2$s package, or delete Hello Dolly if you do not use it.' ), // Control 3 — uploads. args: path, extension. 'uploads_exec_extension' => array( 3, Level::ANOMALY, 'File name compared to the PHP extensions; found: .%2$s.', 'The uploads folder only holds media: keep a copy of this file for your host, then remove it from the site.' ), // args: path, slug, header (D-01, D-02). 'uploads_plugin_data' => array( 3, Level::TO_IDENTIFY, 'PHP file in the folder %2$s keeps its data in, starting with the header this plugin writes (%3$s); %2$s is installed.', 'Make sure %2$s is in use on this site; a file of this folder without that header would be an anomaly.' ), // args: path, directive, extension or target. 'uploads_access_exec' => array( 3, Level::ANOMALY, 'Compared to the rules on PHP handlers and auto_prepend_file; found: %2$s (%3$s).', 'This file makes the server run PHP in the uploads folder: keep a copy for your host, then remove it from the site.' ), // args: path. 'uploads_access_file' => array( 3, Level::SUSPECT, 'Compared to the only lines expected here (deny from all, Require all denied, Options -Indexes, Order, lines that turn PHP off, Files and IfModule containers, comments, empty lines): other lines found.', 'Read this file: it changes how the server treats the uploads folder.' ), // args: path, tag. 'uploads_php_content' => array( 3, Level::ANOMALY, 'First 4 KB of the file compared to the PHP opening tags; found: %2$s.', 'A media file has no reason to hold PHP code: keep a copy for your host, then remove it from the site.' ), 'uploads_silence_index' => array( 3, Level::COUNTED, 'index.php of at most 200 bytes holding only a PHP opening tag and comments.', '' ), // Control 4 — must-use plugins and drop-ins. args: path, name, author, size, date. 'mu_plugin' => array( 4, Level::TO_IDENTIFY, 'Must-use plugin, loaded on every page without activation. Plugin Name: %2$s; Author: %3$s; %4$s bytes; modified %5$s.', 'Make sure you or your host installed this must-use plugin.' ), // args: path, name, author, size, date, marker. 'mu_plugin_marker' => array( 4, Level::SUSPECT, 'Must-use plugin compared to the obfuscation markers; found: %6$s. Plugin Name: %2$s; Author: %3$s; %4$s bytes; modified %5$s.', 'Read this must-use plugin with your host: the marker has legitimate uses but is common in injected code.' ), // args: path, name, author. 'dropin' => array( 4, Level::TO_IDENTIFY, 'Drop-in known to this WordPress, loaded by the core when present. Plugin Name: %2$s; Author: %3$s.', 'Find which plugin or host put this drop-in in place.' ), // args: path, slug. 'content_dir_plugin_config' => array( 4, Level::TO_IDENTIFY, 'PHP file at the first level of the content folder, known as the configuration file of %2$s, which is installed.', 'Make sure %2$s wrote this file; it is not a drop-in and WordPress does not load it by itself.' ), // args: path. 'content_dir_unknown_php' => array( 4, Level::SUSPECT, 'PHP file at the first level of the content folder, neither a drop-in known to this WordPress nor index.php.', 'Identify this file; WordPress does not load it by itself.' ), // Control 5 — accounts. args: login, id, registered, reference date. 'user_registered_after' => array( 5, Level::SUSPECT, 'Account holding a listed capability, registered on %3$s, on or after the reference date %4$s.', 'Make sure you know who created account %1$s (ID %2$s).' ), // args: login, id, capabilities outside roles, roles. 'user_cap_outside_role' => array( 5, Level::SUSPECT, 'Capabilities granted directly to the account, not by its roles (%4$s): %3$s.', 'Check why account %1$s (ID %2$s) holds these capabilities on its own.' ), // args: id. 'user_orphan_admin' => array( 5, Level::ANOMALY, 'Capabilities metadata gives the administrator role to user ID %1$s, which does not exist in the users table.', 'Show this metadata row to your host: it points to an account that WordPress does not list.' ), // Member code, folded into privileged_users. args: login, id, registered. 'privileged_user' => array( 5, Level::TO_IDENTIFY, 'Account %1$s (ID %2$s) holds a listed capability, registered on %3$s.', '' ), // Summary. args: count, reference date. 'privileged_users' => array( 5, Level::TO_IDENTIFY, '%1$s accounts hold at least one listed capability and were registered before %2$s.', 'Go through this list and make sure you know every account.' ), // Control 6 — .htaccess and ini files. args: path, directive, host, line. 'htaccess_external_redirect' => array( 6, Level::ANOMALY, '%2$s target compared to the hosts of home and siteurl; points to %3$s. Line: %4$s', 'Remove this line after a backup, or ask your host who added it.' ), // args: path, variable, line. 'htaccess_conditional_rewrite' => array( 6, Level::ANOMALY, 'Rewrite applied only for some visitors (%2$s condition). Rule: %3$s', 'Remove this rule set after a backup, or ask your host who added it.' ), // args: path, directive, extension, line. 'htaccess_php_handler' => array( 6, Level::ANOMALY, '%2$s compared to the PHP extensions; makes .%3$s run as PHP. Line: %4$s', 'Remove this line after a backup, or ask your host who added it.' ), // args: path, directive, target. 'php_prepend_append' => array( 6, Level::ANOMALY, '%2$s makes PHP run %3$s before or after every script.', 'Find what %3$s is; if nobody on your side set it, show it to your host.' ), // args: path, directive, target, slug (D-02, D-03). 'php_prepend_plugin' => array( 6, Level::TO_IDENTIFY, '%2$s runs %3$s, inside this site, the file name used by the firewall of %4$s, which is installed.', 'Make sure the firewall of %4$s set this line.' ), // args: path, host, line. 'htaccess_external_error_document' => array( 6, Level::SUSPECT, 'ErrorDocument target compared to the hosts of home and siteurl; points to %2$s. Line: %3$s', 'Check who set this error page on another host.' ), // Control 7 — contents. Member code. args: type, id, title, author id, date. 'content_recent' => array( 7, Level::TO_IDENTIFY, '%3$s (ID %2$s, author ID %4$s), created or modified on %5$s.', '' ), // Summary. args: type, count, reference date. 'content_recent_by_type' => array( 7, Level::TO_IDENTIFY, '%2$s items of type %1$s created or modified on or after %3$s.', 'Open each item and make sure you know the change.' ), // args: type, id, title, marker. 'content_marker' => array( 7, Level::SUSPECT, 'Content compared to the markers of injected code; found: %4$s.', 'Open %3$s (ID %2$s) in the code editor and check where this code comes from.' ), // args: type, id, title, author id, author registered, reference date. 'content_author_recent' => array( 7, Level::SUSPECT, 'Author ID %4$s registered on %5$s, on or after the reference date %6$s.', 'Make sure you know the author of %3$s (ID %2$s).' ), // Control 8 — cron. args: hook, function, file. 'cron_callback_uploads' => array( 8, Level::SUSPECT, 'Function %2$s attached to %1$s is defined in the uploads folder: %3$s.', 'Show this file to your host: the uploads folder only holds media.' ), 'cron_callback_mu_plugin' => array( 8, Level::SUSPECT, 'Function %2$s attached to %1$s is defined in a must-use plugin not recognized: %3$s.', 'Make sure you or your host installed this must-use plugin.' ), 'cron_callback_outside' => array( 8, Level::SUSPECT, 'Function %2$s attached to %1$s is defined outside the WordPress folder: %3$s.', 'Show this file to your host.' ), // args: hook, marker. 'cron_args_marker' => array( 8, Level::SUSPECT, 'Arguments of the scheduled event compared to the obfuscation markers; found: %2$s.', 'Find which plugin scheduled %1$s.' ), // Member code. args: hook. 'cron_no_callback' => array( 8, Level::TO_IDENTIFY, '%1$s', '' ), // Summary. args: count. 'cron_without_code' => array( 8, Level::TO_IDENTIFY, '%1$s scheduled events have no code attached on this page load.', 'Usually left behind by a removed plugin; check the hook names.' ), // args: hook, origin. 'cron_attributed' => array( 8, Level::COUNTED, '%1$s attributed to %2$s.', '' ), // Control 9 — settings. args: siteurl, home. 'settings_urls' => array( 9, Level::TO_IDENTIFY, 'siteurl: %1$s; home: %2$s.', 'Make sure both addresses are yours.' ), // args: siteurl host, home host. 'settings_hosts_differ' => array( 9, Level::SUSPECT, 'Host of siteurl (%1$s) compared to host of home (%2$s): different.', 'Make sure both hosts are yours.' ), // args: constant, constant value, option, option value. 'settings_constant_differs' => array( 9, Level::TO_IDENTIFY, '%1$s (%2$s) compared to the option %3$s (%4$s): different.', 'Check who set %1$s in wp-config.php.' ), // args: role, capabilities. 'settings_open_registration_privileged' => array( 9, Level::ANOMALY, 'Anyone can register, and the default role %1$s holds: %2$s.', 'Close registration or set a default role without these capabilities in Settings, General.' ), // args: role. 'settings_open_registration' => array( 9, Level::TO_IDENTIFY, 'Anyone can register; default role: %1$s.', 'Make sure open registration is wanted.' ), // args: entry, option. 'settings_active_plugin_hidden' => array( 9, Level::ANOMALY, '%1$s is active in %2$s but absent from the plugin list, although the file exists.', 'Show this file to your host: an active plugin hidden from the list is a known hiding place.' ), 'settings_active_plugin_missing' => array( 9, Level::TO_IDENTIFY, '%1$s is active in %2$s but the file does not exist.', 'Usually a plugin removed by hand; check the name.' ), // args: option name, marker, autoload. 'option_marker' => array( 9, Level::SUSPECT, 'Option value compared to the obfuscation markers; found: %2$s (autoload: %3$s).', 'Find which plugin or theme writes option %1$s.' ), // args: option name, slug (N-01). 'option_plugin_rules' => array( 9, Level::COUNTED, 'Option %1$s, where %2$s keeps its detection rules, holds the markers it detects; %2$s is installed.', '' ), ); /** * Member code => array( summary code, index of the grouping arg or null ). */ const AGGREGATES = array( 'other_dir_php' => array( 'other_dir_php_by_folder', 1 ), 'privileged_user' => array( 'privileged_users', null ), 'content_recent' => array( 'content_recent_by_type', 0 ), 'cron_no_callback' => array( 'cron_without_code', null ), ); /** * Not-looked entries. args documented per line. * The first eight are always present (SPEC « Non regardé »). */ const NOT_LOOKED = array( 'themes' => 'Themes: WordPress.org publishes no checksums for themes.', 'tables_outside_wordpress' => 'Database tables that WordPress does not create.', 'comments' => 'Comments.', 'files_outside_wordpress' => 'Files outside the WordPress folder.', 'server_configuration' => 'Server configuration (nginx, Apache main configuration, hosting panel).', 'other_hosted_sites' => 'Other sites on the same hosting account.', 'other_network_sites' => 'Other sites of the network.', 'role_change_dates' => 'Dates of role changes: WordPress does not keep them.', // Dynamic. args: none. 'core_not_consented' => 'Control 1 (core files): checksum download not allowed for this pass.', 'plugins_not_consented' => 'Control 2 (plugins): checksum download not allowed for this pass.', 'core_network_error' => 'Control 1 (core files): the WordPress.org checksums could not be downloaded.', 'core_unexpected_response' => 'Control 1 (core files): the WordPress.org answer had an unexpected format.', // args: version, locale. 'core_not_published' => 'Control 1 (core files): WordPress.org publishes no checksums for WordPress %1$s (%2$s).', // args: locale, file. 'core_locale_fallback' => '%2$s: checksums of the locale %1$s are not published; this file differs by locale and was not compared.', // args: slug, version. 'plugin_network_error' => '%1$s %2$s: the WordPress.org checksums could not be downloaded.', 'plugin_no_sums_for_version' => '%1$s: version %2$s has no published checksums.', 'plugin_not_compared' => '%1$s %2$s: no checksums were available for this pass.', // args: slug. 'plugin_not_on_wporg' => '%1$s is not (or no longer) on WordPress.org.', // args: plugin file. 'plugin_single_file_no_sums' => '%1$s: single-file plugin without published checksums.', // args: none. 'hello_no_core_sums' => 'hello.php: compared to the core checksums, which are not available for this pass.', // args: count. items: up to 50 paths. 'unreadable_files' => '%1$s files could not be read.', // args: count. items: up to 50 paths. 'too_large_files' => '%1$s files larger than 5 MB were not read.', // args: count. items: up to 50 paths (D-05). 'too_large_to_compare' => '%1$s files larger than 32 MB were not compared to the checksums.', // args: folder, count (Q-01). 'content_folder_unjudged' => '%1$s: %2$s PHP files, no rule of this plugin judges them.', // args: folder. 'folder_not_traversed' => 'Folder not traversed: %1$s.', ); /** Not-looked codes always present, in order. */ const NOT_LOOKED_FIXED = array( 'themes', 'tables_outside_wordpress', 'comments', 'files_outside_wordpress', 'server_configuration', 'other_hosted_sites', 'other_network_sites', 'role_change_dates' ); /** Line under a control when findings of a code were only counted (D-13). args: count, code. */ const CAPPED = '%1$s more findings with the code %2$s were counted but not listed.'; /** Summary line template. args: anomalies, suspects, to identify, not looked. */ const SUMMARY = '%1$s anomalies, %2$s suspects, %3$s to identify, %4$s points not looked at'; /** * @param string $code Finding code. * @return array{0:int,1:string,2:string,3:string} */ public static function finding( string $code ): array { if ( ! isset( self::FINDINGS[ $code ] ) ) { throw new \InvalidArgumentException( 'Unknown finding code: ' . $code ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- programmer error, never displayed; the domain holds no WordPress function. } return self::FINDINGS[ $code ]; } /** * @param string $template sprintf template. * @param string[] $args Positional arguments. */ public static function render( string $template, array $args ): string { if ( '' === $template ) { return ''; } $args = array_map( 'strval', array_values( $args ) ); // Pad so a template never fails on a missing argument. $args = array_pad( $args, 8, '' ); return vsprintf( $template, $args ); } }