'it deletes csdt_devtools_2fa_force_admins, switching off two-factor enforcement for every administrator', 'disable_app_passwords' => 're-enables application passwords, which authenticate to the REST API without a second factor', ); /** Register the command tree. Called only under WP-CLI. */ public static function register(): void { if ( ! class_exists( 'WP_CLI' ) ) { return; } WP_CLI::add_command( 'csdt fix', 'CSDT_CLI_Fix' ); // Pair, unpair and inspect CloudScale Edge Gateway keys. See class-cli-edge.php. WP_CLI::add_command( 'csdt edge', 'CSDT_CLI_Edge' ); } /** * Run an admin-ajax handler and return its decoded JSON. * * Four filters, not one, because wp_die() picks its handler by request shape * and none of those shapes is "CLI": * * wp_doing_ajax wp_send_json() calls plain die() rather than wp_die() * when this is false, and a plain die() cannot be * intercepted at all. Without this filter the command * would terminate the whole WP-CLI process mid-run, * having already applied the fix, and report nothing. * wp_die_*_handler which of the three fires depends on the request headers, * so all three are covered rather than guessing. * * @param array $post What the handler should see in $_POST. * @return array{ok:bool,data:mixed,raw:string} */ public static function call_ajax( array $post ): array { // This runs under WP-CLI or not at all. The superglobal save-and-restore below is // safe precisely because there is no HTTP request in scope, so that has to be a fact // the code enforces rather than a claim the docblock makes: over HTTP the swap would // let one request's handler see another's input. register() already gates the command // on WP_CLI; this gates the mechanism. if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return array( 'ok' => false, 'data' => 'This path runs under WP-CLI only.', 'raw' => '' ); } $halt = static function () { return static function () { throw new CSDT_CLI_Halt(); }; }; // The handler reads the arguments the operator typed on the command line, so the // superglobals are SET here and emptied afterwards. The nonce is verified, by // ajax_apply_quick_fix()'s own check_ajax_referer(), against the nonce minted in // self::nonce() a few lines earlier at the caller, which is the whole reason this goes // through the handler instead of around it. // // THIS USED TO SAVE AND RESTORE the previous contents, and round 23 reported all three // reads (findings 23, 28, 30, 31: "No nonce check in the function that reads $_POST", // "No sanitiser on the reading line"). The argument against them was sound and the // reviewer reads one function body, so the answer is to stop reading rather than to // explain the read: under WP-CLI there is no HTTP request, PHP populates neither // superglobal, and the guard above has already refused to run anywhere else. The save // was preserving two empty arrays. Emptying them on the way out is the same restore // with nothing read. $_POST = $post; $_REQUEST = $post; add_filter( 'wp_doing_ajax', '__return_true', 99 ); add_filter( 'wp_die_ajax_handler', $halt, 99 ); add_filter( 'wp_die_json_handler', $halt, 99 ); add_filter( 'wp_die_handler', $halt, 99 ); // The handler's answer arrives as DATA, through the sink it exposes for exactly this. // It used to be read by capturing printed output, which had two faults: WordPress.org // rejects a plugin managing its own output buffer, and anything that printed a notice // while a fix ran landed inside the JSON and made the whole result unreadable. The // handler itself is unchanged and still the one the browser calls. $payload = null; $prev_sink = CSDT_Site_Audit::$quick_fix_sink; CSDT_Site_Audit::$quick_fix_sink = static function ( bool $ok, $data, ?int $status ) use ( &$payload ) { $payload = array( 'ok' => $ok, 'data' => $data, 'status' => $status, ); // Stops the handler where wp_send_json_*() would have exited. Same place, same state. throw new CSDT_CLI_Halt(); }; $restore = static function () use ( $halt, $prev_sink ) { CSDT_Site_Audit::$quick_fix_sink = $prev_sink; remove_filter( 'wp_doing_ajax', '__return_true', 99 ); remove_filter( 'wp_die_ajax_handler', $halt, 99 ); remove_filter( 'wp_die_json_handler', $halt, 99 ); remove_filter( 'wp_die_handler', $halt, 99 ); // Empty, not "as they were": see the note at the assignment above. Under WP-CLI // both start empty, so this is the same state the function was entered in. $_POST = array(); $_REQUEST = array(); }; try { CSDT_Site_Audit::ajax_apply_quick_fix(); } catch ( CSDT_CLI_Halt $e ) { // The expected path: the handler answered, through the sink or through wp_die(). What // it answered is in $payload; the exception carries nothing. unset( $e ); } catch ( \Throwable $e ) { $restore(); throw $e; } $restore(); if ( ! is_array( $payload ) ) { // The handler stopped without answering, which is what a refused request looks like: // check_ajax_referer() and the capability check both reach wp_die() directly rather // than the sink. Do NOT report this as "the fix failed": it may not have been attempted // at all, and telling the operator it failed sends them to re-run it. return array( 'ok' => false, 'data' => 'The handler stopped without returning a result, which usually means the request was ' . 'refused before any fix ran (a nonce or capability check). Whether anything changed is ' . 'unknown: check the current state with `wp csdt fix list` before re-running.', 'raw' => '', ); } return array( 'ok' => (bool) $payload['ok'], 'data' => $payload['data'] ?? null, // Kept for callers that print the server's answer verbatim. Encoded here rather than // captured, so it is the payload and nothing else. 'raw' => (string) wp_json_encode( array( 'success' => (bool) $payload['ok'], 'data' => $payload['data'] ?? null, ) ), ); } /** A nonce the handler will accept, for the user WP-CLI is running as. */ public static function nonce(): string { return wp_create_nonce( CSDT_DevTools::SECURITY_NONCE ); } /** * Refuse to run as nobody. * * Shell access already implies control of the site, so this is not a security * boundary, it is about the fix being attributable and the handler's own * capability check being met rather than bypassed. Without --user the handler * would simply answer 403 and look broken. */ public static function require_admin(): void { if ( ! is_user_logged_in() ) { WP_CLI::error( "No user context. Re-run with --user= for an administrator, e.g.\n" . ' wp csdt fix list --user=admin' ); } if ( ! current_user_can( 'manage_options' ) ) { WP_CLI::error( 'That user cannot manage options, which every fix here requires.' ); } } } /** * Apply, inspect and roll back the fixes the Site Audit finds. */ class CSDT_CLI_Fix { /** * List every fix, its route, and whether it is already applied. * * ## OPTIONS * * [--route=] * : Show only one route. * --- * options: * - auto * - recipe * - manual * - none * --- * * [--format=] * : Render format. * --- * default: table * options: * - table * - csv * - json * - yaml * --- * * ## EXAMPLES * * # Everything the plugin can apply itself, and whether it has been * wp csdt fix list --route=auto --user=admin * * # Feed the unapplied ones into a loop * wp csdt fix list --route=auto --format=json --user=admin * * @subcommand list */ public function list_fixes( $args, $assoc_args ): void { CSDT_CLI::require_admin(); $want = $assoc_args['route'] ?? ''; $applied = array(); foreach ( CSDT_Site_Audit::get_quick_fixes() as $qf ) { $applied[ $qf['id'] ] = $qf; } $rows = array(); foreach ( CSDT_Fix_Routes::registry() as $key => $entry ) { $route = strtolower( str_replace( 'self::', '', (string) $entry['route'] ) ); if ( '' !== $want && $route !== $want ) { continue; } $id = (string) ( $entry['id'] ?? '' ); $qf = $applied[ $id ] ?? null; // A fix whose readiness is measured from request headers cannot be reported // from a shell, there is no request, so the check answers "misconfigured" // on every site. '?' rather than a confident wrong answer. $ctx_bound = isset( CSDT_Fix_Routes::needs_request_context()[ $id ] ); $row = array( 'finding' => $key, 'route' => $route, 'id' => $id, // Only AUTO fixes have a state the plugin can read. Saying "no" for a // manual fix would claim knowledge we do not have. 'applied' => null === $qf ? '-' : ( $ctx_bound ? '?' : ( ! empty( $qf['fixed'] ) ? 'yes' : 'no' ) ), 'risk' => null === $qf ? '-' : (string) ( $qf['risk'] ?? 'low' ), 'cli' => $this->cli_verb( $route, $id ), ); $rows[] = $row; } if ( empty( $rows ) ) { WP_CLI::warning( 'No fixes matched.' ); return; } WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'finding', 'route', 'id', 'applied', 'risk', 'cli' ) ); } /** What, if anything, the operator can do about this route from a shell. */ private function cli_verb( string $route, string $id ): string { if ( 'auto' === $route ) { return 'apply'; } if ( 'recipe' === $route ) { return 'show'; } return 'admin UI'; } /** * Print a remediation recipe: the apply script, its rollback, and how to verify. * * Prints. Does not run. These are commands for a root shell, generated against * this server's live state, and the person who runs them should have read them * first, that is the whole reason they are scripts and not a button. * * ## OPTIONS * * * : Recipe id, as shown in the `id` column of `wp csdt fix list --route=recipe`. * * [--part=] * : Which script to print. * --- * default: all * options: * - all * - apply * - rollback * - verify * --- * * [--slug=] * : Component slug, for the update_plugin and update_theme recipes. * * ## EXAMPLES * * wp csdt fix show wpconfig_perms --user=admin * wp csdt fix show wpconfig_perms --part=apply --user=admin > fix.sh */ public function show( $args, $assoc_args ): void { CSDT_CLI::require_admin(); $id = (string) ( $args[0] ?? '' ); if ( ! in_array( $id, CSDT_Remediation::known_ids(), true ) ) { WP_CLI::error( "Unknown recipe '{$id}'. This build ships: " . implode( ', ', CSDT_Remediation::known_ids() ) ); } $recipe = CSDT_Remediation::get( $id, array( 'slug' => (string) ( $assoc_args['slug'] ?? '' ) ) ); if ( null === $recipe ) { WP_CLI::error( "Recipe '{$id}' could not be built against this server's state." ); } $part = (string) ( $assoc_args['part'] ?? 'all' ); // With --part, print the script and nothing else, so the output can be // redirected straight into a file without a header to strip. if ( 'all' !== $part ) { $body = (string) ( $recipe[ $part ]['code'] ?? '' ); if ( '' === $body ) { WP_CLI::error( "Recipe '{$id}' has no '{$part}' script." ); } WP_CLI::line( $body ); return; } WP_CLI::line( '# ' . ( $recipe['title'] ?? $id ) ); WP_CLI::line( '# ' . ( $recipe['summary'] ?? '' ) ); if ( ! empty( $recipe['requires'] ) ) { WP_CLI::line( '# Requires: ' . $recipe['requires'] ); } if ( ! empty( $recipe['danger'] ) ) { WP_CLI::line( '# DANGER: ' . $recipe['danger'] ); } foreach ( array( 'apply', 'rollback', 'verify' ) as $section ) { $body = (string) ( $recipe[ $section ]['code'] ?? '' ); if ( '' === $body ) { continue; } WP_CLI::line( '' ); WP_CLI::line( '# ── ' . strtoupper( $section ) . ' ' . str_repeat( '─', 60 ) ); WP_CLI::line( $body ); } // Placeholders the generator could not fill. Printed last so they are the // final thing on screen, because a script pasted into root's shell with // {{SSH_PORT}} still in it does something other than what it says. if ( ! empty( $recipe['fields'] ) ) { WP_CLI::line( '' ); WP_CLI::warning( 'This recipe has placeholders you must replace before running it:' ); foreach ( (array) $recipe['fields'] as $field ) { WP_CLI::line( ' {{' . ( $field['token'] ?? '?' ) . '}} ' . ( $field['label'] ?? '' ) ); } } } /** * Apply one fix, through the same handler the admin UI uses. * * ## OPTIONS * * * : Fix id, from the `id` column of `wp csdt fix list --route=auto`. * * [--dry-run] * : Report what would be attempted and its stated effect, and change nothing. * This does NOT simulate the write, no handler here is re-entrant enough to * be trialled, so it tells you the fix's current state, its risk, exactly * what it says it will change, and whether it can be undone. Treat it as * reading the confirmation dialog, not as a rehearsal. * * [--yes] * : Skip the confirmation prompt. Required for anything tagged destructive * when there is no terminal to prompt on. * * ## EXAMPLES * * wp csdt fix apply hide_wp_version --user=admin * wp csdt fix apply delete_inactive_plugins --dry-run --user=admin */ public function apply( $args, $assoc_args ): void { CSDT_CLI::require_admin(); $id = (string) ( $args[0] ?? '' ); $qf = null; foreach ( CSDT_Site_Audit::get_quick_fixes() as $candidate ) { if ( ( $candidate['id'] ?? '' ) === $id ) { $qf = $candidate; break; } } if ( null === $qf ) { WP_CLI::error( "Unknown fix '{$id}'. Run `wp csdt fix list --route=auto` for the ids this build applies." ); } $risk = (string) ( $qf['risk'] ?? 'low' ); // Refuse before doing anything, and say why. The handler would refuse this // anyway, ajax_apply_quick_fix() checks ip_detection_is_broken(), but its // message would blame the site's Visitor IP Source setting, sending the // operator to fix something that is not broken. $ctx = CSDT_Fix_Routes::needs_request_context(); if ( isset( $ctx[ $id ] ) ) { WP_CLI::error( "'{$id}' cannot be applied from the command line, because " . $ctx[ $id ] . ".\n" . "Its current state cannot be read here either, which is why `list` shows '?'.\n" . 'Apply it from the Site Audit or Quick Fixes panel, where the check sees a real request.' ); } if ( isset( $assoc_args['dry-run'] ) ) { WP_CLI::line( 'fix : ' . $id ); WP_CLI::line( 'state : ' . ( ! empty( $qf['fixed'] ) ? 'already applied' : 'not applied' ) ); WP_CLI::line( 'risk : ' . $risk ); WP_CLI::line( 'undoable : ' . ( ! empty( $qf['undoable'] ) ? 'yes' : 'no' ) ); WP_CLI::line( 'effect : ' . (string) ( $qf['confirm_msg'] ?? '(none stated)' ) ); if ( ! empty( $qf['undo_msg'] ) ) { WP_CLI::line( 'undo : ' . $qf['undo_msg'] ); } WP_CLI::log( '' ); WP_CLI::warning( 'Dry run: nothing was changed, and nothing was simulated either, the above is what the fix states it will do.' ); return; } if ( ! empty( $qf['fixed'] ) ) { WP_CLI::success( "'{$id}' is already applied. Nothing to do." ); return; } // Confirm on either of two grounds, because they are different questions and // gating on only the first was a real defect. // // The risk tag says how destructive a fix is to DATA. It says nothing about // whether the fix can lock a human out. enforce_2fa_admins is tagged `low`, // correctly, it deletes nothing, and sends every administrator to 2FA // enrolment, unable to reach wp-admin until they finish. Gating only on risk // meant `wp csdt fix apply enforce_2fa_admins` ran with no prompt at all, in // a shell, possibly across a fleet. CSDT_Fix_Routes::needs_operator() is the // missing half, and it states the consequence rather than just refusing. $needs_operator = CSDT_Fix_Routes::needs_operator(); $why_confirm = ''; if ( isset( $needs_operator[ $id ] ) ) { $why_confirm = 'Needs someone present: ' . $needs_operator[ $id ] . '.'; } elseif ( in_array( $risk, array( 'destructive', 'medium', 'moderate' ), true ) ) { $why_confirm = 'Risk: ' . $risk . '.'; } if ( '' !== $why_confirm ) { WP_CLI::confirm( sprintf( "%s\n%s\nApply '%s'?", $why_confirm, (string) ( $qf['confirm_msg'] ?? '' ), $id ), $assoc_args ); } $res = CSDT_CLI::call_ajax( array( 'action' => 'csdt_devtools_quick_fix', 'nonce' => CSDT_CLI::nonce(), 'fix_action' => 'apply', 'fix_id' => $id, ) ); if ( ! $res['ok'] ) { $msg = $res['data']; if ( is_array( $msg ) ) { // A retired fix answers with the recipe that replaced it. Say so, // rather than printing an array and leaving the operator guessing. if ( ! empty( $msg['retired'] ) && ! empty( $msg['recipe'] ) ) { WP_CLI::error( "'{$id}' is no longer applied automatically. Run:\n" . ' wp csdt fix show ' . $msg['recipe'] . ' --user=' ); } $msg = $msg['message'] ?? wp_json_encode( $msg ); } WP_CLI::error( (string) $msg ); } $data = is_array( $res['data'] ) ? $res['data'] : array(); $message = (string) ( $data['message'] ?? '' ); if ( ! empty( $data['warning'] ) ) { WP_CLI::warning( (string) $data['warning'] ); } WP_CLI::success( "Applied '{$id}'." . ( '' !== $message ? ' ' . $message : '' ) ); } /** * Apply every fix that is safe to apply unattended. * * The list is CSDT_Fix_Routes::batch_safe(), an explicit allowlist, not * everything tagged low-risk. Anything in needs_operator() is excluded by * construction, and this command refuses to run if that ever stops being true, * because a batch that quietly grew to include a lockout is the whole risk here. * * Each fix goes through the same handler a single `apply` uses, one at a time, so * a failure stops at that fix instead of half-finishing the set. * * ## OPTIONS * * [--dry-run] * : List what would be applied and change nothing. * * [--yes] * : Skip the single confirmation prompt. * * ## EXAMPLES * * wp csdt fix apply-safe --dry-run --user=admin * wp csdt fix apply-safe --yes --user=admin * * @subcommand apply-safe */ public function apply_safe( $args, $assoc_args ): void { CSDT_CLI::require_admin(); $safe = CSDT_Fix_Routes::batch_safe(); $operator = CSDT_Fix_Routes::needs_operator(); // Belt and braces against the exact way this feature goes wrong: someone adds // a fix to batch_safe() without noticing it also needs a human. Refuse the // whole run rather than skipping the offender quietly. $overlap = array_intersect( $safe, array_keys( $operator ) ); if ( $overlap ) { WP_CLI::error( 'Refusing to run: ' . implode( ', ', $overlap ) . ' is in both batch_safe() and ' . "needs_operator(), so the batch would apply something that needs a person watching.\n" . 'Fix CSDT_Fix_Routes before using this command.' ); } $pending = array(); $already = 0; foreach ( CSDT_Site_Audit::get_quick_fixes() as $qf ) { $id = (string) ( $qf['id'] ?? '' ); if ( ! in_array( $id, $safe, true ) ) { continue; } if ( ! empty( $qf['fixed'] ) ) { ++$already; continue; } $pending[ $id ] = (string) ( $qf['confirm_msg'] ?? '' ); } WP_CLI::log( sprintf( '%d safe fix(es) available, %d already applied.', count( $pending ), $already ) ); if ( empty( $pending ) ) { WP_CLI::success( 'Nothing to do, every safe fix is already applied.' ); return; } foreach ( $pending as $id => $msg ) { WP_CLI::log( ' • ' . $id . ', ' . $msg ); } if ( isset( $assoc_args['dry-run'] ) ) { WP_CLI::warning( 'Dry run: nothing was changed. As with a single apply, nothing was simulated either.' ); return; } WP_CLI::confirm( sprintf( 'Apply these %d fix(es)?', count( $pending ) ), $assoc_args ); $applied = 0; foreach ( array_keys( $pending ) as $id ) { $res = CSDT_CLI::call_ajax( array( 'action' => 'csdt_devtools_quick_fix', 'nonce' => CSDT_CLI::nonce(), 'fix_action' => 'apply', 'fix_id' => $id, ) ); if ( ! $res['ok'] ) { $msg = is_array( $res['data'] ) ? ( $res['data']['message'] ?? wp_json_encode( $res['data'] ) ) : $res['data']; // Stop rather than continue. A batch that presses on past a failure // leaves the operator guessing which half of it took effect. WP_CLI::error( sprintf( "Stopped at '%s': %s\n%d of %d applied before this.", $id, (string) $msg, $applied, count( $pending ) ) ); } ++$applied; WP_CLI::log( ' ✓ ' . $id ); } WP_CLI::success( sprintf( 'Applied %d safe fix(es). Re-run `wp csdt fix list` to confirm the state.', $applied ) ); } /** * Undo a fix, where undoing it does not weaken authentication. * * ## OPTIONS * * * : Fix id to roll back. * * [--yes] * : Skip the confirmation prompt. * * ## EXAMPLES * * wp csdt fix rollback hide_wp_version --user=admin */ public function rollback( $args, $assoc_args ): void { CSDT_CLI::require_admin(); $id = (string) ( $args[0] ?? '' ); // The refusal comes before anything else, including the existence check, so // that no amount of getting the id subtly wrong finds a way through. if ( isset( CSDT_CLI::ROLLBACK_REFUSED[ $id ] ) ) { WP_CLI::error( "Refusing to roll back '{$id}' from the command line, because " . CSDT_CLI::ROLLBACK_REFUSED[ $id ] . ".\n" . "An unattended script that can switch off authentication is a different\n" . "kind of risk from a person doing it deliberately. If you genuinely need\n" . "this, do it in the admin UI: Security → Quick Fixes → Undo, where the\n" . 'consequence is stated and someone is present to read it.' ); } WP_CLI::confirm( "Roll back '{$id}'?", $assoc_args ); $res = CSDT_CLI::call_ajax( array( 'action' => 'csdt_devtools_quick_fix', 'nonce' => CSDT_CLI::nonce(), 'fix_action' => 'revert', 'fix_id' => $id, ) ); if ( ! $res['ok'] ) { $msg = is_array( $res['data'] ) ? ( $res['data']['message'] ?? wp_json_encode( $res['data'] ) ) : $res['data']; WP_CLI::error( (string) $msg ); } $data = is_array( $res['data'] ) ? $res['data'] : array(); WP_CLI::success( (string) ( $data['message'] ?? "Rolled back '{$id}'." ) ); } /** * Show the finding-to-fix coverage matrix. * * Every finding the audit can report resolves to exactly one route: applied * here, a generated script, a decision only you can make, or an explicit * nothing-to-do. This prints that map, which is the same data the build gate * (`shared-build-tools/check-fix-coverage.php`) asserts is complete. * * ## OPTIONS * * [--format=] * : Render format. * --- * default: table * options: * - table * - csv * - json * - yaml * --- * * ## EXAMPLES * * wp csdt fix routes --user=admin */ public function routes( $args, $assoc_args ): void { CSDT_CLI::require_admin(); $counts = array(); $rows = array(); foreach ( CSDT_Fix_Routes::registry() as $key => $entry ) { $resolved = CSDT_Fix_Routes::resolve( $key ); $route = null === $resolved ? 'UNRESOLVED' : $resolved['route']; $counts[ $route ] = ( $counts[ $route ] ?? 0 ) + 1; $rows[] = array( 'finding' => $key, 'route' => $route, 'id' => null === $resolved ? '' : $resolved['id'], 'detail' => null === $resolved ? 'no route resolved, this is a bug, and the build gate should have caught it' : (string) ( $resolved['where'] ?? $resolved['reason'] ?? $resolved['label'] ?? '' ), ); } WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'finding', 'route', 'id', 'detail' ) ); $summary = array(); foreach ( $counts as $route => $n ) { $summary[] = "{$n} {$route}"; } WP_CLI::log( '' ); WP_CLI::log( count( $rows ) . ' findings routed: ' . implode( ', ', $summary ) ); if ( isset( $counts['UNRESOLVED'] ) ) { WP_CLI::error( $counts['UNRESOLVED'] . ' finding(s) have no route. Run the build gate for details.' ); } } }