recipe-id mapping
* are allowed to come from the API. See CSDT_Remediation_Events.
*
* @package CloudScale_Cyber_DevTools
*/
defined( 'ABSPATH' ) || exit;
// The path accessor, loaded here as well as by the main file so this class can be
// loaded on its own (the build-time tests do) without a load-order dependency.
require_once __DIR__ . '/class-paths.php';
class CSDT_Remediation {
/**
* Bumped whenever a recipe body changes.
*
* Reported with telemetry so we can tell which sites are acting on stale
* guidance, and shown in the dialog footer so a support conversation can
* establish what the customer was actually looking at.
*/
const PACK_VERSION = '1';
/** Token delimiters. Deliberately not PHP heredoc-shaped. */
const TOKEN_OPEN = '{{';
const TOKEN_CLOSE = '}}';
/** Where usage telemetry goes, when the site has opted in. */
const EVENT_ENDPOINT = 'https://api.cloudscale.consulting/remediation-event';
/** Opt-in flag. Off unless the administrator turns it on. */
const OPTION_TELEMETRY = 'csdt_remediation_telemetry';
public static function register(): void {
CSDT_Ajax::register( 'wp_ajax_csdt_remediation_recipe', array( __CLASS__, 'ajax_recipe' ) );
CSDT_Ajax::register( 'wp_ajax_csdt_remediation_event', array( __CLASS__, 'ajax_event' ) );
CSDT_Ajax::register( 'wp_ajax_csdt_remediation_telemetry', array( __CLASS__, 'ajax_telemetry_toggle' ) );
}
/** True when this site has opted in to recipe usage telemetry. */
public static function telemetry_enabled(): bool {
return '1' === CSDT_Options::get( self::OPTION_TELEMETRY );
}
/** Turn usage telemetry on or off. */
public static function ajax_telemetry_toggle(): void {
check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above
$on = '1' === sanitize_key( wp_unslash( $_POST['enabled'] ?? '' ) );
update_option( self::OPTION_TELEMETRY, $on ? '1' : '0', false );
wp_send_json_success( array( 'enabled' => $on ? 1 : 0 ) );
}
/**
* Render the opt-in control.
*
* Off by default and stated plainly. WordPress.org requires explicit consent
* for anything that phones home, and a security plugin that quietly reported
* usage would deserve to lose the argument about whether it can be trusted.
*/
public static function render_telemetry_toggle(): void {
$on = self::telemetry_enabled();
?>
>
/dev/null 2>&1; then',
' echo " fail2ban is already installed, this script will only update the jail config."',
'else',
' say "Installing fail2ban"',
' run "sudo apt-get update -qq"',
' run "sudo apt-get install -y fail2ban"',
'fi',
'',
'say "Backing up any existing jail.local"',
'if [ -e "$BACKUP_DIR/jail.local" ] || [ -e "$BACKUP_DIR/jail.local.did-not-exist" ]; then',
' echo " an earlier run already captured the original, keeping it"',
'elif [ -f /etc/fail2ban/jail.local ]; then',
' backup_once /etc/fail2ban/jail.local jail.local',
'else',
' echo " none present, rollback will remove the file we create"',
' record_once jail.local.did-not-exist yes',
'fi',
'',
'say "Writing /etc/fail2ban/jail.local"',
'JAIL_CONTENT=' . self::bash_single_quote( $jail ),
'if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would write:"',
' printf \'%s\\n\' "$JAIL_CONTENT" | sed \'s/^/ /\'',
'else',
' printf \'%s\\n\' "$JAIL_CONTENT" | sudo tee /etc/fail2ban/jail.local >/dev/null',
'fi',
'',
'say "Validating the config before restarting"',
'run "sudo fail2ban-client -t"',
'',
'say "Enabling and restarting fail2ban"',
'run "sudo systemctl enable fail2ban"',
'run "sudo systemctl restart fail2ban"',
)
);
$rollback = self::lines(
array(
'say "Restoring the previous fail2ban jail config"',
'if [ -f "$BACKUP_DIR/jail.local.did-not-exist" ]; then',
' echo " there was no jail.local before, removing the one we created"',
' run "sudo rm -f /etc/fail2ban/jail.local"',
'elif [ -f "$BACKUP_DIR/jail.local" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/jail.local\" /etc/fail2ban/jail.local"',
'else',
' echo " no backup found in $BACKUP_DIR, refusing to guess. Nothing changed." >&2',
' exit 1',
'fi',
'',
'say "Restarting fail2ban"',
'run "sudo systemctl restart fail2ban"',
'',
'echo',
'echo "Note: this leaves the fail2ban package installed. To remove it entirely:"',
'echo " sudo systemctl disable --now fail2ban && sudo apt-get remove -y fail2ban"',
)
);
return array(
'title' => 'SSH brute-force protection (fail2ban)',
'severity' => 'critical',
'summary' => 'Installs fail2ban and bans any IP that fails SSH authentication 3 times in 10 minutes for 24 hours. Unprotected SSH is scanned continuously; this is the single highest-value change on this list.',
'requires' => 'Root (sudo) shell access to the host, not the WordPress container.',
'danger' => 'Sets a firewall ban policy. Your own IP is exempted, but confirm you have a second way in (console, provider web terminal) before running this on a machine you can only reach over SSH.',
'fields' => array(
array(
'token' => 'SSH_PORT',
'label' => 'SSH port',
'default' => '22',
'hint' => 'The port sshd actually listens on. Check with: sudo ss -tlnp | grep sshd',
),
array(
'token' => 'YOUR_IP',
'label' => 'Your public IP (never banned)',
'default' => $ip,
'hint' => '' !== $ip
? 'Detected from this request. Replace it if you administer the server from elsewhere.'
: 'Could not detect a public address for you, this request reached WordPress through a proxy. Your hosting panel, or any "what is my IP" page in a browser, will show it.',
),
),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array(
'code' => self::lines(
array(
'sudo fail2ban-client status sshd',
'# Expect: "Status for the jail: sshd" with a Filter/Actions breakdown.',
'# Currently banned addresses are listed under "Banned IP list".',
)
),
),
'notes' => array(
'This protects SSH only. Failed WordPress logins are already rate-limited and locked out by this plugin\'s login security, so a wp-login jail would duplicate coverage you have.',
'If WordPress runs in a container, fail2ban belongs on the host, not in the container, the container has no view of the host firewall.',
),
'state' => array(
'Detected admin IP' => '' !== $ip ? $ip : 'not detectable (proxied request)',
),
);
}
/**
* Tighten wp-config.php permissions without taking the site down.
*
* The usual advice, chmod 0400, is wrong on a large fraction of real
* hosts. 0400 grants read to the *owner only*, so if wp-config.php is owned
* by root (very common) and PHP runs as www-data, PHP immediately loses the
* ability to read it and every page becomes a fatal error. So the mode is
* chosen from actual ownership, and the script proves PHP can still read the
* file before it leaves you with the change in place.
*/
private static function recipe_wpconfig_perms(): array {
$file = CSDT_Paths::wp_config_path();
$mode = self::mode_of( $file );
$owner = self::owner_of( $file );
$php_user = self::php_user();
$same_user = ( '' !== $owner && $owner === $php_user );
// Owner-only read is only safe when the owner IS the process reading it.
$target_mode = $same_user ? '0400' : '0440';
$apply = self::lines(
array(
'CFG=' . self::bash_single_quote( $file ),
'PHP_USER=' . self::bash_single_quote( $php_user ),
'TARGET_MODE=' . self::bash_single_quote( $target_mode ),
'',
'say "Recording current state for rollback"',
'CURRENT_MODE=$(stat -c \'%a\' "$CFG")',
'echo " mode=$CURRENT_MODE owner=$(stat -c \'%U:%G\' "$CFG")"',
'record_once wp-config.mode "$CURRENT_MODE"',
'',
'say "Applying mode $TARGET_MODE"',
'if [ "$TARGET_MODE" = "0440" ]; then',
' echo " wp-config.php is owned by \'$(stat -c \'%U\' "$CFG")\' but PHP runs as \'$PHP_USER\'."',
' echo " Using 0440 with group read so PHP keeps access. 0400 would break the site."',
' run "sudo chgrp \"$PHP_USER\" \"$CFG\""',
'fi',
'run "sudo chmod \"$TARGET_MODE\" \"$CFG\""',
'',
'say "Proving PHP can still read the file"',
'if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would run: sudo -u \"$PHP_USER\" test -r \"$CFG\""',
'elif sudo -u "$PHP_USER" test -r "$CFG"; then',
' echo " OK, $PHP_USER can read wp-config.php"',
'else',
' echo " FAILED, $PHP_USER can no longer read wp-config.php. Reverting now." >&2',
' sudo chmod "$CURRENT_MODE" "$CFG"',
' echo " reverted to $CURRENT_MODE. The site is unchanged." >&2',
' exit 1',
'fi',
)
);
$rollback = self::lines(
array(
'CFG=' . self::bash_single_quote( $file ),
'',
'say "Restoring the recorded permissions"',
'if [ -f "$BACKUP_DIR/wp-config.mode" ]; then',
' OLD_MODE=$(cat "$BACKUP_DIR/wp-config.mode")',
'else',
' OLD_MODE=' . self::bash_single_quote( '' !== $mode ? $mode : '0644' ),
' echo " no recorded mode found, falling back to the value read at generation time: $OLD_MODE"',
'fi',
'run "sudo chmod \"$OLD_MODE\" \"$CFG\""',
'echo " wp-config.php is now mode $OLD_MODE"',
)
);
return array(
'title' => 'wp-config.php is writable by the web server',
'severity' => 'high',
'summary' => 'Makes wp-config.php read-only so no PHP process can overwrite your database credentials or secret keys. The mode is chosen from the file\'s real ownership, and the change is automatically reverted if PHP loses read access.',
'requires' => 'sudo on the machine (or container) that holds the WordPress files.',
'danger' => 'A wrong mode here makes every page a fatal error. This script verifies PHP can still read the file and reverts itself if not, do not replace it with a bare chmod 0400.',
'fields' => array(),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array(
'code' => self::lines(
array(
'stat -c \'%a %U:%G %n\' ' . self::bash_single_quote( $file ),
'curl -s -o /dev/null -w \'homepage HTTP %{http_code}\\n\' ' . self::bash_single_quote( home_url( '/' ) ),
'# Expect mode 0400 or 0440, and HTTP 200. Anything 5xx means revert immediately.',
)
),
),
'notes' => array(
'While wp-config.php is read-only, any feature that edits it falls back to a runtime setting instead. That is the intended trade-off.',
'Re-run this after a host migration, file ownership frequently changes and the safe mode changes with it.',
),
'state' => array(
'Current mode' => '' !== $mode ? $mode : 'could not read',
'Owned by' => '' !== $owner ? $owner : 'could not read',
'PHP runs as' => $php_user,
'Mode to apply' => $target_mode . ( $same_user ? ' (owner-only read)' : ' (group read, owner differs from the PHP user)' ),
),
);
}
/** Move debug.log above the web root so it stops being downloadable. */
private static function recipe_debug_log(): array {
$log = csdt_devtools_wp_debug_log_path();
// The point of this recipe is a destination ABOVE the web root, so the path it
// names is necessarily outside the install. It is only ever rendered as text
// for the site owner to read; this class never opens it.
$parent = dirname( CSDT_Paths::site_root() ); // rendered as text only; nothing here opens it
$new_log = rtrim( $parent, '/' ) . '/csdt-debug.log';
$php_user = self::php_user();
$url = content_url( '/debug.log' );
$exists = file_exists( $log ); // csdt-submission-ok: read-only existence check, mirrors the audit finding
$size = $exists ? size_format( (int) @filesize( $log ) ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- size is cosmetic; a failed stat renders as an empty label
$apply = self::lines(
array(
'OLD_LOG=' . self::bash_single_quote( $log ),
'NEW_LOG=' . self::bash_single_quote( $new_log ),
'CFG=' . self::bash_single_quote( CSDT_Paths::wp_config_path() ),
'PHP_USER=' . self::bash_single_quote( $php_user ),
'',
'say "Backing up wp-config.php"',
'backup_once "$CFG" wp-config.php',
'',
'say "Moving the log above the web root"',
'if [ -f "$OLD_LOG" ]; then',
' run "sudo mv \"$OLD_LOG\" \"$NEW_LOG\""',
'else',
' echo " no debug.log present, creating the new location only"',
' run "sudo touch \"$NEW_LOG\""',
'fi',
'run "sudo chown \"$PHP_USER\" \"$NEW_LOG\""',
'run "sudo chmod 0640 \"$NEW_LOG\""',
'',
'say "Pointing WP_DEBUG_LOG at the new path"',
'if grep -q "WP_DEBUG_LOG" "$CFG"; then',
' echo " WP_DEBUG_LOG is already defined, edit it by hand to:"',
' echo " define( \'WP_DEBUG_LOG\', \'$NEW_LOG\' );"',
' echo " (not editing an existing define automatically, too easy to corrupt)"',
'else',
' DEFINE_LINE="define( \'WP_DEBUG_LOG\', \'$NEW_LOG\' ); // CloudScale: moved above web root"',
' if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would insert before the \\"stop editing\\" marker: $DEFINE_LINE"',
' else',
self::awk_insert_define( ' ' ),
' grep -q "WP_DEBUG_LOG" "$CFG" || { echo " could not insert the define, restore from $BACKUP_DIR and edit by hand" >&2; exit 1; }',
' echo " inserted"',
' fi',
'fi',
)
);
$rollback = self::lines(
array(
'OLD_LOG=' . self::bash_single_quote( $log ),
'NEW_LOG=' . self::bash_single_quote( $new_log ),
'CFG=' . self::bash_single_quote( CSDT_Paths::wp_config_path() ),
'',
'say "Restoring wp-config.php"',
'if [ -f "$BACKUP_DIR/wp-config.php" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/wp-config.php\" \"$CFG\""',
'else',
' echo " no wp-config.php backup in $BACKUP_DIR, remove the WP_DEBUG_LOG line by hand" >&2',
'fi',
'',
'say "Moving the log back"',
'echo " WARNING: this makes the log publicly downloadable again."',
'if [ -f "$NEW_LOG" ]; then',
' run "sudo mv \"$NEW_LOG\" \"$OLD_LOG\""',
'fi',
)
);
return array(
'title' => 'debug.log is publicly downloadable',
'severity' => 'high',
'summary' => 'Moves wp-content/debug.log above the web root and repoints WP_DEBUG_LOG at it. Debug logs routinely contain full paths, SQL, and occasionally credentials in stack traces.',
'requires' => 'Shell access with permission to write above the web root.',
'danger' => '',
'fields' => array(),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array(
'code' => self::lines(
array(
'curl -s -o /dev/null -w \'debug.log HTTP %{http_code}\\n\' ' . self::bash_single_quote( $url ),
'# Expect 403 or 404. A 200 means the file is still being served.',
'ls -l ' . self::bash_single_quote( $new_log ),
)
),
),
'notes' => array(
'On nginx, .htaccess rules are ignored entirely, moving the file is the only fix that works. Denying the path in the nginx config is an equally valid alternative if you would rather not move it.',
'The log stays readable from the plugin\'s Server Logs tab after the move.',
'Better still, turn WP_DEBUG_LOG off on a production site once you have finished diagnosing.',
),
'state' => array(
'Log present' => $exists ? 'yes' . ( '' !== $size ? ' (' . $size . ')' : '' ) : 'no',
'Public URL' => $url,
'Moving to' => $new_log,
),
);
}
/** Add DISALLOW_FILE_EDIT to wp-config.php. */
private static function recipe_disallow_file_edit(): array {
$cfg = CSDT_Paths::wp_config_path();
$apply = self::lines(
array(
'CFG=' . self::bash_single_quote( $cfg ),
'',
'say "Backing up wp-config.php"',
'backup_once "$CFG" wp-config.php',
'',
'say "Checking whether the constant is already set"',
'if grep -q "DISALLOW_FILE_EDIT" "$CFG"; then',
' echo " already present, nothing to do"',
' exit 0',
'fi',
'',
'say "Inserting the define"',
'DEFINE_LINE="define( \'DISALLOW_FILE_EDIT\', true ); // CloudScale: lock the in-admin file editors"',
'if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would insert: $DEFINE_LINE"',
'else',
self::awk_insert_define( ' ' ),
' grep -q "DISALLOW_FILE_EDIT" "$CFG" || { echo " insert failed, wp-config.php has a non-standard layout. Restore from $BACKUP_DIR and add the line by hand." >&2; exit 1; }',
' echo " inserted"',
'fi',
'',
'say "Checking PHP still parses wp-config.php"',
'run "php -l \"$CFG\""',
)
);
$rollback = self::lines(
array(
'CFG=' . self::bash_single_quote( $cfg ),
'',
'say "Restoring wp-config.php from backup"',
'if [ -f "$BACKUP_DIR/wp-config.php" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/wp-config.php\" \"$CFG\""',
' echo " restored, the in-admin editors are available again"',
'else',
' echo " no backup found. Remove this line from wp-config.php by hand:" >&2',
' echo " define( \'DISALLOW_FILE_EDIT\', true );" >&2',
' exit 1',
'fi',
)
);
return array(
'title' => 'Theme and plugin file editors are reachable from wp-admin',
'severity' => 'medium',
'summary' => 'Sets DISALLOW_FILE_EDIT so the built-in code editors disappear. Without it, one compromised administrator session is enough to write arbitrary PHP to your server through a web form.',
'requires' => 'Write access to wp-config.php.',
'danger' => '',
'fields' => array(),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array(
'code' => self::lines(
array(
'grep -n "DISALLOW_FILE_EDIT" ' . self::bash_single_quote( $cfg ),
'# Then confirm in wp-admin: Appearance -> Theme File Editor should be gone.',
)
),
),
'notes' => array(
'This plugin can also apply the same restriction at runtime without touching wp-config.php, use that if you would rather not edit files. The constant is stronger because it survives the plugin being deactivated.',
),
'state' => array(
'Currently set' => ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) ? 'yes' : 'no',
),
);
}
/**
* Publicly readable readme.html / license.txt.
*
* Deleting them is a stopgap, not a fix: WordPress ships both files and restores
* them on every core update, so the finding comes back on the next release. The
* durable answer is a webserver deny rule, which is printed rather than applied,
* same reasoning as the wp-cron recipe, every host lays its vhosts out differently
* and a bad edit there takes the whole site offline.
*/
private static function recipe_default_files(): array {
$root = CSDT_Paths::site_root() . '/';
$home = home_url( '/' );
$nginx = implode(
"\n",
array(
'# CloudScale: stop serving the files WordPress ships for humans, not browsers.',
'location ~* ^/(readme\.html|license\.txt|wp-config-sample\.php)$ {',
' deny all;',
' access_log off;',
' log_not_found off;',
'}',
)
);
$apply = self::lines(
array(
'ROOT=' . self::bash_single_quote( rtrim( $root, '/' ) ),
'',
'say "Backing up the two files before removing them"',
'for f in readme.html license.txt; do',
' [ -f "$ROOT/$f" ] && backup_once "$ROOT/$f" "$f"',
'done',
'',
'say "Removing them"',
'for f in readme.html license.txt; do',
' if [ ! -f "$ROOT/$f" ]; then',
' echo " $f already absent"',
' elif [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would remove $ROOT/$f"',
' else',
' run "sudo rm -f \"$ROOT/$f\""',
' echo " removed $f"',
' fi',
'done',
'',
'say "Making it stick"',
'echo " WordPress SHIPS these files and will put them back on the next core"',
'echo " update, so deleting them alone is temporary. Add this to your nginx"',
'echo " server block, then: sudo nginx -t \&\& sudo systemctl reload nginx"',
'echo',
'NGINX_SNIPPET=' . self::bash_single_quote( $nginx ),
'printf \'%s\n\' "$NGINX_SNIPPET" | sed "s/^/ /"',
'echo',
'echo " Not automated: vhost layouts differ per host and a bad edit here takes"',
'echo " the whole site offline. Apache equivalent, inside your vhost or .htaccess:"',
'echo " "',
'echo " Require all denied"',
'echo " "',
)
);
$rollback = self::lines(
array(
'ROOT=' . self::bash_single_quote( rtrim( $root, '/' ) ),
'',
'say "Restoring the files from backup"',
'RESTORED=0',
'for f in readme.html license.txt; do',
' if [ -f "$BACKUP_DIR/$f" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/$f\" \"$ROOT/$f\""',
' echo " restored $f"',
' RESTORED=1',
' fi',
'done',
'if [ "$RESTORED" = 0 ]; then',
' echo " nothing to restore. Both files are shipped by WordPress, so a core"',
' echo " update or a fresh download replaces them if you ever need them back." >&2',
'fi',
'echo " If you added the nginx deny rule, remove it too or the files stay blocked."',
)
);
$verify = self::lines(
array(
'# Expect 403 (deny rule in place) or 404 (deleted). A 200 means neither took effect.',
'for f in readme.html license.txt; do',
' printf \'%s -> \' "$f"',
' curl -s -o /dev/null -w \'%{http_code}\n\' ' . self::bash_single_quote( rtrim( $home, '/' ) ) . '"/$f"',
'done',
)
);
$readme_present = file_exists( rtrim( $root, '/' ) . '/readme.html' ) ? 'yes' : 'no';
$license_present = file_exists( rtrim( $root, '/' ) . '/license.txt' ) ? 'yes' : 'no';
return array(
'title' => 'readme.html and license.txt are readable by anyone',
'severity' => 'low',
'summary' => 'Removes the two files WordPress ships in the web root, and shows the webserver rule that keeps them gone. readme.html states the WordPress version, which is free reconnaissance for anyone matching your site against version-specific exploits.',
'requires' => 'Shell access to the web root. The deny rule needs webserver config access and a reload.',
'danger' => '',
'fields' => array(),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array( 'code' => $verify ),
'notes' => array(
'Low severity on purpose. This is reconnaissance hygiene, not a vulnerability, nobody breaks in through license.txt. Fix it because it is free, not because it is urgent.',
'The deletion is temporary by design of WordPress itself: both files return on the next core update. Only the webserver rule survives, which is why it is printed here even though it cannot be applied for you.',
),
'state' => array(
'readme.html present' => $readme_present,
'license.txt present' => $license_present,
),
);
}
/**
* No HTTP-to-HTTPS redirect.
*
* The advice usually attached to this finding is an .htaccess RewriteRule, which is
* useless on nginx, and FORCE_SSL_ADMIN, which only covers wp-admin. Behind a CDN the
* real switch is at the edge.
*
* The danger is specific and worth stating rather than hand-waving: if the CDN talks to
* the origin over plain HTTP (Cloudflare "Flexible" SSL), adding an origin-side
* redirect to HTTPS creates an infinite loop and takes the site down, the edge asks
* for HTTP, the origin says "go to HTTPS", the edge asks for HTTP again. Check the mode
* before touching the origin.
*/
private static function recipe_force_https(): array {
$cfg = CSDT_Paths::wp_config_path();
$home = home_url( '/' );
$host = wp_parse_url( $home, PHP_URL_HOST );
$nginx = implode(
"\n",
array(
'# CloudScale: origin-level HTTPS enforcement.',
'# ONLY add this if your CDN reaches the origin over HTTPS',
'# (Cloudflare SSL mode Full or Full (Strict)). On Flexible it loops.',
'server {',
' listen 80;',
' server_name ' . (string) $host . ' www.' . (string) $host . ';',
' return 301 https://$host$request_uri;',
'}',
)
);
$apply = self::lines(
array(
'CFG=' . self::bash_single_quote( $cfg ),
'',
'say "Step 1 of 3, force HTTPS for wp-admin and login (safe everywhere)"',
'backup_once "$CFG" wp-config.php',
'if grep -q "FORCE_SSL_ADMIN" "$CFG"; then',
' echo " already present, nothing to do"',
'elif [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would insert define( \'FORCE_SSL_ADMIN\', true );"',
'else',
' DEFINE_LINE="define( \'FORCE_SSL_ADMIN\', true ); // CloudScale: admin and login over HTTPS only"',
self::awk_insert_define( ' ' ),
' grep -q "FORCE_SSL_ADMIN" "$CFG" || { echo " insert failed, add the line by hand, backup is in $BACKUP_DIR" >&2; exit 1; }',
' echo " inserted"',
' run "php -l \"$CFG\""',
'fi',
'',
'say "Step 2 of 3, turn on HTTPS at the edge (this is the actual fix)"',
'echo " Cloudflare dashboard: SSL/TLS -> Edge Certificates -> Always Use HTTPS -> On."',
'echo " This redirects every http:// request before it ever reaches your server,"',
'echo " so it cannot loop and needs no origin change."',
'echo',
'echo " Same thing over the API, export a scoped token first, do NOT paste your"',
'echo " Global API Key into a shell script:"',
'echo " export CF_TOKEN="',
'echo " export CF_ZONE="',
'echo \' curl -sX PATCH "https://api.cloudflare.com/client/v4/zones/$CF_ZONE/settings/always_use_https" \\\'',
'echo \' -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \\\'',
'echo \' --data "{\\"value\\":\\"on\\"}"\'',
'echo',
'say "Step 3 of 3, origin redirect, ONLY if the edge reaches you over HTTPS"',
'echo " Check first: Cloudflare SSL/TLS -> Overview must say Full or Full (Strict)."',
'echo " If it says Flexible, STOP, the block below will loop and the site will"',
'echo " return ERR_TOO_MANY_REDIRECTS. Fix the SSL mode first."',
'echo',
'NGINX_SNIPPET=' . self::bash_single_quote( $nginx ),
'printf \'%s\n\' "$NGINX_SNIPPET" | sed "s/^/ /"',
'echo',
'echo " Then: sudo nginx -t \&\& sudo systemctl reload nginx"',
'echo " Not automated: a bad server block takes every site on the host offline."',
)
);
$rollback = self::lines(
array(
'CFG=' . self::bash_single_quote( $cfg ),
'',
'say "Restoring wp-config.php from backup"',
'if [ -f "$BACKUP_DIR/wp-config.php" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/wp-config.php\" \"$CFG\""',
' echo " restored"',
'else',
' echo " no backup found. Remove this line by hand:" >&2',
' echo " define( \'FORCE_SSL_ADMIN\', true );" >&2',
'fi',
'echo',
'echo " The edge setting and any nginx block are NOT reverted by this script:"',
'echo " Cloudflare: Always Use HTTPS -> Off"',
'echo " nginx: remove the listen 80 redirect block, then reload"',
)
);
$verify = self::lines(
array(
'# Expect 301 (or 308) to the https:// URL. A 200 here means plain HTTP is still',
'# being served and nothing took effect.',
'curl -s -o /dev/null -w \'http -> %{http_code} %{redirect_url}\n\' ' . self::bash_single_quote( 'http://' . (string) $host . '/' ),
'curl -s -o /dev/null -w \'https -> %{http_code}\n\' ' . self::bash_single_quote( 'https://' . (string) $host . '/' ),
)
);
return array(
'title' => 'Plain HTTP is served without redirecting to HTTPS',
'severity' => 'medium',
'summary' => 'Enforces HTTPS at the edge, in wp-admin, and optionally at the origin. Without a redirect, anything that follows an old http:// link transmits cookies and form data in clear text before any encryption is negotiated.',
'requires' => 'Write access to wp-config.php. Edge step needs CDN dashboard or API access; origin step needs webserver config access.',
'danger' => 'Do NOT add the origin redirect while your CDN reaches the origin over plain HTTP (Cloudflare SSL mode "Flexible"). The edge would request HTTP, the origin would answer "redirect to HTTPS", and the site would fail with ERR_TOO_MANY_REDIRECTS. Confirm the mode is Full or Full (Strict) first. Steps 1 and 2 are safe regardless.',
'fields' => array(),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array( 'code' => $verify ),
'notes' => array(
'Ordered by safety, not by thoroughness: step 2 alone fixes the finding on a CDN-fronted site and cannot loop, because the redirect happens before the request reaches your server. Step 3 is belt-and-braces for direct-to-origin traffic.',
'The .htaccess RewriteRule usually suggested for this does nothing on nginx, and FORCE_SSL_ADMIN only covers wp-admin, neither is a site-wide redirect on its own.',
'HSTS is the logical next step once redirects are confirmed working, but it is deliberately not here: a max-age you cannot serve HTTPS for is very hard to walk back.',
),
'state' => array(
'home_url scheme' => (string) wp_parse_url( $home, PHP_URL_SCHEME ),
'FORCE_SSL_ADMIN set' => ( defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN ) ? 'yes' : 'no',
'Request reached us over' => is_ssl() ? 'https' : 'http',
),
);
}
/**
* Require a login for the two REST namespaces that leak environment detail.
*
* Deliberately narrow. The advice attached to this finding usually reaches for
* "authenticate the REST API", which breaks things people notice: wp/v2 is what the
* block editor talks to, oembed serves the front end, and on this install csdt/v1,
* csdt-devtools/v1, csdt/cf-callback and cloudscale-site-analytics/v1 are all live
* plugin endpoints, one of them a webhook that by definition arrives unauthenticated.
* Only wp-site-health/v1 and wp-abilities/v1 are closed: nothing anonymous needs them,
* and they describe the server (PHP version, HTTPS and loopback status) or its
* roles and capabilities.
*
* Shipped as an mu-plugin, not a functions.php edit: it survives a theme switch, loads
* before regular plugins, and rollback is deleting one file rather than un-editing one.
*/
private static function recipe_rest_namespace_auth(): array {
// The must-use directory comes from the one path accessor, never a concatenated path.
$mu_dir = CSDT_Paths::mu_plugins_dir();
$mu_file = $mu_dir . '/csdt-rest-namespace-auth.php';
$home = rtrim( home_url( '/' ), '/' );
$mu_body = implode(
"\n",
array(
'/ and ?rest_route=//",
"\t\t\$blocked = array( 'wp-site-health/v1', 'wp-abilities/v1' );",
"\t\tforeach ( \$blocked as \$ns ) {",
"\t\t\tif ( false !== strpos( \$uri, '/wp-json/' . \$ns ) || false !== strpos( \$uri, 'rest_route=/' . \$ns ) ) {",
"\t\t\t\treturn new WP_Error(",
"\t\t\t\t\t'rest_forbidden',",
"\t\t\t\t\t__( 'Authentication required.' ),",
"\t\t\t\t\tarray( 'status' => 401 )",
"\t\t\t\t);",
"\t\t\t}",
"\t\t}",
"\t\treturn \$result;",
"\t}",
');',
'',
)
);
$apply = self::lines(
array(
'MU_DIR=' . self::bash_single_quote( $mu_dir ),
'MU_FILE=' . self::bash_single_quote( $mu_file ),
'',
'say "Making sure the mu-plugins directory exists"',
'if [ ! -d "$MU_DIR" ]; then',
' run "sudo mkdir -p \"$MU_DIR\""',
' echo " created $MU_DIR"',
'else',
' echo " present"',
'fi',
'',
'say "Backing up any existing copy"',
'[ -f "$MU_FILE" ] && backup_once "$MU_FILE" csdt-rest-namespace-auth.php',
'',
'say "Writing the mu-plugin"',
'MU_CONTENT=' . self::bash_single_quote( $mu_body ),
'if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would write $MU_FILE"',
'else',
' printf \'%s\n\' "$MU_CONTENT" | sudo tee "$MU_FILE" >/dev/null',
' run "sudo chown ' . self::php_user() . ' \"$MU_FILE\""',
' run "sudo chmod 0644 \"$MU_FILE\""',
' echo " written"',
'fi',
'',
'say "Checking PHP parses it"',
'run "php -l \"$MU_FILE\""',
)
);
$rollback = self::lines(
array(
'MU_FILE=' . self::bash_single_quote( $mu_file ),
'',
'say "Removing the mu-plugin"',
'if [ -f "$BACKUP_DIR/csdt-rest-namespace-auth.php" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/csdt-rest-namespace-auth.php\" \"$MU_FILE\""',
' echo " restored the previous version"',
'elif [ -f "$MU_FILE" ]; then',
' run "sudo rm -f \"$MU_FILE\""',
' echo " deleted, both namespaces are public again"',
'else',
' echo " nothing to remove"',
'fi',
)
);
$verify = self::lines(
array(
'# The two closed namespaces must now answer 401 to an anonymous caller.',
'for ns in wp-site-health/v1 wp-abilities/v1; do',
' printf \'%-22s -> \' "$ns"',
' curl -s -o /dev/null -w \'%{http_code}\n\' ' . self::bash_single_quote( $home ) . '"/wp-json/$ns"',
'done',
'',
'# Just as important: prove nothing else moved. These must all still answer 200,',
'# and the block editor must still load. A 401 here means the filter is too broad.',
'for ns in wp/v2/types wp/v2/posts oembed/1.0/embed csdt/v1 cloudscale-site-analytics/v1; do',
' printf \'%-32s -> \' "$ns"',
' curl -s -o /dev/null -w \'%{http_code}\n\' ' . self::bash_single_quote( $home ) . '"/wp-json/$ns"',
'done',
)
);
return array(
'title' => 'REST namespaces describing the server are readable without a login',
'severity' => 'low',
'summary' => 'Requires a logged-in user for wp-site-health/v1 and wp-abilities/v1. Those two report PHP version, HTTPS and loopback status, and role/capability detail, reconnaissance that saves an attacker the trouble of fingerprinting you.',
'requires' => 'Write access to wp-content/mu-plugins.',
'danger' => '',
'fields' => array(),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array( 'code' => $verify ),
'notes' => array(
'Two namespaces only, by design. Closing wp/v2 breaks the block editor, closing oembed breaks front-end embeds, and this install also serves csdt/v1, csdt-devtools/v1, csdt/cf-callback and cloudscale-site-analytics/v1, the callback is a webhook and arrives unauthenticated by definition. The verify step checks those still answer 200 precisely so a too-broad filter shows up immediately.',
'It yields to any decision already made on the request, so it composes with this plugin\'s existing REST authentication handling instead of overriding it.',
'wp/v2/users is already closed on this install, which is the REST leak that actually matters, this recipe does not change that behaviour.',
),
'state' => array(
// Deliberately not reporting writability here: is_writable() trips
// WordPress.WP.AlternativeFunctions, and a warning in that sniff is what
// turned into a real WordPress.org rejection last time. The apply script
// fails loudly on a permission problem anyway, which is more reliable than
// a pre-flight guess made as a different user.
'mu-plugin installed' => file_exists( $mu_file ) ? 'yes' : 'no',
'mu-plugins path' => $mu_dir,
),
);
}
/**
* WP-Cron hardening, and an honest correction to the usual advice.
*
* DISABLE_WP_CRON is widely recommended as the fix for "wp-cron.php can be
* hit repeatedly to cause CPU spikes". It is not: wp-cron.php does not check
* that constant, so an attacker can still request it directly and still make
* WordPress run every due event. DISABLE_WP_CRON only stops WordPress
* spawning cron for ordinary visitors. Closing the DoS vector needs the web
* server to refuse external requests for the file, so the recipe does all
* three parts: the constant, a real system cron, and the deny rule.
*/
private static function recipe_wp_cron(): array {
$cfg = CSDT_Paths::wp_config_path();
$path = CSDT_Paths::site_root();
$apply = self::lines(
array(
'CFG=' . self::bash_single_quote( $cfg ),
'WP_PATH=' . self::bash_single_quote( $path ),
'PHP_BIN=' . self::bash_single_quote( self::token( 'PHP_BIN' ) ),
'CRON_USER=' . self::bash_single_quote( self::token( 'CRON_USER' ) ),
'',
'say "Step 1 of 3, back up wp-config.php and set DISABLE_WP_CRON"',
'backup_once "$CFG" wp-config.php',
'if grep -q "DISABLE_WP_CRON" "$CFG"; then',
' echo " already defined, leaving it alone"',
'else',
' DEFINE_LINE="define( \'DISABLE_WP_CRON\', true ); // CloudScale: cron runs from the system scheduler"',
' if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would insert: $DEFINE_LINE"',
' else',
self::awk_insert_define( ' ' ),
' grep -q "DISABLE_WP_CRON" "$CFG" || { echo " insert failed, restore from $BACKUP_DIR and edit by hand" >&2; exit 1; }',
' fi',
'fi',
'run "php -l \"$CFG\""',
'',
'say "Step 2 of 3, install the system cron that now owns scheduling"',
'echo " This MUST work, or scheduled posts, backups and cleanups stop silently."',
'CRON_LINE="*/5 * * * * cd $WP_PATH && $PHP_BIN wp-cron.php >/dev/null 2>&1"',
'if [ -e "$BACKUP_DIR/crontab.before" ]; then',
' echo " keeping the crontab captured by an earlier run"',
'elif [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would save the current crontab to $BACKUP_DIR/crontab.before"',
'else',
' sudo crontab -u "$CRON_USER" -l 2>/dev/null | grep -v \'wp-cron.php\' > "$BACKUP_DIR/crontab.before" || true',
' echo " saved to $BACKUP_DIR/crontab.before"',
'fi',
'if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would add to $CRON_USER\'s crontab: $CRON_LINE"',
'else',
' ( sudo crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "wp-cron.php" || true; printf \'%s\\n\' "$CRON_LINE" ) | sudo crontab -u "$CRON_USER" -',
' echo " installed"',
'fi',
'',
'say "Step 3 of 3, refuse external requests for wp-cron.php"',
'echo " DISABLE_WP_CRON alone does NOT stop this: wp-cron.php ignores the constant"',
'echo " and still runs every due event for anyone who requests it. Add this to the"',
'echo " server block for your site, then reload:"',
'echo',
'echo " location = /wp-cron.php {"',
'echo " allow 127.0.0.1;"',
'echo " allow ::1;"',
'echo " deny all;"',
'echo " include fastcgi_params;"',
'echo " fastcgi_pass ;"',
'echo " }"',
'echo',
'echo " Apache equivalent, in a block:"',
'echo " Require local"',
'echo',
'echo " Then: sudo nginx -t \\&\\& sudo systemctl reload nginx"',
'echo " Not automated because every host lays its vhosts out differently and a"',
'echo " bad edit here takes the whole site offline."',
)
);
$rollback = self::lines(
array(
'CFG=' . self::bash_single_quote( $cfg ),
'CRON_USER=' . self::bash_single_quote( self::token( 'CRON_USER' ) ),
'',
'say "Restoring wp-config.php"',
'if [ -f "$BACKUP_DIR/wp-config.php" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/wp-config.php\" \"$CFG\""',
'else',
' echo " no backup, remove the DISABLE_WP_CRON line by hand" >&2',
'fi',
'',
'say "Restoring the previous crontab"',
'if [ -f "$BACKUP_DIR/crontab.before" ]; then',
' run "sudo crontab -u \"$CRON_USER\" \"$BACKUP_DIR/crontab.before\""',
' echo " restored"',
'else',
' echo " no crontab backup, remove the wp-cron.php line manually:" >&2',
' echo " sudo crontab -u $CRON_USER -e" >&2',
'fi',
'',
'echo',
'echo "If you added the nginx/Apache deny rule, remove it and reload the web server."',
)
);
return array(
'title' => 'wp-cron.php can be triggered by anyone',
'severity' => 'medium',
'summary' => 'Hands scheduling to the system cron and blocks external requests for wp-cron.php. Repeated requests to that file make WordPress run every due task, which is a cheap way to burn your CPU.',
'requires' => 'sudo, plus the ability to edit your web server config.',
'danger' => 'If the system cron is not working, scheduled posts, backups and cleanups stop running and nothing warns you. Run the verify step.',
'fields' => array(
array(
'token' => 'PHP_BIN',
'label' => 'PHP binary',
'default' => '/usr/bin/php',
'hint' => 'Find it with: which php',
),
array(
'token' => 'CRON_USER',
'label' => 'User to own the cron job',
'default' => self::php_user(),
'hint' => 'Should be the user PHP runs as, so generated files keep the right ownership.',
),
),
'apply' => array( 'code' => $apply ),
'rollback' => array( 'code' => $rollback ),
'verify' => array(
'code' => self::lines(
array(
'# 1. Is anything overdue? (empty output is good)',
'cd ' . self::bash_single_quote( $path ) . ' && wp cron event list --due-now',
'',
'# 2. Is the system cron registered?',
'sudo crontab -u ' . self::token( 'CRON_USER' ) . ' -l | grep wp-cron',
'',
'# 3. Is the file refused from outside? Expect 403.',
'curl -s -o /dev/null -w \'wp-cron.php HTTP %{http_code}\\n\' ' . self::bash_single_quote( rtrim( site_url(), '/' ) . '/wp-cron.php' ),
)
),
),
'notes' => array(
'Come back in an hour and re-run step 1. An empty due-now list then is the only real proof the system cron took over.',
'ALTERNATE_WP_CRON is not a substitute, it changes how cron is spawned, not who can spawn it.',
),
'state' => array(
'DISABLE_WP_CRON' => ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) ? 'set' : 'not set',
'WordPress path' => $path,
),
);
}
/**
* Update a vulnerable plugin, theme, or core.
*
* The rollback is the reason this is worth generating rather than saying
* "click update": it pins the exact version installed right now, so a broken
* update is one command away from being undone. WordPress itself gives you
* no such affordance.
*
* @param string $id update_plugin | update_theme | update_core.
* @param array $args Expects 'slug' (and optionally 'version').
*/
private static function recipe_update( string $id, array $args ): array {
$path = CSDT_Paths::site_root();
$slug = isset( $args['slug'] ) ? sanitize_key( (string) $args['slug'] ) : '';
$kind = 'update_plugin' === $id ? 'plugin' : ( 'update_theme' === $id ? 'theme' : 'core' );
$current = isset( $args['version'] ) ? sanitize_text_field( (string) $args['version'] ) : '';
if ( '' === $current ) {
$current = self::installed_version( $kind, $slug );
}
$label = 'core' === $kind
? 'WordPress core'
: ucfirst( $kind ) . ( '' !== $slug ? ' "' . $slug . '"' : '' );
$target = '' !== $slug ? $slug : self::token( 'SLUG' );
$pinned = '' !== $current ? $current : self::token( 'CURRENT_VERSION' );
$wp = 'wp --path=' . self::bash_single_quote( $path );
$is_core = ( 'core' === $kind );
$apply_lines = array(
'say "Confirming WP-CLI works here"',
'run "' . $wp . ' core version"',
'',
'say "Recording the installed version for rollback"',
);
if ( $is_core ) {
$apply_lines[] = 'CURRENT=$(' . $wp . ' core version)';
} else {
$apply_lines[] = 'CURRENT=$(' . $wp . ' ' . $kind . ' get ' . $target . ' --field=version)';
}
$apply_lines = array_merge(
$apply_lines,
array(
'echo " currently installed: $CURRENT"',
'record_once version.before "$CURRENT"',
'',
'say "Backing up the database"',
'echo " An update can migrate the schema; the rollback below cannot undo that."',
'if [ -e "$BACKUP_DIR/db-before-update.sql" ]; then',
' echo " keeping the dump taken by an earlier run, it predates the update"',
'else',
' run "' . $wp . ' db export \"$BACKUP_DIR/db-before-update.sql\""',
'fi',
'',
)
);
if ( $is_core ) {
$apply_lines[] = 'say "Updating WordPress core"';
$apply_lines[] = 'run "' . $wp . ' core update"';
$apply_lines[] = 'run "' . $wp . ' core update-db"';
} else {
$apply_lines[] = 'say "Backing up the current ' . $kind . ' files"';
$apply_lines[] = 'SRC=$(' . $wp . ' ' . $kind . ' path ' . $target . ')';
$apply_lines[] = 'run "cp -a \"$SRC\" \"$BACKUP_DIR/\""';
$apply_lines[] = '';
$apply_lines[] = 'say "Updating ' . $label . '"';
$apply_lines[] = 'run "' . $wp . ' ' . $kind . ' update ' . $target . '"';
}
$apply_lines = array_merge(
$apply_lines,
array(
'',
'say "Checking the site still responds"',
'run "curl -fsS -o /dev/null -w \'homepage HTTP %{http_code}\\n\' ' . self::bash_single_quote( home_url( '/' ) ) . '"',
)
);
$rollback_lines = array(
'say "Rolling back to the recorded version"',
'if [ -f "$BACKUP_DIR/version.before" ]; then',
' PIN=$(cat "$BACKUP_DIR/version.before")',
'else',
' PIN=' . self::bash_single_quote( $pinned ),
' echo " no recorded version, using the value read at generation time: $PIN"',
'fi',
'echo " target version: $PIN"',
'',
);
if ( $is_core ) {
$rollback_lines[] = 'run "' . $wp . ' core update --version=\"$PIN\" --force"';
$rollback_lines[] = 'run "' . $wp . ' core update-db"';
} else {
$rollback_lines[] = 'run "' . $wp . ' ' . $kind . ' install ' . $target . ' --version=\"$PIN\" --force"';
}
$rollback_lines = array_merge(
$rollback_lines,
array(
'',
'say "If the update migrated the database, restore the dump too"',
'echo " Only do this if the site is still broken after the version rollback -"',
'echo " it discards every content change made since the update:"',
'echo " ' . $wp . ' db import \\"$BACKUP_DIR/db-before-update.sql\\""',
)
);
$verify = $is_core
? $wp . ' core version' . "\n" . $wp . ' core check-update'
: $wp . ' ' . $kind . ' get ' . $target . ' --field=version' . "\n" . $wp . ' ' . $kind . ' list --update=available';
return array(
'title' => 'Update ' . $label,
'severity' => 'high',
'summary' => 'Updates ' . $label . ' with a database dump and a version pin taken first, so a bad update can be reversed in one command.',
'requires' => 'WP-CLI on the server, and enough disk space for a database dump.',
'danger' => 'Updates can migrate the database. The version rollback restores files; only the database import restores schema, and that discards content changes made since.',
'fields' => '' !== $slug || $is_core ? array() : array(
array(
'token' => 'SLUG',
'label' => ucfirst( $kind ) . ' slug',
'default' => '',
'hint' => 'The directory name, e.g. "contact-form-7". List them with: wp ' . $kind . ' list',
),
array(
'token' => 'CURRENT_VERSION',
'label' => 'Version currently installed',
'default' => '',
'hint' => 'Used as the rollback target if the script cannot read it live.',
),
),
'apply' => array( 'code' => self::lines( $apply_lines ) ),
'rollback' => array( 'code' => self::lines( $rollback_lines ) ),
'verify' => array( 'code' => $verify ),
'notes' => array(
'Test on a staging copy first when the update crosses a major version.',
'If the vulnerable ' . ( $is_core ? 'release' : $kind ) . ' has no fixed version available, removing it is the only real remediation, an unmaintained component stays exploitable.',
),
'state' => array(
'Installed version' => '' !== $current ? $current : 'could not read',
'WordPress path' => $path,
),
);
}
/**
* Reverse a table-prefix migration performed by an earlier version.
*
* The migration itself has been withdrawn, but sites that ran it must keep a
* way back, deleting the automation and the escape hatch together would
* strand them. So the rollback snapshot recorded at migration time is turned
* into a script instead of being executed from a web request.
*
* @return array|null Null when this site has no migration to reverse.
*/
private static function recipe_db_prefix_rollback(): ?array {
$info = get_option( 'csdt_db_prefix_rollback' );
if ( ! is_array( $info ) || empty( $info['old_prefix'] ) || empty( $info['new_prefix'] ) ) {
return null;
}
$old = (string) $info['old_prefix'];
$new = (string) $info['new_prefix'];
$tables = is_array( $info['tables'] ?? null ) ? $info['tables'] : array();
$path = CSDT_Paths::site_root();
$cfg = CSDT_Paths::wp_config_path();
$wp = 'wp --path=' . self::bash_single_quote( $path );
// Renames are emitted individually from the recorded list rather than
// derived from a pattern, so only the tables this migration actually
// touched are renamed back.
$renames = array();
foreach ( $tables as $pair ) {
$from = isset( $pair['to'] ) ? (string) $pair['to'] : '';
$to = isset( $pair['from'] ) ? (string) $pair['from'] : '';
if ( '' === $from || '' === $to ) {
continue;
}
$renames[] = 'run "' . $wp . ' db query ' . self::bash_single_quote( 'RENAME TABLE `' . $from . '` TO `' . $to . '`;' ) . '"';
}
$apply = array_merge(
array(
'CFG=' . self::bash_single_quote( $cfg ),
'',
'say "Dumping the database first, table renames are not something to retry blind"',
'if [ -e "$BACKUP_DIR/db-before-prefix-rollback.sql" ]; then',
' echo " keeping the dump from an earlier run"',
'else',
' run "' . $wp . ' db export \"$BACKUP_DIR/db-before-prefix-rollback.sql\""',
'fi',
'',
'say "Backing up wp-config.php"',
'backup_once "$CFG" wp-config.php',
'',
'say "Renaming ' . count( $renames ) . ' tables back to the ' . $old . ' prefix"',
),
$renames,
array(
'',
'say "Restoring the one option key that really was prefixed"',
'run "' . $wp . ' db query ' . self::bash_single_quote(
'UPDATE `' . $new . 'options` SET option_name = ' . "'" . $old . 'user_roles' . "'"
. ' WHERE option_name = ' . "'" . $new . 'user_roles' . "'" . ';'
) . '"',
'',
'say "Restoring prefixed user meta keys (' . $new . 'capabilities, ' . $new . 'user_level)"',
'run "' . $wp . ' db query ' . self::bash_single_quote(
'UPDATE `' . $old . 'usermeta` SET meta_key = CONCAT('
. "'" . $old . "'" . ', SUBSTRING(meta_key, ' . ( strlen( $new ) + 1 ) . '))'
. ' WHERE meta_key LIKE ' . "'" . $new . "%'" . ';'
) . '"',
'',
'say "Pointing wp-config.php back at the ' . $old . ' prefix"',
'if [ "$DRY_RUN" = 1 ]; then',
' echo " [dry-run] would set \\$table_prefix = ' . "'" . $old . "'" . ';"',
'else',
' TMP=$(mktemp)',
' awk -v old=' . self::bash_single_quote( "'" . $old . "'" ) . ' \'/^\\$table_prefix[[:space:]]*=/ { print "$table_prefix = " old ";"; next } { print }\' "$CFG" > "$TMP"',
' sudo cp "$TMP" "$CFG"',
' rm -f "$TMP"',
' grep -q "table_prefix" "$CFG" || { echo " \\$table_prefix line vanished, restore $BACKUP_DIR/wp-config.php immediately" >&2; exit 1; }',
'fi',
'run "php -l \"$CFG\""',
'',
'say "Flushing any object cache still keyed to the old prefix"',
'run "' . $wp . ' cache flush || true"',
)
);
$rollback = array(
'echo "This script re-applies the ' . $new . ' prefix, i.e. it undoes the undo."',
'echo "You almost certainly want the database dump instead:"',
'echo " ' . $wp . ' db import \\"$BACKUP_DIR/db-before-prefix-rollback.sql\\""',
'echo',
'echo "Restoring wp-config.php from the backup taken by the apply script:"',
'CFG=' . self::bash_single_quote( $cfg ),
'if [ -f "$BACKUP_DIR/wp-config.php" ]; then',
' run "sudo cp -a \"$BACKUP_DIR/wp-config.php\" \"$CFG\""',
'else',
' echo " no backup found, set \\$table_prefix by hand" >&2',
'fi',
);
return array(
'title' => 'Reverse the ' . $old . ' to ' . $new . ' table-prefix migration',
'severity' => 'medium',
'summary' => 'Renames the ' . count( $renames ) . ' tables recorded at migration time back to the ' . $old . ' prefix, restores the prefixed option and user-meta keys, and repoints wp-config.php. Generated from this site\'s own migration record, not from a guess.',
'requires' => 'WP-CLI and shell access. Take the site offline first if you can.',
'danger' => 'This renames live database tables. Any plugin that created its own tables kept the ' . $old . ' prefix throughout and is unaffected, but run the database dump step and confirm it exists before continuing.',
'fields' => array(),
'apply' => array( 'code' => self::lines( $apply ) ),
'rollback' => array( 'code' => self::lines( $rollback ) ),
'verify' => array(
'code' => self::lines(
array(
$wp . ' db query "SHOW TABLES LIKE \'' . $old . '%\';"',
'grep table_prefix ' . self::bash_single_quote( $cfg ),
'curl -s -o /dev/null -w \'homepage HTTP %{http_code}\\n\' ' . self::bash_single_quote( home_url( '/' ) ),
'# Expect the ' . $old . ' tables listed, the matching $table_prefix, and HTTP 200.',
)
),
),
'notes' => array(
'The prefix migration was withdrawn from this plugin. This script exists so sites that ran it are not stuck with it.',
'Check the Site Audit afterwards for orphaned option names, the old migration renamed some options it should not have, and there is a one-click repair for those.',
),
'state' => array(
'Migrated from' => $old,
'Migrated to' => $new,
'Tables renamed' => (string) count( $renames ),
'Recorded' => ! empty( $info['time'] ) ? gmdate( 'Y-m-d H:i', (int) $info['time'] ) . ' UTC' : 'unknown',
),
);
}
/** Best-effort read of an installed component version. */
private static function installed_version( string $kind, string $slug ): string {
if ( 'core' === $kind ) {
return (string) get_bloginfo( 'version' );
}
if ( '' === $slug ) {
return '';
}
if ( 'theme' === $kind ) {
$theme = wp_get_theme( $slug );
return $theme->exists() ? (string) $theme->get( 'Version' ) : '';
}
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
foreach ( get_plugins() as $file => $data ) {
if ( dirname( $file ) === $slug || $file === $slug ) {
return (string) ( $data['Version'] ?? '' );
}
}
return '';
}
// -------------------------------------------------------------- mapping
/**
* Which recipe, if any, addresses a finding.
*
* Keys off structured fields, the quick-fix id, the nuclei template id, the
* affected component, rather than pattern-matching the human-readable
* title. Prose changes every time someone improves the wording; ids do not.
*
* @param array $finding Finding from the audit, nuclei, or the AI report.
* @return array{id:string,args:array}|null
*/
public static function map_finding( array $finding ): ?array {
$fix_id = isset( $finding['fix_id'] ) ? (string) $finding['fix_id'] : '';
$direct = array(
'ssh_brute_force' => 'fail2ban',
'wpconfig_perms' => 'wpconfig_perms',
'block_debug_log' => 'debug_log_exposed',
'disable_file_edit' => 'disallow_file_edit',
'disable_wp_cron' => 'wp_cron_hardening',
);
if ( '' !== $fix_id && isset( $direct[ $fix_id ] ) ) {
return array( 'id' => $direct[ $fix_id ], 'args' => array() );
}
// An outdated component is an update, but ONLY when the finding carries the
// structured fields set by the CVE matcher or the nuclei normaliser.
//
// Deliberately does not fall back to 'affected_component'. The model is told
// that field may hold "a plugin or theme slug, file path, endpoint, or header
// name", so treating it as a slug turns a Content-Security-Policy finding
// into "update the plugin content-security-policy", a confidently wrong
// recipe, which is worse than offering none.
$slug = isset( $finding['component_slug'] ) ? sanitize_key( (string) $finding['component_slug'] ) : '';
$type = isset( $finding['component_type'] ) ? (string) $finding['component_type'] : '';
if ( 'core' === $type ) {
return array( 'id' => 'update_core', 'args' => array() );
}
if ( '' !== $slug && in_array( $type, array( 'plugin', 'theme' ), true ) ) {
return array(
'id' => 'theme' === $type ? 'update_theme' : 'update_plugin',
'args' => array(
'slug' => $slug,
'version' => isset( $finding['installed_version'] ) ? (string) $finding['installed_version'] : '',
),
);
}
$template = isset( $finding['template_id'] ) ? (string) $finding['template_id'] : '';
if ( '' !== $template ) {
if ( str_contains( $template, 'debug-log' ) || str_contains( $template, 'debug.log' ) ) {
return array( 'id' => 'debug_log_exposed', 'args' => array() );
}
if ( str_contains( $template, 'wp-config' ) ) {
return array( 'id' => 'wpconfig_perms', 'args' => array() );
}
if ( str_contains( $template, 'wp-cron' ) ) {
return array( 'id' => 'wp_cron_hardening', 'args' => array() );
}
}
return null;
}
// ------------------------------------------------------------------ ajax
/** Render one recipe for the dialog. */
public static function ajax_recipe(): void {
check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above
$id = isset( $_POST['recipe'] ) ? sanitize_key( wp_unslash( $_POST['recipe'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above
$slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( $_POST['slug'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above
$version = isset( $_POST['version'] ) ? sanitize_text_field( wp_unslash( $_POST['version'] ) ) : '';
$recipe = self::get( $id, array( 'slug' => $slug, 'version' => $version ) );
if ( null === $recipe ) {
wp_send_json_error( array( 'message' => 'No remediation recipe is available for "' . $id . '".' ), 404 );
}
wp_send_json_success( $recipe );
}
/**
* Forward one usage event, if the site opted in.
*
* Proxied through PHP rather than posted from the browser so the licence key
* never reaches client-side JavaScript, and so a site with telemetry off
* cannot be made to report by a crafted page. Fire-and-forget: a failed or
* slow metrics call must never be visible to the person trying to fix a
* security problem.
*/
public static function ajax_event(): void {
check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
if ( '1' !== CSDT_Options::get( self::OPTION_TELEMETRY ) ) {
wp_send_json_success( array( 'recorded' => false, 'reason' => 'telemetry disabled' ) );
}
$licence = self::licence_key();
if ( '' === $licence ) {
wp_send_json_success( array( 'recorded' => false, 'reason' => 'unlicensed' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above
$recipe = isset( $_POST['recipe'] ) ? sanitize_key( wp_unslash( $_POST['recipe'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above
$event = isset( $_POST['event'] ) ? sanitize_key( wp_unslash( $_POST['event'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above
$severity = isset( $_POST['severity'] ) ? sanitize_key( wp_unslash( $_POST['severity'] ) ) : '';
// Only report recipes this build actually ships, so a stale page or a
// tampered request cannot write arbitrary labels into our metrics.
if ( '' === $event || ! in_array( $recipe, self::known_ids(), true ) ) {
wp_send_json_success( array( 'recorded' => false, 'reason' => 'unknown recipe or event' ) );
}
wp_remote_post(
self::EVENT_ENDPOINT,
array(
'user-agent' => CSDT_DevTools::USER_AGENT,
'timeout' => 3,
'blocking' => false,
'headers' => array(
'Content-Type' => 'application/json',
'X-License-Key' => $licence,
),
'body' => wp_json_encode(
array(
'recipe' => $recipe,
'event' => $event,
'severity' => $severity,
'pack' => self::PACK_VERSION,
'plugin_version' => CSDT_DevTools::VERSION,
)
),
)
);
wp_send_json_success( array( 'recorded' => true ) );
}
/** The licence key that identifies this site to the metrics endpoint. */
private static function licence_key(): string {
if ( ! class_exists( 'CSDT_Managed_AI' ) ) {
return '';
}
$o = get_option( CSDT_Managed_AI::OPTION, array() );
return is_array( $o ) ? trim( (string) ( $o['proxy_license_key'] ?? '' ) ) : '';
}
// --------------------------------------------------------------- helpers
/**
* Insert $DEFINE_LINE just above wp-config.php's "stop editing" marker.
*
* Uses awk rather than `sed -i` on purpose. BSD sed (macOS, FreeBSD) does not
* expand \n in a replacement, it inserts a literal "n", so the sed form of
* this silently corrupts wp-config.php on a non-GNU host instead of failing.
* awk is POSIX and behaves identically everywhere. Writing to a temp file and
* copying over the original also keeps the destination's ownership and mode,
* and leaves the original untouched if awk fails halfway.
*
* @param string $indent Leading whitespace for the emitted lines.
*/
private static function awk_insert_define( string $indent ): string {
return self::lines(
array(
$indent . 'TMP=$(mktemp)',
$indent . 'awk -v line="$DEFINE_LINE" \'!ins && /\\/\\* That.s all, stop editing/ { print line; print ""; ins=1 } { print }\' "$CFG" > "$TMP"',
$indent . 'sudo cp "$TMP" "$CFG"',
$indent . 'rm -f "$TMP"',
)
);
}
/** A {{TOKEN}} the dialog will ask the user to fill in. */
private static function token( string $name ): string {
return self::TOKEN_OPEN . $name . self::TOKEN_CLOSE;
}
/** Join script lines. */
private static function lines( array $lines ): string {
return implode( "\n", $lines );
}
/**
* Single-quote a value for bash.
*
* Paths and modes are interpolated into generated scripts, so they get
* quoted properly even though they come from our own state reads, an
* An install root containing a space should produce a working script, not a
* mysterious one.
*/
private static function bash_single_quote( string $value ): string {
return "'" . str_replace( "'", "'\\''", $value ) . "'";
}
/**
* Wrap a body in the shared preamble.
*
* Every generated script is safe to run twice, refuses to continue after a
* failed step, supports --dry-run, and puts its backups somewhere findable
* and predictable so the matching rollback can locate them without being
* told.
*/
private static function script( string $title, string $body, string $danger ): string {
$header = array(
'#!/usr/bin/env bash',
'#',
'# ' . $title,
'# Generated by CloudScale Cyber DevTools for ' . wp_parse_url( home_url(), PHP_URL_HOST ),
'# Recipe pack ' . self::PACK_VERSION . ', ' . gmdate( 'Y-m-d H:i' ) . ' UTC',
'#',
'# Read this before you run it. It is meant to be understood, not trusted.',
'# Run it once with --dry-run to see what it would do.',
);
if ( '' !== $danger ) {
$header[] = '#';
foreach ( self::wrap_comment( $danger ) as $line ) {
$header[] = '# ' . $line;
}
}
$preamble = array(
'',
'set -euo pipefail',
'',
'DRY_RUN=0',
'if [ "${1:-}" = "--dry-run" ]; then DRY_RUN=1; fi',
'',
'# Backups land here. The matching rollback script reads the same path.',
'BACKUP_DIR="${CSDT_BACKUP_DIR:-$HOME/csdt-backups/' . gmdate( 'Ymd' ) . '}"',
'mkdir -p "$BACKUP_DIR"',
'',
'say() { printf \'\\n==> %s\\n\' "$*"; }',
'# eval so that redirections and pipes in a step survive --dry-run reporting.',
'run() {',
' if [ "$DRY_RUN" = 1 ]; then',
' printf \' [dry-run] %s\\n\' "$*"',
' else',
' eval "$@"',
' fi',
'}',
'',
'# First backup wins. Applying twice is harmless by design, but a second',
'# run must NOT overwrite the pristine copy the first run captured, that',
'# would leave the rollback restoring an already-modified file while',
'# cheerfully reporting success.',
'backup_once() {',
' local src="$1" name="$2"',
' if [ -e "$BACKUP_DIR/$name" ]; then',
' echo " keeping the existing backup $BACKUP_DIR/$name (captured by an earlier run)"',
' elif [ "$DRY_RUN" = 1 ]; then',
' printf \' [dry-run] would back up %s -> %s\\n\' "$src" "$BACKUP_DIR/$name"',
' else',
' cp -a "$src" "$BACKUP_DIR/$name"',
' echo " backed up $src -> $BACKUP_DIR/$name"',
' fi',
'}',
'',
'# Same rule for recorded scalars (a file mode, a version number).',
'record_once() {',
' local name="$1" value="$2"',
' if [ -e "$BACKUP_DIR/$name" ]; then',
' echo " keeping recorded $name=$(cat "$BACKUP_DIR/$name") from an earlier run"',
' elif [ "$DRY_RUN" = 1 ]; then',
' printf \' [dry-run] would record %s=%s\\n\' "$name" "$value"',
' else',
' printf \'%s\\n\' "$value" > "$BACKUP_DIR/$name"',
' echo " recorded $name=$value"',
' fi',
'}',
'',
'if [ "$DRY_RUN" = 1 ]; then say "DRY RUN, nothing will be changed"; fi',
'echo "Backups: $BACKUP_DIR"',
'',
);
$footer = array(
'',
'say "Done"',
'if [ "$DRY_RUN" = 1 ]; then',
' echo "This was a dry run. Re-run without --dry-run to apply."',
'else',
' echo "Backups for rollback are in: $BACKUP_DIR"',
'fi',
);
return self::lines( array_merge( $header, $preamble, array( $body ), $footer ) );
}
/** Soft-wrap a sentence for a comment block. */
private static function wrap_comment( string $text ): array {
return explode( "\n", wordwrap( $text, 72, "\n", false ) );
}
}