> $components Feed result components. * @return int Number of vulnerable components recorded. */ public static function record_candidates( array $components ): int { try { $out = array(); foreach ( $components as $component ) { if ( ! is_array( $component ) ) { continue; } $slug = isset( $component['slug'] ) ? (string) $component['slug'] : ''; $cves = isset( $component['matched_cves'] ) && is_array( $component['matched_cves'] ) ? $component['matched_cves'] : array(); if ( '' === $slug || empty( $cves ) ) { continue; } $ids = array(); $severity = ''; $fixed_in = ''; $conf = ''; foreach ( $cves as $cve ) { if ( ! is_array( $cve ) ) { continue; } $id = isset( $cve['cve_id'] ) ? (string) $cve['cve_id'] : ''; if ( '' !== $id ) { $ids[] = $id; } $severity = self::worse_severity( $severity, isset( $cve['severity'] ) ? (string) $cve['severity'] : '' ); // The LOWEST patched version across the findings is the version at // which every one of them stops applying, so that is what the shield // must wait for. Taking the first, or the highest, would either lift // the shield while a finding still applies or hold it open forever. $fix = isset( $cve['fixed_in'] ) ? (string) $cve['fixed_in'] : ''; if ( '' !== $fix && ( '' === $fixed_in || version_compare( $fix, $fixed_in, '>' ) ) ) { $fixed_in = $fix; } if ( '' === $conf && ! empty( $cve['confidence'] ) ) { $conf = (string) $cve['confidence']; } } $out[ $slug ] = array( 'slug' => $slug, 'name' => isset( $component['name'] ) ? (string) $component['name'] : $slug, 'type' => isset( $component['type'] ) ? (string) $component['type'] : 'plugin', 'version' => isset( $component['version'] ) ? (string) $component['version'] : '', 'severity' => '' !== $severity ? $severity : 'medium', 'cve_ids' => array_values( array_unique( $ids ) ), 'fixed_in' => $fixed_in, 'confidence' => $conf, 'seen_at' => time(), ); } update_option( self::OPTION_CANDIDATES, $out, false ); // Its OWN try/catch. Alerting runs after the index is safely stored, and a // failure to notify must never cost us the finding itself. Same reasoning as // record() keeping its own catch away from the refusal path. try { self::alert_new_vulnerabilities( $out ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } return count( $out ); } catch ( \Throwable $e ) { // Never allowed to break a scan. A scan that fails because the shield // bookkeeping threw would lose the CVE findings themselves, which are worth // far more than this index. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return 0; } } /** * Alerts on findings that were not present the last time a scan ran. * * WHY THIS EXISTS * --------------- * Shields are entirely reactive to the CVE feed, and until this was added nothing * told anybody a finding had appeared. The only CVE-adjacent alert in the plugin * comes from `run_scheduled_scan()`, which is not scheduled on every install (it was * not scheduled on this one), and the nightly Playwright scan drives the AJAX path * instead, which does not notify at all. So a plugin could acquire a critical CVE * and the only way to find out was to open the panel. * * WHAT IT ALERTS ON * ----------------- * Newly seen (component, CVE) pairs, so a rescan that returns the same findings is * silent, and a component that gains a SECOND CVE is reported rather than being * swallowed because its slug was already known. * * It never claims a shield is available unless a surface has actually been * enumerated for that component. Enumeration runs in the admin, not on the scan * path, so at this moment the surface list may be absent or stale, and promising * protection that may not be offerable would be the product contradicting itself. * * @since 1.10.779 * @param array> $candidates Current vulnerable components. * @return int Number of findings alerted on. */ private static function alert_new_vulnerabilities( array $candidates ): int { $seen = array(); $fresh = array(); foreach ( $candidates as $slug => $c ) { foreach ( (array) ( $c['cve_ids'] ?? array() ) as $cve ) { $seen[] = (string) $slug . '|' . (string) $cve; } } $alerted = get_option( self::OPTION_ALERTED, array() ); $alerted = is_array( $alerted ) ? array_map( 'strval', $alerted ) : array(); foreach ( $seen as $key ) { if ( ! in_array( $key, $alerted, true ) ) { $fresh[] = $key; } } // Pruned to what is currently vulnerable, so an updated component that later // regresses alerts again and this row cannot grow forever. Written even when // nothing is fresh, because that pruning is the whole point. update_option( self::OPTION_ALERTED, $seen, false ); if ( empty( $fresh ) ) { return 0; } // Respects the same two toggles every other alert here does. Checked AFTER the // bookkeeping above, so switching alerts on does not produce a backlog of every // CVE the site has ever had. if ( '1' !== (string) CSDT_Options::get( self::OPTION_ALERT_ENABLED ) || '1' !== (string) CSDT_Options::get( 'csdt_notify_telegram_enabled' ) ) { return 0; } if ( ! is_callable( array( 'CSDT_Telegram', 'send' ) ) ) { return 0; } $surfaces = self::surfaces(); $worst = ''; $lines = array(); $slugs = array(); foreach ( $fresh as $key ) { $parts = explode( '|', $key, 2 ); $slug = $parts[0]; $cve = $parts[1] ?? ''; $c = $candidates[ $slug ] ?? array(); $worst = self::worse_severity( $worst, (string) ( $c['severity'] ?? '' ) ); if ( isset( $slugs[ $slug ] ) ) { $lines[] = ' also ' . $cve; continue; } $slugs[ $slug ] = true; $fixed = (string) ( $c['fixed_in'] ?? '' ); $line = sprintf( '%s %s (%s) %s', (string) ( $c['name'] ?? $slug ), '' !== (string) ( $c['version'] ?? '' ) ? 'v' . (string) $c['version'] : '', strtoupper( (string) ( $c['severity'] ?? 'unrated' ) ), $cve ); $line .= "\n " . ( '' !== $fixed ? 'Fixed in ' . $fixed . '. Update to clear it.' : 'No fixed version published yet.' ); $surface = isset( $surfaces[ $slug ] ) && is_array( $surfaces[ $slug ] ) ? $surfaces[ $slug ] : null; if ( null === $surface ) { $line .= "\n Shield: open the Firewall tab and re-scan surfaces to see whether one can be offered."; } else { $n_ajax = count( (array) ( $surface['ajax'] ?? array() ) ); $n_rest = count( (array) ( $surface['rest'] ?? array() ) ); $line .= 0 === $n_ajax + $n_rest ? "\n Shield: not available, no anonymous endpoint belongs to this component alone." : sprintf( "\n Shield available: %d ajax action(s), %d REST namespace(s).", $n_ajax, $n_rest ); } $lines[] = $line; } $body = sprintf( "%d new vulnerability finding(s) on %s\n\n%s\n\nFirewall tab: %s", count( $fresh ), wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ), implode( "\n\n", $lines ), admin_url( 'admin.php?page=' . ( defined( 'CSDT_DevTools::TOOLS_SLUG' ) || class_exists( 'CSDT_DevTools' ) ? CSDT_DevTools::TOOLS_SLUG : 'cs-devtools' ) . '&tab=firewall' ) ); CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $body, 'Vulnerable component', 'critical' === $worst ? 'critical' : 'warning' ); return count( $fresh ); } /** * Vulnerable components the last scan found. * * @since 1.10.776 * @return array> */ public static function candidates(): array { $stored = get_option( self::OPTION_CANDIDATES, array() ); return is_array( $stored ) ? $stored : array(); } /** * The worse of two severity labels. * * An unrecognised label loses to a recognised one rather than being ranked, so a * feed that starts emitting a new word cannot silently outrank "critical". * * @since 1.10.776 * @param string $a First label. * @param string $b Second label. * @return string */ private static function worse_severity( string $a, string $b ): string { $ia = array_search( strtolower( $a ), self::SEVERITY_ORDER, true ); $ib = array_search( strtolower( $b ), self::SEVERITY_ORDER, true ); if ( false === $ia ) { return false === $ib ? $a : $b; } if ( false === $ib ) { return $a; } return $ib > $ia ? $b : $a; } // ── Surface enumeration ────────────────────────────────────────────── /** * Enumerates the unauthenticated surface of every installed component. * * Runs in admin and cron contexts only. It walks $wp_filter and instantiates the * REST server, neither of which belongs on a visitor's request. * * THE HONEST LIMIT, stated here because the panel states it too: a plugin that * registers its `wp_ajax_nopriv_*` handlers only when DOING_AJAX is already true * will not appear in this enumeration, because on an admin page load it has not * registered them. Such a component reports zero ajax actions rather than reporting * a surface it does not have on this request, and no shield is offered for a surface * that could not be seen. * * @since 1.10.776 * @return array> Slug to surface record. */ public static function enumerate_surfaces(): array { $ajax = self::nopriv_actions_by_component(); $rest = self::rest_namespaces_by_component(); $out = array(); $slugs = array_unique( array_merge( array_keys( $ajax ), array_keys( $rest ) ) ); foreach ( $slugs as $slug ) { $actions = isset( $ajax[ $slug ] ) ? array_values( $ajax[ $slug ] ) : array(); $spaces = isset( $rest[ $slug ] ) ? array_values( $rest[ $slug ] ) : array(); sort( $actions ); sort( $spaces ); $out[ $slug ] = array( 'ajax' => array_slice( $actions, 0, self::MAX_AJAX_PER_COMPONENT ), 'rest' => array_slice( $spaces, 0, self::MAX_REST_PER_COMPONENT ), 'enumerated_at' => time(), ); } update_option( self::OPTION_SURFACES, $out, false ); return $out; } /** * The last enumeration, or an empty array if none has run. * * @since 1.10.776 * @return array> */ public static function surfaces(): array { $stored = get_option( self::OPTION_SURFACES, array() ); return is_array( $stored ) ? $stored : array(); } /** * Maps `wp_ajax_nopriv_*` actions to the component that owns them. * * An action with handlers attributed to more than one component is dropped from * every component's list, not assigned to the first one seen. Refusing a shared * action to protect one plugin would break the other, and a shield that breaks an * unrelated plugin is worse than the vulnerability it is standing in front of. * * @since 1.10.776 * @return array> */ private static function nopriv_actions_by_component(): array { $owners = array(); $hooks = isset( $GLOBALS['wp_filter'] ) && is_array( $GLOBALS['wp_filter'] ) ? $GLOBALS['wp_filter'] : array(); foreach ( $hooks as $hook_name => $hook ) { $hook_name = (string) $hook_name; if ( 0 !== strpos( $hook_name, 'wp_ajax_nopriv_' ) ) { continue; } $action = substr( $hook_name, strlen( 'wp_ajax_nopriv_' ) ); if ( '' === $action ) { continue; } $callbacks = is_object( $hook ) && isset( $hook->callbacks ) && is_array( $hook->callbacks ) ? $hook->callbacks : ( is_array( $hook ) ? $hook : array() ); foreach ( $callbacks as $by_priority ) { if ( ! is_array( $by_priority ) ) { continue; } foreach ( $by_priority as $registered ) { $cb = is_array( $registered ) && isset( $registered['function'] ) ? $registered['function'] : null; $slug = null === $cb ? '' : self::component_for_callback( $cb ); if ( '' === $slug ) { // Unattributable: recorded as an owner named for nothing, which // makes the action ambiguous and therefore not shieldable. Silently // ignoring it would let an action look single owned when part of it // belongs to code this cannot see. $slug = '?'; } $owners[ $action ][ $slug ] = true; } } } $out = array(); foreach ( $owners as $action => $by_slug ) { if ( 1 !== count( $by_slug ) ) { continue; // Shared or partly unattributable. } $slug = (string) array_key_first( $by_slug ); if ( '' === $slug || '?' === $slug ) { continue; } $out[ $slug ][] = (string) $action; } return $out; } /** * Maps REST namespaces to the component that owns them. * * Same single owner rule as the ajax map, and for the same reason: `wp/v2` carries * routes from core and from any plugin that extends it, so it can never be shielded, * and that falls out of the ownership test rather than needing a denylist of core * namespaces to be kept up to date. * * @since 1.10.776 * @return array> */ private static function rest_namespaces_by_component(): array { if ( ! function_exists( 'rest_get_server' ) ) { return array(); } try { $server = rest_get_server(); $routes = is_object( $server ) && method_exists( $server, 'get_routes' ) ? $server->get_routes() : array(); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return array(); } $owners = array(); foreach ( (array) $routes as $route => $handlers ) { $namespace = self::namespace_from_route( (string) $route ); if ( '' === $namespace ) { continue; } foreach ( (array) $handlers as $handler ) { if ( ! is_array( $handler ) || ! isset( $handler['callback'] ) ) { continue; } if ( self::is_namespace_index( $handler['callback'] ) ) { continue; } $slug = self::component_for_callback( $handler['callback'] ); $owners[ $namespace ][ '' === $slug ? '?' : $slug ] = true; } } $out = array(); foreach ( $owners as $namespace => $by_slug ) { if ( 1 !== count( $by_slug ) ) { continue; } $slug = (string) array_key_first( $by_slug ); if ( '' === $slug || '?' === $slug ) { continue; } $out[ $slug ][] = (string) $namespace; } return $out; } /** * Whether a handler is core's auto-registered namespace index route. * * WordPress registers `GET /` for EVERY namespace, handled by * WP_REST_Server::get_namespace_index, which lives in wp-includes and therefore * attributes to no plugin. Counting it as an owner made every single namespace * multi-owned, so the single-owner rule rejected all of them and REST shielding * could never offer anything at all. * * Found on the live site, not by a test: the enumeration reported five ajax actions * and ZERO namespaces, and every namespace on the install (including this plugin's * own, and Akismet's, where every other route attributed correctly) carried exactly * one unattributable handler, always this one. The route table the tests used was * hand-written and contained no index route, which is not a shape any real * WordPress install produces. * * Narrow on purpose: the method name alone is not enough, because a plugin may * define a class with that method. The object must actually be the core server. * * @since 1.10.778 * @param mixed $cb Route callback. * @return bool */ private static function is_namespace_index( $cb ): bool { return is_array( $cb ) && isset( $cb[0], $cb[1] ) && is_object( $cb[0] ) && class_exists( 'WP_REST_Server' ) && $cb[0] instanceof WP_REST_Server && 'get_namespace_index' === $cb[1]; } /** * The namespace part of a registered REST route. * * A namespace is the first two path segments (`myplugin/v1`) for everything WordPress * registers through register_rest_route(), and the namespace index route itself is * exactly those two segments. A single segment route is returned as is rather than * discarded, because a plugin may register one and dropping it would silently under * report that plugin's surface. * * @since 1.10.776 * @param string $route Route pattern, with a leading slash. * @return string */ private static function namespace_from_route( string $route ): string { $route = trim( $route, '/' ); if ( '' === $route ) { return ''; // The API index, owned by core. } $parts = explode( '/', $route ); if ( count( $parts ) >= 2 ) { return $parts[0] . '/' . $parts[1]; } return $parts[0]; } /** * Resolves a callback to the plugin or theme directory that defines it. * * Returns '' when attribution is not possible, and '' for anything defined outside * a plugin or theme directory. That distinction is the point: CSDT_Perf_Monitor's * equivalent returns the string 'WordPress Core' for both "this is core" and "this * could not be resolved", which is fine for a performance table a human reads and * wrong here, where the two answers lead to different decisions about whether to * refuse traffic. * * @since 1.10.776 * @param callable|mixed $cb Callback of any shape. * @return string Component slug, or '' when unattributable. */ public static function component_for_callback( $cb ): string { $file = ''; try { if ( $cb instanceof Closure ) { $rf = new ReflectionFunction( $cb ); $file = (string) $rf->getFileName(); } elseif ( is_string( $cb ) && function_exists( $cb ) ) { $rf = new ReflectionFunction( $cb ); $file = (string) $rf->getFileName(); } elseif ( is_array( $cb ) && 2 === count( $cb ) ) { $class = is_object( $cb[0] ) ? get_class( $cb[0] ) : (string) $cb[0]; $method = (string) ( $cb[1] ?? '' ); if ( '' !== $method && method_exists( $class, $method ) ) { $rm = new ReflectionMethod( $class, $method ); $file = (string) $rm->getFileName(); } } elseif ( is_object( $cb ) && method_exists( $cb, '__invoke' ) ) { $rm = new ReflectionMethod( $cb, '__invoke' ); $file = (string) $rm->getFileName(); } } catch ( \Throwable $e ) { return ''; } if ( '' === $file ) { return ''; } return self::component_from_file( function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $file ) : $file ); } /** * Maps an absolute file path to a plugin slug, or a theme slug prefixed `theme:`. * * @since 1.10.776 * @param string $file Normalised absolute path. * @return string Slug, or '' when the file is not inside a plugin or theme. */ private static function component_from_file( string $file ): string { if ( '' === $file ) { return ''; } $slug = self::slug_under( $file, CSDT_Paths::plugins_dir() ); if ( '' !== $slug ) { return $slug; } if ( function_exists( 'get_theme_root' ) ) { $slug = self::slug_under( $file, get_theme_root() ); if ( '' !== $slug ) { return 'theme:' . $slug; } } return ''; } /** * The first path segment of $file relative to $root, or '' if it is not under it. * * BOTH the configured root and its resolved real path are tried, because reflection * reports the file it actually opened. A symlinked plugin directory is an ordinary * development arrangement (and on macOS the system temp directory is itself a * symlink), and there the two disagree: the configured plugins directory says /var/x, reflection says * /private/var/x, and a plain prefix test attributes nothing. * * That failure is silent and it fails in the safe direction, which is precisely what * makes it worth handling explicitly: no attribution means no surface, which means * the panel offers no shield and reports the component as having no attributable * endpoints. The site would simply never be offered protection, and nothing on * screen would say why. * * @since 1.10.776 * @param string $file Normalised absolute file path. * @param string $root Directory to test against. * @return string */ private static function slug_under( string $file, string $root ): string { $roots = array( $root ); $real = realpath( $root ); if ( is_string( $real ) && '' !== $real ) { $roots[] = $real; } foreach ( $roots as $candidate ) { $candidate = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $candidate ) : str_replace( '\\', '/', $candidate ); $candidate = rtrim( (string) $candidate, '/' ); if ( '' === $candidate ) { continue; } if ( 0 === strpos( $file, $candidate . '/' ) ) { $relative = substr( $file, strlen( $candidate ) + 1 ); $slug = explode( '/', $relative )[0] ?? ''; if ( '' !== $slug ) { return (string) $slug; } } } return ''; } // ── Shield state ───────────────────────────────────────────────────── /** * The shields currently refusing traffic. * * @since 1.10.776 * @return array> */ public static function active(): array { $stored = get_option( self::OPTION_ACTIVE, array() ); return is_array( $stored ) ? $stored : array(); } /** * Whether every shield is currently suspended by the master switch. * * @since 1.10.776 * @return bool */ public static function is_suspended(): bool { return '1' === (string) get_option( self::OPTION_SUSPENDED, '' ); } /** * Suspends or resumes every shield at once, keeping their configuration. * * @since 1.10.776 * @param bool $suspended Whether shields should stop refusing. */ public static function set_suspended( bool $suspended ): void { if ( $suspended ) { update_option( self::OPTION_SUSPENDED, '1', true ); } else { // Deleted rather than set to '0', so the stored state has exactly two // shapes (absent, or '1') instead of three that all have to agree. delete_option( self::OPTION_SUSPENDED ); } $count = count( self::active() ); self::audit( $suspended ? 'shields_suspended' : 'shields_resumed', '', $suspended ? sprintf( /* translators: %d: number of shields suspended. */ __( 'All shields suspended by an administrator. %d shield(s) stopped refusing but were not lifted.', 'cloudscale-cyber-devtools' ), $count ) : sprintf( /* translators: %d: number of shields resumed. */ __( 'Shields resumed by an administrator. %d shield(s) are refusing again.', 'cloudscale-cyber-devtools' ), $count ) ); } /** * Why a shield cannot be enabled for this component, or '' when it can. * * Pure, so every refusal is testable without touching an option. A refusal is * returned as text for the administrator rather than as a boolean, because a * refusal nobody can act on is the fault this workstream found in its own first * attempt at the firewall panel. * * @since 1.10.776 * @param string $slug Component slug. * @param array $candidates Vulnerable components. * @param array $surfaces Enumerated surfaces. * @param array $active Currently active shields. * @return string Refusal text, or '' when the shield may be enabled. */ public static function validate_enable( string $slug, array $candidates, array $surfaces, array $active ): string { if ( '' === $slug ) { return __( 'No component was named.', 'cloudscale-cyber-devtools' ); } if ( ! isset( $candidates[ $slug ] ) ) { // Only a concrete version range match from the feed may be shielded. An // AI derived or inferred finding never reaches this list, by design. return __( 'That component is not on the list of components the vulnerability feed matched. A shield is only offered for a concrete version range match.', 'cloudscale-cyber-devtools' ); } if ( isset( $active[ $slug ] ) ) { return __( 'A shield is already active for that component.', 'cloudscale-cyber-devtools' ); } if ( count( $active ) >= self::MAX_SHIELDS ) { return __( 'The maximum number of active shields has been reached.', 'cloudscale-cyber-devtools' ); } $surface = isset( $surfaces[ $slug ] ) && is_array( $surfaces[ $slug ] ) ? $surfaces[ $slug ] : null; if ( null === $surface ) { return __( 'No surface enumeration has run for that component yet. Run "Re-scan surfaces" first.', 'cloudscale-cyber-devtools' ); } $ajax = isset( $surface['ajax'] ) && is_array( $surface['ajax'] ) ? $surface['ajax'] : array(); $rest = isset( $surface['rest'] ) && is_array( $surface['rest'] ) ? $surface['rest'] : array(); if ( empty( $ajax ) && empty( $rest ) ) { // The single most important refusal here. Enabling a shield with nothing to // refuse would put a control on screen reading "protected" while doing // nothing at all, which is the exact failure this workstream keeps finding. return __( 'No unauthenticated endpoints could be attributed to that component, so a shield would refuse nothing. Nothing has been enabled.', 'cloudscale-cyber-devtools' ); } return ''; } /** * Enables a shield. * * @since 1.10.776 * @param string $slug Component slug. * @return string Refusal text, or '' on success. */ public static function enable( string $slug ): string { $candidates = self::candidates(); $surfaces = self::surfaces(); $active = self::active(); $refusal = self::validate_enable( $slug, $candidates, $surfaces, $active ); if ( '' !== $refusal ) { return $refusal; } $candidate = $candidates[ $slug ]; $surface = $surfaces[ $slug ]; $active[ $slug ] = array( 'slug' => $slug, 'name' => (string) ( $candidate['name'] ?? $slug ), 'type' => (string) ( $candidate['type'] ?? 'plugin' ), 'ajax' => array_values( (array) ( $surface['ajax'] ?? array() ) ), 'rest' => array_values( (array) ( $surface['rest'] ?? array() ) ), 'fixed_in' => (string) ( $candidate['fixed_in'] ?? '' ), 'cve_ids' => array_values( (array) ( $candidate['cve_ids'] ?? array() ) ), 'severity' => (string) ( $candidate['severity'] ?? '' ), 'version_at' => (string) ( $candidate['version'] ?? '' ), 'enabled_at' => time(), ); update_option( self::OPTION_ACTIVE, $active, true ); self::audit( 'shield_enabled', $slug, sprintf( /* translators: 1: number of ajax actions, 2: number of REST namespaces. */ __( 'Shield enabled: %1$d ajax actions and %2$d REST namespaces now refuse anonymous requests.', 'cloudscale-cyber-devtools' ), count( $active[ $slug ]['ajax'] ), count( $active[ $slug ]['rest'] ) ) ); return ''; } /** * Lifts a shield. * * @since 1.10.776 * @param string $slug Component slug. * @param string $reason Why, for the audit trail. * @return bool Whether a shield was actually lifted. */ public static function lift( string $slug, string $reason = '' ): bool { $active = self::active(); if ( ! isset( $active[ $slug ] ) ) { return false; } unset( $active[ $slug ] ); update_option( self::OPTION_ACTIVE, $active, true ); self::audit( 'shield_lifted', $slug, '' !== $reason ? $reason : __( 'Shield lifted by an administrator.', 'cloudscale-cyber-devtools' ) ); return true; } /** * Writes an admin audit line, if the activity log is present. * * @since 1.10.776 * @param string $event Event key. * @param string $object Component slug. * @param string $detail Human readable detail. */ private static function audit( string $event, string $object, string $detail ): void { try { if ( is_callable( array( 'CSDT_Activity_Log', 'record' ) ) ) { CSDT_Activity_Log::record( $event, $object, $detail ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } // ── Enforcement ────────────────────────────────────────────────────── /** * Hooked to `plugins_loaded` priority 5. * * After the IP blocklist (1), country rules (2), the request firewall (3) and IP * reputation (4). Appended to the end of an established chain rather than inserted * into it: every layer above is already proven in that order, and a shield is the * narrowest decision of the five, applying to one named component's endpoints * rather than to the request as a whole. * * Priority 5 is early enough. Plugin FILES have been included by the time * `plugins_loaded` fires, but an ajax action is not dispatched until * admin-ajax.php calls do_action( 'wp_ajax_nopriv_' . $action ), and a REST route * is not dispatched until rest_api_init has run and the server matches it. Both * happen well after this, so the vulnerable handler never executes. * * Fails OPEN on any throw, like the firewall: a shield that 500s the site is worse * than a shield that misses a request. * * @since 1.10.776 */ public static function enforce(): void { try { if ( self::$enforced ) { return; } $active = self::active(); if ( empty( $active ) ) { return; // One autoloaded option read on the common path, and nothing else. } self::$enforced = true; // Read only once there is something to suspend, so an install with no // shields does not pay for the master switch at all. if ( self::is_suspended() ) { return; } if ( self::is_exempt() ) { return; } $slug = self::shielded_slug_for_request( $active ); if ( '' === $slug ) { return; } self::refuse( $slug ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Second gate, on `rest_pre_dispatch`. * * enforce() matches a REST namespace against the request path, which covers the two * shapes WordPress itself produces (a `/wp-json/` prefixed path and `?rest_route=`). * This runs once the server has parsed the route for real, so a routing arrangement * that produces neither shape cannot slip past. It costs one array lookup, and only * on REST requests. * * Returns the untouched $result on every path that does not refuse, so a filter that * declines to act cannot alter a response. * * @since 1.10.776 * @param mixed $result Existing short circuit result. * @param mixed $server REST server. * @param mixed $request Request object. * @return mixed */ public static function rest_gate( $result, $server = null, $request = null ) { try { $active = self::active(); if ( empty( $active ) || self::is_suspended() || self::is_exempt() ) { return $result; } $route = is_object( $request ) && method_exists( $request, 'get_route' ) ? (string) $request->get_route() : ''; if ( '' === $route ) { return $result; } $slug = self::slug_for_rest_route( $active, $route ); if ( '' === $slug ) { return $result; } self::refuse( $slug ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } return $result; } /** * The component whose shield this request trips, or ''. * * @since 1.10.776 * @param array $active Active shields. * @return string */ public static function shielded_slug_for_request( array $active ): string { $path = self::request_path(); // admin-ajax.php: WordPress reads the action out of $_REQUEST, so this must too, // or a shield could be walked around by moving the action between GET and POST. if ( '' !== $path && preg_match( '#/admin-ajax\.php$#', $path ) ) { $action = (string) CSDT_Request::routing_text( 'action', 'request' ); if ( '' !== $action ) { foreach ( $active as $slug => $shield ) { $actions = isset( $shield['ajax'] ) && is_array( $shield['ajax'] ) ? $shield['ajax'] : array(); if ( in_array( $action, $actions, true ) ) { return (string) $slug; } } } } $route = self::rest_route_from_request( $path ); if ( '' !== $route ) { return self::slug_for_rest_route( $active, $route ); } return ''; } /** * The component owning a shielded namespace that this route falls under, or ''. * * Matched on a segment boundary: a shield on `acme/v1` must refuse * `acme/v1/anything` and must NOT refuse `acme/v10`, which belongs to nobody in * particular and may well be another plugin. * * @since 1.10.776 * @param array $active Active shields. * @param string $route Route, with or without a leading slash. * @return string */ public static function slug_for_rest_route( array $active, string $route ): string { $route = trim( $route, '/' ); if ( '' === $route ) { return ''; } foreach ( $active as $slug => $shield ) { $spaces = isset( $shield['rest'] ) && is_array( $shield['rest'] ) ? $shield['rest'] : array(); foreach ( $spaces as $namespace ) { $namespace = trim( (string) $namespace, '/' ); if ( '' === $namespace ) { continue; } if ( $route === $namespace || 0 === strpos( $route, $namespace . '/' ) ) { return (string) $slug; } } } return ''; } /** * The REST route this request addresses, or '' when it is not a REST request. * * @since 1.10.776 * @param string $path Request path, query string already removed. * @return string */ private static function rest_route_from_request( string $path ): string { $route = CSDT_Request::routing_text( 'rest_route' ); if ( null !== $route ) { return trim( $route, '/' ); } if ( '' === $path ) { return ''; } $prefix = function_exists( 'rest_get_url_prefix' ) ? (string) rest_get_url_prefix() : 'wp-json'; $prefix = trim( $prefix, '/' ); if ( '' === $prefix ) { return ''; } $needle = '/' . $prefix . '/'; $pos = strpos( $path, $needle ); if ( false === $pos ) { // The namespace index, `/wp-json`, addresses no namespace of its own. return ''; } return trim( substr( $path, $pos + strlen( $needle ) ), '/' ); } /** * The current request path, with the query string removed. * * Split on the raw string before any decoding, so an encoded `%3F` cannot be * decoded into a `?` and cut the path short. That is the same fault, and the same * fix, as CSDT_Firewall::path_only(). * * @since 1.10.776 * @return string */ private static function request_path(): string { // plugins_loaded runs before wp_magic_quotes(), so $unslash is false. Split on a // literal and compared against stored names; never output or stored. $uri = CSDT_Request::server( 'REQUEST_URI', 8192, false ); if ( '' === $uri ) { return ''; } $cut = strcspn( $uri, '?#' ); return substr( $uri, 0, $cut ); } /** * Whether this request is exempt from every shield. * * @since 1.10.776 * @return bool */ private static function is_exempt(): bool { if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return true; } // The whole semantic of a shield is "require authentication for this component's // anonymous entry points", so any signed in user passes. Note that a signed in // user reaching a nopriv action is unusual (WordPress dispatches wp_ajax_ for // them, not wp_ajax_nopriv_) but it is not impossible, and refusing them would // be breakage with no security benefit at all. if ( self::is_authenticated() ) { return true; } // A request the site made to itself, including the loopback that drives cron. // A plugin that calls its own REST route server side would otherwise be refused // by its own shield. $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; if ( '' === $ip ) { return true; // No resolvable peer: fail inert, as every layer here does. } if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return true; // Private, reserved or loopback. } if ( class_exists( 'CSDT_Login' ) && in_array( $ip, CSDT_Login::own_public_ips(), true ) ) { return true; } return false; } /** * Whether a valid signed in cookie is present. * * Fails OPEN when the pluggable layer is missing, so a future load order change * degrades to "a shielded endpoint was reachable" rather than "every visitor is * refused". The asymmetry is deliberate and matches * CSDT_Login::request_is_site_admin(). * * @since 1.10.776 * @return bool */ private static function is_authenticated(): bool { if ( class_exists( 'CSDT_Firewall' ) && ! CSDT_Firewall::has_auth_cookie() ) { return false; // Anonymous, the common and cheap path. } if ( ! function_exists( 'wp_validate_auth_cookie' ) ) { return true; // Fails open, deliberately. See the docblock. } return (bool) wp_validate_auth_cookie( '', 'logged_in' ); } /** * Records the refusal and ends the request with a 403. * * @since 1.10.776 * @param string $slug Component slug. */ private static function refuse( string $slug ): void { self::record( $slug ); // Set here as well: core's XML-RPC die handler never sets a status, so xmlrpc.php would answer 200. status_header( 403 ); wp_die( esc_html__( 'Access denied.', 'cloudscale-cyber-devtools' ), '', array( 'response' => 403 ) ); } /** * Writes the refusal to the shared firewall log. * * Its OWN try/catch, not enforce()'s. Recording runs before the refusal so that a * refused request is recorded, and that ordering once meant a throw in the recorder * reached the caller's catch and the request was never refused at all. Observability * silently disabling the control it observes: this workstream has shipped that bug * once already and the test suite asserts it cannot come back. * * @since 1.10.776 * @param string $slug Component slug. */ private static function record( string $slug ): void { try { if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { return; } $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; if ( '' === $ip ) { return; } if ( is_callable( array( 'CSDT_Storage', 'record_match' ) ) ) { CSDT_Storage::record_match( $ip, self::RULE_PREFIX . $slug, 'blocked' ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } // ── Automatic lift ─────────────────────────────────────────────────── /** * Lifts any shield whose reason for existing has gone. * * Three reasons, all of which mean the shield is now pure breakage: * * 1. the installed version has reached the version the finding was fixed in; * 2. the component is no longer installed; * 3. the component is no longer active, so its endpoints are not registered. * * A shield with no `fixed_in` is never lifted automatically. The feed did not say * which version fixes it, so nothing here can know that it has been fixed, and * guessing in the direction of "probably safe now" is the wrong guess for a * security control. It stays until an administrator lifts it. * * @since 1.10.776 * @return array Slugs lifted. */ public static function maybe_lift_all(): array { $active = self::active(); if ( empty( $active ) ) { return array(); } $lifted = array(); foreach ( $active as $slug => $shield ) { $reason = self::lift_reason( (string) $slug, is_array( $shield ) ? $shield : array() ); if ( '' === $reason ) { continue; } if ( self::lift( (string) $slug, $reason ) ) { $lifted[] = (string) $slug; } } if ( ! empty( $lifted ) ) { update_option( self::OPTION_LAST_LIFT, array( 'at' => time(), 'slugs' => $lifted, ), false ); } return $lifted; } /** * Why a shield should lift itself now, or '' to keep it. * * Pure apart from reading the installed component list, so the version comparison * is testable directly. * * @since 1.10.776 * @param string $slug Component slug. * @param array $shield Active shield record. * @return string */ public static function lift_reason( string $slug, array $shield ): string { $installed = self::installed_version( $slug, (string) ( $shield['type'] ?? 'plugin' ) ); if ( null === $installed ) { return __( 'Shield lifted automatically: the component is no longer installed or active.', 'cloudscale-cyber-devtools' ); } $fixed_in = (string) ( $shield['fixed_in'] ?? '' ); if ( '' === $fixed_in || '' === $installed ) { return ''; } if ( version_compare( $installed, $fixed_in, '>=' ) ) { return sprintf( /* translators: 1: installed version, 2: version the vulnerability was fixed in. */ __( 'Shield lifted automatically: version %1$s is installed, and the vulnerability was fixed in %2$s.', 'cloudscale-cyber-devtools' ), $installed, $fixed_in ); } return ''; } /** * The installed version of a component, or null when it is absent or inactive. * * Null and empty string are different answers and are kept different: null means * "not there", '' means "there, but the version could not be read". Only the first * lifts a shield. * * @since 1.10.776 * @param string $slug Component slug, possibly `theme:` prefixed. * @param string $type 'plugin' or 'theme'. * @return string|null */ private static function installed_version( string $slug, string $type ): ?string { if ( 0 === strpos( $slug, 'theme:' ) || 'theme' === $type ) { $theme_slug = 0 === strpos( $slug, 'theme:' ) ? substr( $slug, strlen( 'theme:' ) ) : $slug; if ( ! function_exists( 'wp_get_theme' ) ) { return ''; } $theme = wp_get_theme( $theme_slug ); if ( ! is_object( $theme ) || ! $theme->exists() ) { return null; } return (string) $theme->get( 'Version' ); } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! function_exists( 'get_plugins' ) ) { return ''; // Cannot read: not a reason to lift. } $active_only = function_exists( 'get_option' ) ? (array) get_option( 'active_plugins', array() ) : array(); foreach ( get_plugins() as $file => $data ) { if ( explode( '/', (string) $file )[0] !== $slug ) { continue; } // Installed but deactivated registers no hooks, so its shield refuses // endpoints that no longer exist. if ( ! in_array( $file, $active_only, true ) ) { return null; } return (string) ( $data['Version'] ?? '' ); } return null; } // ── AJAX ───────────────────────────────────────────────────────────── /** * Enables or lifts a shield. * * A valid nonce proves the request came from a page, not that the user may change * a security setting, so the capability is checked as well. * * @since 1.10.776 */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $slug = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) ); $action = sanitize_text_field( wp_unslash( $_POST['shield_action'] ?? '' ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } // Outside the try, as in CSDT_Firewall::ajax_save(): a refusal here is the guard // working as designed, and the catch all must not rewrite it into "Internal error". if ( 'lift' === $action ) { if ( ! self::lift( $slug ) ) { wp_send_json_error( __( 'No shield was active for that component.', 'cloudscale-cyber-devtools' ) ); return; } wp_send_json_success( array( 'msg' => __( 'Shield lifted. That component\'s endpoints are reachable again.', 'cloudscale-cyber-devtools' ) ) ); return; } if ( 'suspend' === $action || 'resume' === $action ) { $suspend = 'suspend' === $action; self::set_suspended( $suspend ); $count = count( self::active() ); wp_send_json_success( array( 'msg' => $suspend ? sprintf( /* translators: %d: number of shields suspended. */ _n( '%d shield suspended. It is still configured and will refuse again when you resume.', '%d shields suspended. They are still configured and will refuse again when you resume.', $count, 'cloudscale-cyber-devtools' ), $count ) : sprintf( /* translators: %d: number of shields resumed. */ _n( '%d shield is refusing again.', '%d shields are refusing again.', $count, 'cloudscale-cyber-devtools' ), $count ), ) ); return; } if ( 'enable' !== $action ) { wp_send_json_error( __( 'Unrecognised action.', 'cloudscale-cyber-devtools' ) ); return; } $refusal = self::enable( $slug ); if ( '' !== $refusal ) { wp_send_json_error( $refusal ); return; } $active = self::active(); wp_send_json_success( array( 'msg' => sprintf( /* translators: 1: number of ajax actions, 2: number of REST namespaces. */ __( 'Shield enabled: %1$d ajax actions and %2$d REST namespaces now refuse anonymous requests.', 'cloudscale-cyber-devtools' ), count( (array) ( $active[ $slug ]['ajax'] ?? array() ) ), count( (array) ( $active[ $slug ]['rest'] ?? array() ) ) ), ) ); } /** * Re-enumerates every component's unauthenticated surface. * * @since 1.10.776 */ public static function ajax_enumerate(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $surfaces = self::enumerate_surfaces(); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } $ajax = 0; $rest = 0; foreach ( $surfaces as $surface ) { $ajax += count( (array) ( $surface['ajax'] ?? array() ) ); $rest += count( (array) ( $surface['rest'] ?? array() ) ); } wp_send_json_success( array( 'msg' => sprintf( /* translators: 1: number of components, 2: ajax actions, 3: REST namespaces. */ __( 'Scanned: %1$d components with an unauthenticated surface, %2$d ajax actions and %3$d REST namespaces attributed.', 'cloudscale-cyber-devtools' ), count( $surfaces ), $ajax, $rest ), ) ); } /** * Tests one component's endpoints from outside, as an anonymous visitor. * * The point of this button is to answer "what will my visitors get" before the * administrator commits, and after. It makes a real unauthenticated loopback * request, because the only way to know whether a shield refuses is to be refused * by it. Cookies are deliberately not sent. * * @since 1.10.776 */ public static function ajax_test(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $slug = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) ); $surfaces = self::surfaces(); $active = self::active(); $surface = $active[ $slug ] ?? ( $surfaces[ $slug ] ?? null ); if ( ! is_array( $surface ) ) { wp_send_json_error( __( 'Nothing has been enumerated for that component yet.', 'cloudscale-cyber-devtools' ) ); return; } $results = array(); $actions = array_slice( (array) ( $surface['ajax'] ?? array() ), 0, 3 ); foreach ( $actions as $action ) { $results[] = self::probe( add_query_arg( 'action', rawurlencode( (string) $action ), admin_url( 'admin-ajax.php' ) ), 'admin-ajax.php?action=' . (string) $action ); } $spaces = array_slice( (array) ( $surface['rest'] ?? array() ), 0, 3 ); foreach ( $spaces as $namespace ) { $results[] = self::probe( rest_url( trailingslashit( (string) $namespace ) ), (string) $namespace ); } if ( empty( $results ) ) { wp_send_json_error( __( 'That component has no unauthenticated endpoints to test.', 'cloudscale-cyber-devtools' ) ); return; } wp_send_json_success( array( 'shielded' => isset( $active[ $slug ] ), 'results' => $results, ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } /** * One anonymous loopback probe. * * @since 1.10.776 * @param string $url Absolute URL. * @param string $label What to show for it. * @return array */ private static function probe( string $url, string $label ): array { $response = wp_remote_get( $url, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 10, 'redirection' => 0, 'cookies' => array(), 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); if ( is_wp_error( $response ) ) { return array( 'label' => $label, 'status' => 0, // CSDT_Error_Text::in_seconds(), not the raw message: WordPress's // HTTP layer reports a curl timeout in milliseconds, so the 10-second // ceiling set above would be shown to the administrator as "10000 // milliseconds" and match nothing they can find in the settings. // // No class_exists() fallback. The class is required unconditionally by // the main plugin file, and a fallback branch here would render the raw // milliseconds message on a path nothing ever takes, which is both the // bug this gate exists to catch and a branch no test could reach. 'note' => CSDT_Error_Text::in_seconds( $response->get_error_message() ), ); } return array( 'label' => $label, 'status' => (int) wp_remote_retrieve_response_code( $response ), 'note' => '', ); } // ── Panel ──────────────────────────────────────────────────────────── /** * Renders the Vulnerable Component Shields panel, on the Firewall tab after the * IP Reputation panel. * * @since 1.10.776 */ public static function render_panel(): void { $candidates = self::candidates(); $surfaces = self::surfaces(); $active = self::active(); $counts = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::match_counts() : null; $log_broken = null === $counts; $last_lift = get_option( self::OPTION_LAST_LIFT, array() ); $scanned = get_option( self::OPTION_CANDIDATES, null ); $suspended = self::is_suspended(); ?>
🛡️ 'What a shield is, and what it is not', 'rec' => 'Read this first', 'html' => 'A shield is not a patch. It does not fix the vulnerability and it does not know what the exploit looks like. The vulnerability feed reports that a plugin you have installed, at the version you have installed, has a known unpatched flaw. It does not report which URL or which parameter is the vulnerable one, so nothing here can filter for that specific attack.

What a shield does instead is close that one plugin\'s anonymous entry points: the AJAX actions it registers for logged out visitors, and the REST routes it owns. Signed in users are never affected. It is a blunt instrument used deliberately, for a short time, on a component you already know is vulnerable.', ), array( 'name' => 'It can break a front end feature', 'rec' => 'Honest limits', 'html' => 'If the vulnerable plugin uses those endpoints to power something your visitors actually use, a contact form, a live search, a booking widget, then shielding it will stop that working for logged out visitors. That is the whole trade and there is no version of it where the endpoint is both refused and available.

This is why nothing is enabled for you. Each shield lists exactly which endpoints it will refuse before you turn it on, the Test endpoints button shows you what an anonymous visitor gets right now, and Lift undoes it immediately.', ), array( 'name' => 'Only endpoints that belong to that plugin alone', 'rec' => 'How it decides', 'html' => 'Every AJAX handler and REST route is traced back to the file that defines it, and from there to the plugin directory it lives in. An endpoint is only offered for shielding when every handler behind it belongs to the vulnerable plugin.

An action shared with a second plugin, or one this cannot trace at all, is left out and reported as not shieldable, because refusing it would break the other owner in order to protect this one. If a component shows no shieldable endpoints, a shield is not offered for it at all rather than being offered and doing nothing.', ), array( 'name' => 'It lifts itself when you update', 'rec' => 'What happens next', 'html' => 'When the feed said which version fixes the flaw, the shield lifts itself as soon as that version is installed, and records why in the activity log. It also lifts if the plugin is deactivated or removed.

If the feed did not say which version fixes it, the shield stays until you lift it by hand. Nothing here will guess that a vulnerability has been fixed.', ), array( 'name' => 'If something breaks, stop everything first', 'rec' => 'Recovery', 'html' => 'Suspend all shields stops every shield refusing at once, without lifting any of them. Your configuration is kept, so you can narrow down which component was responsible and then resume, rather than rebuilding the set from scratch.

While shields are suspended the panel says so, and each one reads SUSPENDED rather than ACTIVE, because a green badge next to an endpoint that is currently open would be the panel lying to you. Lift is the other option and it is permanent for that component: it removes the shield rather than pausing it.', ), array( 'name' => 'What is recorded', 'rec' => 'Privacy', 'html' => 'A refusal records the visitor\'s address, the component slug and a truncated path in the same firewall log the request firewall and IP reputation use, so refusals appear in Recent Matches on this tab. The query string and the request body are never stored, here or anywhere in this workstream.', ), ) ); ?>

$candidate ) : ?>






—