diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index f3897a6..e059e2f 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -491,6 +491,22 @@

+ + +

+ +

+ +

+ diff --git a/changelog.txt b/changelog.txt index d46f512..a23c8c0 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ *** WebDecoy Bot Detection Changelog *** = Unreleased = +* Added: per-path crawler rules set in WebDecoy Cloud now apply in WordPress too. If your site is connected and you have told WebDecoy to refuse, say, AI training crawlers on /premium/*, this plugin refuses them there as well, using the same rule the WebDecoy edge sensor uses. Watched paths and sites in Monitor count what would have been refused instead of refusing. The rules are read from WebDecoy twice daily and shown under the AI Crawlers setting; a copy older than two days is not applied. Precedence is simple: cloud rules can only refuse, never allow; Block AI crawlers still refuses site-wide; the custom allowlist exempts a bot from Block AI crawlers but not from a cloud path rule; and this plugin's monitor mode still gates every block. * Changed: known crawlers are now identified from the same crawler registry the WebDecoy dashboard and the other WebDecoy sensors use, instead of a list kept only in this plugin. A crawler is named the same thing here and in your reports. The registry knows 182 crawlers where the old list knew 54, so more search engines (Naver, Seznam, Ecosia, Mojeek and others), more AI crawlers (ByteSpider, Mistral, YouBot and others) and more link-preview, monitoring, SEO and feed services are recognised as what they are. * Changed: with Block AI crawlers on, AI agents and assistants that browse on a person's behalf (for example ChatGPT-User, Claude-User, Operator, Siri) are refused along with training crawlers, and so are the newly recognised AI crawlers above. ChatGPT-User and OAI-SearchBot are now classified as OpenAI documents them (an assistant and a search crawler, neither used for training), which matters once per-path crawler policy arrives; under this setting they are refused either way. The setting is the one instruction about AI traffic; if you want a specific one through, add it to the custom allowlist by name. * Changed: the W3C markup and Validator.nu checkers are no longer listed as known bots. They were never treated as good bots, so requests from them are scored exactly as before; they simply no longer carry a bot name. diff --git a/includes/class-webdecoy-cloud-connect.php b/includes/class-webdecoy-cloud-connect.php index 1ee4f74..4a8724a 100644 --- a/includes/class-webdecoy-cloud-connect.php +++ b/includes/class-webdecoy-cloud-connect.php @@ -204,8 +204,10 @@ public function maybe_handle_return(): void // Single-use: burn the nonce so the token can't be replayed. delete_transient(self::NONCE_TRANSIENT); - // Pull entitlements immediately, then keep them fresh twice daily. + // Pull entitlements and the site's cloud policy immediately, then keep + // both fresh twice daily. $this->sync_entitlements(); + (new WebDecoy_Cloud_Policy())->sync(); $this->schedule_sync(); // What just happened is that credentials were stored. Whether this site @@ -271,6 +273,7 @@ public function handle_disconnect(): void webdecoy()->clear_cloud_credentials(); delete_option(self::ENTITLEMENTS_OPTION); + WebDecoy_Cloud_Policy::clear(); delete_transient(self::NONCE_TRANSIENT); wp_clear_scheduled_hook(self::CRON_HOOK); diff --git a/includes/class-webdecoy-cloud-policy.php b/includes/class-webdecoy-cloud-policy.php new file mode 100644 index 0000000..57200ca --- /dev/null +++ b/includes/class-webdecoy-cloud-policy.php @@ -0,0 +1,182 @@ + $organization_id, 'host' => $host], + self::CONFIG_ENDPOINT + ); + $response = wp_remote_get($url, [ + 'timeout' => 5, + 'headers' => ['Accept' => 'application/json'], + ]); + if (is_wp_error($response)) { + return; + } + $code = (int) wp_remote_retrieve_response_code($response); + if ($code < 200 || $code >= 300) { + return; + } + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($body) || !isset($body['mode'])) { + return; + } + update_option(self::OPTION, ['config' => self::relevant($body), 'fetched_at' => time()], false); + } + + /** + * Keep only what this plugin reads. The config also carries public keys, + * credential ids and the deny-list, which the edge needs and this plugin + * does not; storing them here would be a second copy of things that + * change without us. + * + * @param array $body + * @return array + */ + private static function relevant(array $body): array + { + $keep = ['scope', 'mode', 'routes', 'route_min_trust', 'monitor_routes', 'route_exceptions', 'route_refusals', 'generated_at']; + $out = []; + foreach ($keep as $k) { + if (array_key_exists($k, $body)) { + $out[$k] = $body[$k]; + } + } + return $out; + } + + /** + * The policy to apply on this request, or null when there is none to + * trust: not connected, never fetched, or fetched too long ago. + * + * @return array|null + */ + public static function get_policy(): ?array + { + $cached = get_option(self::OPTION, null); + if (!is_array($cached) || !is_array($cached['config'] ?? null)) { + return null; + } + $age = time() - (int) ($cached['fetched_at'] ?? 0); + if ($age > self::MAX_AGE) { + return null; + } + return $cached['config']; + } + + /** + * What the settings page says about the cloud policy: whether one is + * applied, how old it is, and what it refuses. + * + * @return array{state:string,fetched_at:int,mode:string,refusing:int,watching:int} + */ + public static function describe(): array + { + $cached = get_option(self::OPTION, null); + $config = is_array($cached) && is_array($cached['config'] ?? null) ? $cached['config'] : null; + $fetched_at = is_array($cached) ? (int) ($cached['fetched_at'] ?? 0) : 0; + if (self::organization_id() === '') { + $state = 'not_connected'; + } elseif ($config === null) { + $state = 'not_fetched'; + } elseif (time() - $fetched_at > self::MAX_AGE) { + $state = 'stale'; + } else { + $state = 'applied'; + } + $refusing = 0; + $watching = 0; + foreach ((array) ($config['route_refusals'] ?? []) as $r) { + if (!is_array($r) || empty($r['refuse_behaviors'])) { + continue; + } + if (($r['mode'] ?? '') === 'monitor') { + $watching++; + } else { + $refusing++; + } + } + return [ + 'state' => $state, + 'fetched_at' => $fetched_at, + 'mode' => (string) ($config['mode'] ?? ''), + 'refusing' => $refusing, + 'watching' => $watching, + ]; + } + + /** Drop the cached policy, on disconnect. */ + public static function clear(): void + { + delete_option(self::OPTION); + } + + private static function organization_id(): string + { + $options = get_option('webdecoy_options', []); + return is_array($options) ? (string) ($options['organization_id'] ?? '') : ''; + } +} diff --git a/includes/class-webdecoy-detector.php b/includes/class-webdecoy-detector.php index f9c3e88..fc4f865 100644 --- a/includes/class-webdecoy-detector.php +++ b/includes/class-webdecoy-detector.php @@ -51,6 +51,9 @@ public function __construct(?array $options = null) 'allow_social_bots' => $this->options['allow_social_bots'] ?? true, 'block_ai_crawlers' => $this->options['block_ai_crawlers'] ?? false, 'custom_allowlist' => $this->options['custom_allowlist'] ?? [], + // Per-path crawler refusals set in WebDecoy Cloud (#995); null + // when not connected or the cached copy is too old to trust. + 'cloud_policy' => class_exists('WebDecoy_Cloud_Policy') ? WebDecoy_Cloud_Policy::get_policy() : null, ]); } diff --git a/sdk/src/BotDetector.php b/sdk/src/BotDetector.php index 2e6af21..0d30b1c 100644 --- a/sdk/src/BotDetector.php +++ b/sdk/src/BotDetector.php @@ -150,6 +150,10 @@ class BotDetector * - block_ai_crawlers: bool (default: false) * - custom_allowlist: array of bot names (default: []) * - verify_bot_ips: bool (default: true) - Verify good bots via reverse DNS + * - cloud_policy: array|null (default: null) - The site's WebDecoy Cloud + * enforcement config (mode, protected paths, per-path crawler refusals), + * as served by the clearance config endpoint. Null when the site is + * not connected or the cached copy is too old to trust. */ public function __construct(array $options = []) { @@ -161,6 +165,7 @@ public function __construct(array $options = []) 'custom_allowlist' => [], 'verify_bot_ips' => true, 'trusted_proxies' => [], + 'cloud_policy' => null, ], $options); $this->goodBotList = new GoodBotList(); @@ -220,6 +225,31 @@ public function analyze(?array $signals = null, ?string $clientIP = null): Detec return $result; } + // A per-path crawler refusal set in WebDecoy Cloud (#995): the same + // policy the dashboard shows and the edge sensor applies, resolved + // by the same rule. It acts on the User-Agent claim, like the + // setting above, and for the same reason. The custom allowlist does + // not override it: a local exemption that opened a path the owner + // protected in the cloud would be a policy granting access by + // accident, which is the one thing a policy must not do. + $refusal = $this->cloudRefusal($botInfo, (string) ($signals['request_path'] ?? '')); + if ($refusal !== null) { + $result->addMetadata('bot_info', $botInfo); + $result->addMetadata('cloud_policy', $refusal); + if ($refusal['mode'] === 'enforce') { + $result->setIsGoodBot(false); + $result->setScore(self::SCORE_POLICY_DENIED); + $result->setScoreBreakdown(['crawler_refused' => self::SCORE_POLICY_DENIED]); + $result->setConfidence(1.0); + $result->addFlag('crawler_refused'); + $result->addMetadata('denied_by', 'cloud_policy'); + return $result; + } + // Monitoring: counted, not applied. The crawler continues as + // the good bot it is, carrying what would have happened. + $result->addFlag('crawler_would_be_refused'); + } + // Check if this bot should be allowed if ($this->shouldAllowBot($botInfo)) { // Verify the bot's IP if verification is enabled @@ -562,6 +592,48 @@ private function deniedByPolicy(array $botInfo): bool && !empty($this->options['block_ai_crawlers']); } + /** + * What the cloud policy says about this crawler on this path, or null when + * it says nothing: no policy, a path no protected pattern covers, a crawler + * of a kind the covering paths do not refuse, or a crawler the registry + * does not place in a kind at all. + * + * The returned mode is the deciding mode from route resolution: 'enforce' + * when the site enforces and a refusing path decides, 'monitor' when the + * site monitors or only watched paths cover the request. + * + * @param array $botInfo Bot information from identifyBot() + * @param string $requestPath The request URI (query string is ignored) + * @return array{mode:string,pattern:string,behavior:string,assurance:string}|null + */ + public function cloudRefusal(array $botInfo, string $requestPath): ?array + { + $policy = $this->options['cloud_policy']; + if (!is_array($policy)) { + return null; + } + $behavior = (string) ($botInfo['behavior'] ?? ''); + if ($behavior === '') { + return null; + } + $path = (string) (parse_url($requestPath, PHP_URL_PATH) ?: '/'); + $siteMode = (string) ($policy['mode'] ?? 'monitor'); + $resolution = RouteResolution::resolve($path, RouteResolution::rulesFromConfig($policy), $siteMode); + if ($resolution['deciding_mode'] === '' || !in_array($behavior, $resolution['refused_behaviors'], true)) { + return null; + } + return [ + 'mode' => $resolution['deciding_mode'], + 'pattern' => $resolution['attributed'], + 'behavior' => $behavior, + // What this plugin can say about the identity it acted on. It + // reads the User-Agent; it did not verify the source address + // before refusing, and does not need to: refusing a claim harms + // nobody who is not making it. + 'assurance' => 'claimed', + ]; + } + /** * Check if a bot should be allowed based on policies * diff --git a/sdk/src/RouteResolution.php b/sdk/src/RouteResolution.php new file mode 100644 index 0000000..c206171 --- /dev/null +++ b/sdk/src/RouteResolution.php @@ -0,0 +1,289 @@ + 2, + 'human-likely' => 1, + 'clean' => 0, + ]; + + private static function rank(?string $grade): int + { + return self::TRUST_RANK[$grade ?? ''] ?? 0; + } + + private static function grade(int $rank): string + { + if ($rank <= 0) { + return ''; + } + foreach (self::TRUST_RANK as $g => $r) { + if ($r === $rank) { + return $g; + } + } + return ''; + } + + /** + * The pattern grammar, once: a trailing "/*" covers the base and everything + * beneath it, anything else is an exact path. + */ + public static function patternMatches(string $path, string $pattern): bool + { + if (substr($pattern, -2) === '/*') { + $base = substr($pattern, 0, -2); + // "/*" is the whole site: an empty base covers every path. + if ($base === '') { + return true; + } + return $path === $base || strpos($path, $base . '/') === 0; + } + return $path === $pattern; + } + + /** Exact patterns outrank every prefix; a longer prefix outranks a shorter one. */ + private static function specificity(string $pattern): int + { + return substr($pattern, -2) === '/*' ? strlen($pattern) - 2 : 1 << 20; + } + + /** + * @param list> $rules + * @param string $siteMode The site's enforcement mode; anything but 'monitor' is enforcing. + * @return array{covering:list,deciding_mode:string,attributed:string,required_trust:string,requirement_source:string,previews:list>,excepted_by:string,refused_behaviors:list} + */ + public static function resolve(string $path, array $rules, string $siteMode): array + { + /** @var array $strictest */ + $strictest = []; + /** @var array $enforcing */ + $enforcing = []; + /** @var list $covering */ + $covering = []; + /** @var list $excepting */ + $excepting = []; + /** @var array> $refuses */ + $refuses = []; + + // The sorted union of $already and what each pattern refuses; null when empty. + $refusedUnion = static function (array $already, array $patterns) use (&$refuses): ?array { + $set = []; + foreach ($already as $b) { + $set[$b] = true; + } + foreach ($patterns as $p) { + foreach ($refuses[$p] ?? [] as $b => $_) { + $set[$b] = true; + } + } + if ($set === []) { + return null; + } + $out = array_keys($set); + sort($out, SORT_STRING); + return $out; + }; + $preview = static function (string $pattern, string $requiredTrust, ?array $refused): array { + $out = ['pattern' => $pattern, 'required_trust' => $requiredTrust]; + if ($refused !== null) { + $out['refused_behaviors'] = $refused; + } + return $out; + }; + + foreach ($rules as $r) { + $pattern = (string) ($r['pattern'] ?? ''); + if (!self::patternMatches($path, $pattern)) { + continue; + } + // An exception removes this path's coverage only. + $excepted = false; + foreach ((array) ($r['exceptions'] ?? []) as $e) { + if (self::patternMatches($path, (string) $e)) { + $excepted = true; + break; + } + } + if ($excepted) { + $excepting[] = $pattern; + continue; + } + $seen = array_key_exists($pattern, $strictest); + if (!$seen) { + $covering[] = $pattern; + } + $tr = self::rank(isset($r['min_trust']) ? (string) $r['min_trust'] : null); + if (!$seen || $tr > $strictest[$pattern]) { + $strictest[$pattern] = $tr; + } + // Existing enforcing coverage wins: one refusing definition makes the path refuse. + if (($r['mode'] ?? '') !== 'monitor') { + $enforcing[$pattern] = true; + } + foreach ((array) ($r['refuse_behaviors'] ?? []) as $b) { + $refuses[$pattern][(string) $b] = true; + } + } + + $previews = []; + if ($covering === []) { + $exceptedBy = ''; + foreach ($excepting as $p) { + if ($exceptedBy === '' || self::specificity($p) > self::specificity($exceptedBy)) { + $exceptedBy = $p; + } + } + return [ + 'covering' => [], + 'deciding_mode' => '', + 'attributed' => '', + 'required_trust' => '', + 'requirement_source' => '', + 'previews' => [], + 'excepted_by' => $exceptedBy, + 'refused_behaviors' => [], + ]; + } + + // usort is stable since PHP 8.0; on 7.4 the index tiebreak keeps it so, + // matching Go's sort.SliceStable. + $indexed = []; + foreach ($covering as $i => $p) { + $indexed[] = [$p, $i]; + } + usort($indexed, static function (array $a, array $b): int { + $d = self::specificity($b[0]) - self::specificity($a[0]); + return $d !== 0 ? $d : $a[1] - $b[1]; + }); + $covering = array_map(static function (array $x): string { + return $x[0]; + }, $indexed); + + $siteMonitors = $siteMode === 'monitor'; + $deciding = array_values(array_filter($covering, static function (string $p) use ($enforcing): bool { + return isset($enforcing[$p]); + })); + $decidingMode = $siteMonitors ? 'monitor' : 'enforce'; + if ($deciding === []) { + if (!$siteMonitors) { + // Only Monitor paths cover this request on an enforcing site, so + // nothing enforces it and each preview starts from no requirement. + foreach ($covering as $p) { + $previews[] = $preview($p, self::grade($strictest[$p] ?? 0), $refusedUnion([], [$p])); + } + } + $deciding = $covering; + $decidingMode = 'monitor'; + } + // A monitoring site keeps the same deciding paths, so its would-have-refused + // counts are what enforcing would refuse. + + $best = 0; + $source = ''; + foreach ($deciding as $p) { + $r = $strictest[$p] ?? 0; + if ($r > $best) { + $best = $r; + $source = $p; + } + } + + // Overlapping rules add up, over the same paths that set the requirement. + $refused = $refusedUnion([], $deciding) ?? []; + + if ($decidingMode === 'enforce') { + foreach ($covering as $p) { + if (isset($enforcing[$p])) { + continue; + } + $previews[] = $preview($p, self::grade(max($best, $strictest[$p] ?? 0)), $refusedUnion($refused, [$p])); + } + } + + return [ + 'covering' => $covering, + 'deciding_mode' => $decidingMode, + 'attributed' => $deciding[0], + 'required_trust' => self::grade($best), + 'requirement_source' => $source, + 'previews' => $previews, + 'excepted_by' => '', + 'refused_behaviors' => $refused, + ]; + } + + /** + * The rules a validator config carries, in the shape resolve() reads. + * + * Mirrors routeRules() in the Worker: a refusing pattern's exceptions + * belong to every definition of it; a watched path carries its own mode; + * a refusal entry is one more definition of its pattern and the resolver + * unions them. + * + * @param array $config The clearance config body + * @return list> + */ + public static function rulesFromConfig(array $config): array + { + $routes = array_values(array_filter((array) ($config['routes'] ?? []), 'is_string')); + $refusing = array_fill_keys($routes, true); + $except = []; + foreach ((array) ($config['route_exceptions'] ?? []) as $e) { + if (is_array($e) && isset($refusing[$e['pattern'] ?? '']) && is_array($e['except'] ?? null)) { + $except[(string) $e['pattern']] = array_values(array_filter($e['except'], 'is_string')); + } + } + $rules = []; + foreach ($routes as $p) { + $rules[] = ['pattern' => $p, 'exceptions' => $except[$p] ?? []]; + } + foreach ((array) ($config['route_min_trust'] ?? []) as $e) { + if (is_array($e) && isset($refusing[$e['pattern'] ?? ''])) { + $rules[] = ['pattern' => (string) $e['pattern'], 'min_trust' => (string) ($e['min_trust'] ?? ''), 'exceptions' => $except[(string) $e['pattern']] ?? []]; + } + } + foreach ((array) ($config['monitor_routes'] ?? []) as $e) { + if (is_array($e) && is_string($e['pattern'] ?? null)) { + $rules[] = ['pattern' => $e['pattern'], 'min_trust' => (string) ($e['min_trust'] ?? ''), 'mode' => 'monitor', 'exceptions' => array_values(array_filter((array) ($e['except'] ?? []), 'is_string'))]; + } + } + foreach ((array) ($config['route_refusals'] ?? []) as $e) { + if (!is_array($e) || !is_string($e['pattern'] ?? null)) { + continue; + } + $behaviors = array_values(array_filter((array) ($e['refuse_behaviors'] ?? []), 'is_string')); + if ($behaviors === []) { + continue; + } + if (($e['mode'] ?? '') === 'monitor') { + $rules[] = ['pattern' => $e['pattern'], 'mode' => 'monitor', 'refuse_behaviors' => $behaviors]; + } elseif (isset($refusing[$e['pattern']])) { + $rules[] = ['pattern' => $e['pattern'], 'exceptions' => $except[$e['pattern']] ?? [], 'refuse_behaviors' => $behaviors]; + } + } + return $rules; + } +} diff --git a/tests/CloudPolicyTest.php b/tests/CloudPolicyTest.php new file mode 100644 index 0000000..84fce88 --- /dev/null +++ b/tests/CloudPolicyTest.php @@ -0,0 +1,129 @@ + $userAgent, + 'REMOTE_ADDR' => '203.0.113.9', + 'REQUEST_URI' => $uri, + 'REQUEST_METHOD' => 'GET', + 'HTTP_ACCEPT' => '*/*', + ]; + // Like the WordPress wrapper: collected signals plus the request path. + $detector = new \WebDecoy\BotDetector($options); + $signals = $detector->getSignalCollector()->collect(); + $signals['request_path'] = $uri; + return $detector->analyze($signals); +} + +function cloud_policy_config(string $mode = 'enforce'): array +{ + return [ + 'scope' => ['basis' => 'host'], + 'mode' => $mode, + 'routes' => ['/premium/*'], + 'route_min_trust' => [], + 'monitor_routes' => [['pattern' => '/blog/*', 'min_trust' => '', 'except' => []]], + 'route_exceptions' => [['pattern' => '/premium/*', 'except' => ['/premium/free/*']]], + 'route_refusals' => [ + ['pattern' => '/premium/*', 'mode' => '', 'refuse_behaviors' => ['training']], + ['pattern' => '/blog/*', 'mode' => 'monitor', 'refuse_behaviors' => ['training']], + ], + ]; +} + +const CLOUD_GPTBOT = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)'; +const CLOUD_CHATGPT_USER = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot'; +const CLOUD_GOOGLEBOT = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'; + +echo "\nCloud policy: per-path crawler refusals\n"; + +$t('a training crawler is refused on a path that refuses training, whatever the local setting', function () use ($true, $same) { + $result = cloud_policy_analyze(CLOUD_GPTBOT, '/premium/article?utm=x', ['cloud_policy' => cloud_policy_config(), 'block_ai_crawlers' => false]); + $true($result->shouldBlock(100), 'served despite the cloud refusal'); + $same(false, $result->isGoodBot()); + $true(in_array('crawler_refused', $result->getFlags(), true), 'the reason must be visible in the log'); + $meta = $result->getMetadata(); + $same('cloud_policy', $meta['denied_by'] ?? null); + $same('/premium/*', $meta['cloud_policy']['pattern'] ?? null); + $same('training', $meta['cloud_policy']['behavior'] ?? null); + $same('claimed', $meta['cloud_policy']['assurance'] ?? null); +}); + +$t('the same crawler passes off-path and on an excepted path', function () use ($same) { + foreach (['/about', '/premium/free/sample'] as $uri) { + $result = cloud_policy_analyze(CLOUD_GPTBOT, $uri, ['cloud_policy' => cloud_policy_config()]); + $same(true, $result->isGoodBot(), $uri); + $same(false, in_array('crawler_refused', $result->getFlags(), true), $uri); + } +}); + +$t('a crawler of another kind passes on the refusing path', function () use ($same) { + foreach ([CLOUD_GOOGLEBOT, CLOUD_CHATGPT_USER] as $ua) { + $result = cloud_policy_analyze($ua, '/premium/article', ['cloud_policy' => cloud_policy_config(), 'verify_bot_ips' => false]); + $same(true, $result->isGoodBot(), $ua); + } +}); + +$t('a watched path counts the refusal and lets the crawler through', function () use ($same, $true) { + $result = cloud_policy_analyze(CLOUD_GPTBOT, '/blog/post', ['cloud_policy' => cloud_policy_config()]); + $same(true, $result->isGoodBot()); + $true(in_array('crawler_would_be_refused', $result->getFlags(), true)); + $same('monitor', $result->getMetadata()['cloud_policy']['mode'] ?? null); +}); + +$t('a monitoring site counts every refusal instead of applying it', function () use ($same, $true) { + $result = cloud_policy_analyze(CLOUD_GPTBOT, '/premium/article', ['cloud_policy' => cloud_policy_config('monitor')]); + $same(true, $result->isGoodBot()); + $true(in_array('crawler_would_be_refused', $result->getFlags(), true)); + $same('/premium/*', $result->getMetadata()['cloud_policy']['pattern'] ?? null); +}); + +$t('the custom allowlist does not open a path the cloud policy refuses', function () use ($true) { + $result = cloud_policy_analyze(CLOUD_GPTBOT, '/premium/article', ['cloud_policy' => cloud_policy_config(), 'custom_allowlist' => ['GPTBot']]); + $true($result->shouldBlock(100), 'a local exemption granted access the owner refused in the cloud'); +}); + +$t('the local setting still refuses first, site-wide', function () use ($true) { + $result = cloud_policy_analyze(CLOUD_GPTBOT, '/about', ['cloud_policy' => cloud_policy_config(), 'block_ai_crawlers' => true]); + $true(in_array('ai_crawler_blocked', $result->getFlags(), true)); +}); + +$t('no policy, no effect', function () use ($same) { + $result = cloud_policy_analyze(CLOUD_GPTBOT, '/premium/article', ['cloud_policy' => null]); + $same(true, $result->isGoodBot()); +}); diff --git a/tests/RouteResolutionTest.php b/tests/RouteResolutionTest.php new file mode 100644 index 0000000..c8bf1a2 --- /dev/null +++ b/tests/RouteResolutionTest.php @@ -0,0 +1,73 @@ + $p) { + $same($got['previews'][$i]['pattern'], $p['pattern'], "preview $i pattern"); + $same($got['previews'][$i]['required_trust'], $p['required_trust'], "preview $i required_trust"); + $same($got['previews'][$i]['refused_behaviors'] ?? null, $p['refused_behaviors'] ?? null, "preview $i refused_behaviors"); + } + }); +} + +$t('rules are read from a validator config as the Worker reads them', function () use ($same) { + $config = [ + 'routes' => ['/premium/*', '/login'], + 'route_min_trust' => [['pattern' => '/login', 'min_trust' => 'human-likely']], + 'route_exceptions' => [['pattern' => '/premium/*', 'except' => ['/premium/free/*']]], + 'monitor_routes' => [['pattern' => '/blog/*', 'min_trust' => '', 'except' => []]], + 'route_refusals' => [ + ['pattern' => '/premium/*', 'mode' => '', 'refuse_behaviors' => ['training']], + ['pattern' => '/blog/*', 'mode' => 'monitor', 'refuse_behaviors' => ['training', 'search']], + ['pattern' => '/ghost/*', 'mode' => '', 'refuse_behaviors' => ['training']], + ], + ]; + $rules = \WebDecoy\RouteResolution::rulesFromConfig($config); + $r = \WebDecoy\RouteResolution::resolve('/premium/a', $rules, 'enforce'); + $same($r['refused_behaviors'], ['training']); + $same($r['deciding_mode'], 'enforce'); + $same(\WebDecoy\RouteResolution::resolve('/premium/free/x', $rules, 'enforce')['excepted_by'], '/premium/*'); + // A watched path refuses nothing, in either site mode. + $b = \WebDecoy\RouteResolution::resolve('/blog/x', $rules, 'enforce'); + $same($b['deciding_mode'], 'monitor'); + $same($b['refused_behaviors'], ['search', 'training']); + // A refusal whose pattern is not a served route is ignored, as the Worker ignores it. + $same(\WebDecoy\RouteResolution::resolve('/ghost/x', $rules, 'enforce')['covering'], []); +}); diff --git a/tests/vectors/route_resolution_vectors.json b/tests/vectors/route_resolution_vectors.json new file mode 100644 index 0000000..ba11f0c --- /dev/null +++ b/tests/vectors/route_resolution_vectors.json @@ -0,0 +1,1499 @@ +{ + "about": "What protected paths say about one request path (#1125, #1118, #1119). The rule is written on ResolveRoute in pkg/models/enforcement_route_scope.go. Go, the Cloudflare Worker and the AWS Lambda each replay every case; change a case here and all three must agree, or CI fails. A rule's mode is \"monitor\" for a watched path and absent for one that refuses when its site enforces; a case's site_mode is absent for an enforcing site. A rule's exceptions are patterns beneath it that it does not cover; excepted_by names the one path credited when exceptions leave nothing covering the request. A rule's refuse_behaviors are the crawler behaviors that path refuses (#995); refused_behaviors is the sorted union over the deciding paths, and in monitor mode it is what would have been refused.", + "cases": [ + { + "name": "an exact pattern covers that path", + "path": "/login", + "rules": [ + { + "pattern": "/login" + } + ], + "want": { + "covering": [ + "/login" + ], + "deciding_mode": "enforce", + "attributed": "/login", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an exact pattern does not cover a trailing slash", + "path": "/login/", + "rules": [ + { + "pattern": "/login" + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an exact pattern does not cover a child path", + "path": "/login/reset", + "rules": [ + { + "pattern": "/login" + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a prefix covers its base", + "path": "/checkout", + "rules": [ + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [ + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a prefix covers its base with a trailing slash", + "path": "/checkout/", + "rules": [ + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [ + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a prefix covers paths beneath it", + "path": "/checkout/pay/confirm", + "rules": [ + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [ + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a prefix does not cover a sibling that only shares its letters", + "path": "/checkoutx", + "rules": [ + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "matching is case-sensitive", + "path": "/Checkout/pay", + "rules": [ + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "the whole-site pattern covers the root", + "path": "/", + "rules": [ + { + "pattern": "/*" + } + ], + "want": { + "covering": [ + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "the whole-site pattern covers every path, not only the root", + "path": "/a/b/c", + "rules": [ + { + "pattern": "/*" + } + ], + "want": { + "covering": [ + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "the root is not covered by a narrower prefix", + "path": "/", + "rules": [ + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "no protected paths cover nothing", + "path": "/anything", + "rules": [], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "only matching paths are covering", + "path": "/api/v1/orders", + "rules": [ + { + "pattern": "/checkout/*" + }, + { + "pattern": "/api/*" + } + ], + "want": { + "covering": [ + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a longer prefix is more specific than a shorter one", + "path": "/api/reports/export", + "rules": [ + { + "pattern": "/api/*" + }, + { + "pattern": "/api/reports/*" + } + ], + "want": { + "covering": [ + "/api/reports/*", + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/reports/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "specificity does not depend on configuration order", + "path": "/api/reports/export", + "rules": [ + { + "pattern": "/api/reports/*" + }, + { + "pattern": "/api/*" + } + ], + "want": { + "covering": [ + "/api/reports/*", + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/reports/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an exact pattern is more specific than a prefix", + "path": "/api/orders", + "rules": [ + { + "pattern": "/api/*" + }, + { + "pattern": "/api/orders" + } + ], + "want": { + "covering": [ + "/api/orders", + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/orders", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "the whole-site pattern is the least specific", + "path": "/checkout/pay", + "rules": [ + { + "pattern": "/*" + }, + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [ + "/checkout/*", + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an identical pattern at site and organization scope is one path", + "path": "/checkout/pay", + "rules": [ + { + "pattern": "/checkout/*" + }, + { + "pattern": "/checkout/*", + "min_trust": "human-likely" + } + ], + "want": { + "covering": [ + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "human-likely", + "requirement_source": "/checkout/*", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a path's own requirement applies", + "path": "/checkout/pay", + "rules": [ + { + "pattern": "/checkout/*", + "min_trust": "attested-human" + } + ], + "want": { + "covering": [ + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "attested-human", + "requirement_source": "/checkout/*", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a narrower path inherits a broader path's requirement, and says where it came from", + "path": "/api/public", + "rules": [ + { + "pattern": "/api/*", + "min_trust": "human-likely" + }, + { + "pattern": "/api/public" + } + ], + "want": { + "covering": [ + "/api/public", + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/public", + "required_trust": "human-likely", + "requirement_source": "/api/*", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "the strictest covering requirement wins over a more specific, weaker one", + "path": "/checkout/pay", + "rules": [ + { + "pattern": "/*", + "min_trust": "attested-human" + }, + { + "pattern": "/checkout/*", + "min_trust": "human-likely" + } + ], + "want": { + "covering": [ + "/checkout/*", + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "attested-human", + "requirement_source": "/*", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "when requirements tie, the most specific path is their source", + "path": "/checkout/pay", + "rules": [ + { + "pattern": "/*", + "min_trust": "human-likely" + }, + { + "pattern": "/checkout/*", + "min_trust": "human-likely" + } + ], + "want": { + "covering": [ + "/checkout/*", + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "human-likely", + "requirement_source": "/checkout/*", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a requirement on a path that does not cover the request does not apply", + "path": "/checkout/pay", + "rules": [ + { + "pattern": "/admin/*", + "min_trust": "attested-human" + }, + { + "pattern": "/checkout/*" + } + ], + "want": { + "covering": [ + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an explicit clean minimum is no requirement", + "path": "/login", + "rules": [ + { + "pattern": "/login", + "min_trust": "clean" + } + ], + "want": { + "covering": [ + "/login" + ], + "deciding_mode": "enforce", + "attributed": "/login", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an unknown grade is no requirement", + "path": "/login", + "rules": [ + { + "pattern": "/login", + "min_trust": "superhuman" + } + ], + "want": { + "covering": [ + "/login" + ], + "deciding_mode": "enforce", + "attributed": "/login", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "existing enforcing coverage wins over a Monitor path inside it", + "path": "/api/reports/export", + "rules": [ + { + "pattern": "/api/*" + }, + { + "pattern": "/api/reports/*", + "min_trust": "attested-human", + "mode": "monitor" + } + ], + "want": { + "covering": [ + "/api/reports/*", + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [ + { + "pattern": "/api/reports/*", + "required_trust": "attested-human" + } + ], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an enforcing path inside a Monitor path is enforced", + "path": "/api/orders", + "rules": [ + { + "pattern": "/api/*", + "mode": "monitor" + }, + { + "pattern": "/api/orders" + } + ], + "want": { + "covering": [ + "/api/orders", + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/orders", + "required_trust": "", + "requirement_source": "", + "previews": [ + { + "pattern": "/api/*", + "required_trust": "" + } + ], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "the rest of a Monitor path is watched", + "path": "/api/other", + "rules": [ + { + "pattern": "/api/*", + "mode": "monitor" + }, + { + "pattern": "/api/orders" + } + ], + "want": { + "covering": [ + "/api/*" + ], + "deciding_mode": "monitor", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [ + { + "pattern": "/api/*", + "required_trust": "" + } + ], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "nested Monitor paths inside an enforcing path are previewed independently", + "path": "/api/reports/export", + "rules": [ + { + "pattern": "/api/*" + }, + { + "pattern": "/api/reports/*", + "min_trust": "attested-human", + "mode": "monitor" + }, + { + "pattern": "/api/reports/export", + "mode": "monitor" + } + ], + "want": { + "covering": [ + "/api/reports/export", + "/api/reports/*", + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [ + { + "pattern": "/api/reports/export", + "required_trust": "" + }, + { + "pattern": "/api/reports/*", + "required_trust": "attested-human" + } + ], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "nested Monitor paths with no enforcing path decide together and preview alone", + "path": "/api/reports/x", + "rules": [ + { + "pattern": "/api/*", + "min_trust": "human-likely", + "mode": "monitor" + }, + { + "pattern": "/api/reports/*", + "mode": "monitor" + } + ], + "want": { + "covering": [ + "/api/reports/*", + "/api/*" + ], + "deciding_mode": "monitor", + "attributed": "/api/reports/*", + "required_trust": "human-likely", + "requirement_source": "/api/*", + "previews": [ + { + "pattern": "/api/reports/*", + "required_trust": "" + }, + { + "pattern": "/api/*", + "required_trust": "human-likely" + } + ], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a preview requires at least what is already enforced", + "path": "/checkout/pay", + "rules": [ + { + "pattern": "/checkout/*", + "min_trust": "human-likely" + }, + { + "pattern": "/checkout/pay", + "mode": "monitor" + } + ], + "want": { + "covering": [ + "/checkout/pay", + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "human-likely", + "requirement_source": "/checkout/*", + "previews": [ + { + "pattern": "/checkout/pay", + "required_trust": "human-likely" + } + ], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a monitoring site decides by the same paths as enforcing would, and previews nothing", + "path": "/api/reports/x", + "rules": [ + { + "pattern": "/api/*" + }, + { + "pattern": "/api/reports/*", + "min_trust": "attested-human", + "mode": "monitor" + } + ], + "site_mode": "monitor", + "want": { + "covering": [ + "/api/reports/*", + "/api/*" + ], + "deciding_mode": "monitor", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a monitoring site decides a request only Monitor paths cover by those paths", + "path": "/api/reports/x", + "rules": [ + { + "pattern": "/api/*", + "mode": "monitor" + }, + { + "pattern": "/api/reports/*", + "min_trust": "human-likely", + "mode": "monitor" + } + ], + "site_mode": "monitor", + "want": { + "covering": [ + "/api/reports/*", + "/api/*" + ], + "deciding_mode": "monitor", + "attributed": "/api/reports/*", + "required_trust": "human-likely", + "requirement_source": "/api/reports/*", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a pattern that refuses in one definition refuses, whatever the other says", + "path": "/checkout", + "rules": [ + { + "pattern": "/checkout/*" + }, + { + "pattern": "/checkout/*", + "mode": "monitor" + } + ], + "want": { + "covering": [ + "/checkout/*" + ], + "deciding_mode": "enforce", + "attributed": "/checkout/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a Monitor path that does not cover the request plays no part", + "path": "/blog", + "rules": [ + { + "pattern": "/api/*", + "mode": "monitor" + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an exception removes its path's coverage, and nothing else covers the request", + "path": "/api/webhooks/payments", + "rules": [ + { + "pattern": "/api/*", + "exceptions": [ + "/api/webhooks/payments" + ] + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "/api/*", + "refused_behaviors": [] + } + }, + { + "name": "a path beside the exception is still covered", + "path": "/api/orders", + "rules": [ + { + "pattern": "/api/*", + "exceptions": [ + "/api/webhooks/payments" + ] + } + ], + "want": { + "covering": [ + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an exact exception does not except the paths beneath it", + "path": "/api/webhooks/payments/retry", + "rules": [ + { + "pattern": "/api/*", + "exceptions": [ + "/api/webhooks/payments" + ] + } + ], + "want": { + "covering": [ + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a prefix exception on the whole site excepts its base and beneath", + "path": "/.well-known/acme-challenge/token", + "rules": [ + { + "pattern": "/*", + "exceptions": [ + "/.well-known/*" + ] + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "/*", + "refused_behaviors": [] + } + }, + { + "name": "an exception on one path while another path still covers the request: no exception applies", + "path": "/api/webhooks/payments", + "rules": [ + { + "pattern": "/api/*", + "exceptions": [ + "/api/webhooks/*" + ] + }, + { + "pattern": "/api/webhooks/*", + "min_trust": "human-likely" + } + ], + "want": { + "covering": [ + "/api/webhooks/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/webhooks/*", + "required_trust": "human-likely", + "requirement_source": "/api/webhooks/*", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "the same sub-path excepted by two paths: the most specific is credited", + "path": "/api/webhooks/payments", + "rules": [ + { + "pattern": "/*", + "exceptions": [ + "/api/webhooks/payments" + ] + }, + { + "pattern": "/api/*", + "exceptions": [ + "/api/webhooks/*" + ] + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "/api/*", + "refused_behaviors": [] + } + }, + { + "name": "a pattern defined twice covers the request if either definition does not except it", + "path": "/api/webhooks/payments", + "rules": [ + { + "pattern": "/api/*", + "exceptions": [ + "/api/webhooks/payments" + ] + }, + { + "pattern": "/api/*" + } + ], + "want": { + "covering": [ + "/api/*" + ], + "deciding_mode": "enforce", + "attributed": "/api/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "an excepted enforcing path leaves a Monitor path deciding the request", + "path": "/api/hooks/stripe", + "rules": [ + { + "pattern": "/api/*", + "exceptions": [ + "/api/hooks/*" + ] + }, + { + "pattern": "/api/hooks/*", + "mode": "monitor" + } + ], + "want": { + "covering": [ + "/api/hooks/*" + ], + "deciding_mode": "monitor", + "attributed": "/api/hooks/*", + "required_trust": "", + "requirement_source": "", + "previews": [ + { + "pattern": "/api/hooks/*", + "required_trust": "" + } + ], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a watched path's exception is credited like any other", + "path": "/beta/health", + "rules": [ + { + "pattern": "/beta/*", + "mode": "monitor", + "exceptions": [ + "/beta/health" + ] + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "/beta/*", + "refused_behaviors": [] + } + }, + { + "name": "an exception on a path that does not cover the request plays no part", + "path": "/about", + "rules": [ + { + "pattern": "/api/*", + "exceptions": [ + "/api/health" + ] + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "a path refuses the crawler behaviors it names", + "path": "/premium/a", + "rules": [ + { + "pattern": "/premium/*", + "refuse_behaviors": [ + "training" + ] + } + ], + "want": { + "covering": [ + "/premium/*" + ], + "deciding_mode": "enforce", + "attributed": "/premium/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [ + "training" + ] + } + }, + { + "name": "a path that names no behaviors refuses none", + "path": "/premium/a", + "rules": [ + { + "pattern": "/premium/*" + } + ], + "want": { + "covering": [ + "/premium/*" + ], + "deciding_mode": "enforce", + "attributed": "/premium/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + }, + { + "name": "overlapping enforcing paths add their refused behaviors together, sorted", + "path": "/premium/reports/q3", + "rules": [ + { + "pattern": "/premium/*", + "refuse_behaviors": [ + "training" + ] + }, + { + "pattern": "/premium/reports/*", + "refuse_behaviors": [ + "agent", + "seo" + ] + }, + { + "pattern": "/*", + "refuse_behaviors": [ + "training" + ] + } + ], + "want": { + "covering": [ + "/premium/reports/*", + "/premium/*", + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/premium/reports/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [ + "agent", + "seo", + "training" + ] + } + }, + { + "name": "a path whose exception matches contributes no refused behaviors", + "path": "/premium/free-sample", + "rules": [ + { + "pattern": "/premium/*", + "refuse_behaviors": [ + "training" + ], + "exceptions": [ + "/premium/free-sample" + ] + }, + { + "pattern": "/*", + "refuse_behaviors": [ + "seo" + ] + } + ], + "want": { + "covering": [ + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [ + "seo" + ] + } + }, + { + "name": "when exceptions leave nothing covering the request, nothing is refused", + "path": "/premium/free-sample", + "rules": [ + { + "pattern": "/premium/*", + "refuse_behaviors": [ + "training" + ], + "exceptions": [ + "/premium/free-sample" + ] + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "/premium/*", + "refused_behaviors": [] + } + }, + { + "name": "a Monitor path beside an enforcing one does not refuse; its preview carries both sets", + "path": "/premium/a", + "rules": [ + { + "pattern": "/*", + "refuse_behaviors": [ + "seo" + ] + }, + { + "pattern": "/premium/*", + "mode": "monitor", + "refuse_behaviors": [ + "training" + ] + } + ], + "want": { + "covering": [ + "/premium/*", + "/*" + ], + "deciding_mode": "enforce", + "attributed": "/*", + "required_trust": "", + "requirement_source": "", + "previews": [ + { + "pattern": "/premium/*", + "required_trust": "", + "refused_behaviors": [ + "seo", + "training" + ] + } + ], + "excepted_by": "", + "refused_behaviors": [ + "seo" + ] + } + }, + { + "name": "only a Monitor path covers: what it would refuse is reported in monitor mode, and previewed", + "path": "/premium/a", + "rules": [ + { + "pattern": "/premium/*", + "mode": "monitor", + "refuse_behaviors": [ + "training" + ] + } + ], + "want": { + "covering": [ + "/premium/*" + ], + "deciding_mode": "monitor", + "attributed": "/premium/*", + "required_trust": "", + "requirement_source": "", + "previews": [ + { + "pattern": "/premium/*", + "required_trust": "", + "refused_behaviors": [ + "training" + ] + } + ], + "excepted_by": "", + "refused_behaviors": [ + "training" + ] + } + }, + { + "name": "a monitoring site reports what enforcing would refuse, without previews", + "path": "/premium/a", + "site_mode": "monitor", + "rules": [ + { + "pattern": "/premium/*", + "refuse_behaviors": [ + "training" + ] + }, + { + "pattern": "/*", + "mode": "monitor", + "refuse_behaviors": [ + "seo" + ] + } + ], + "want": { + "covering": [ + "/premium/*", + "/*" + ], + "deciding_mode": "monitor", + "attributed": "/premium/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [ + "training" + ] + } + }, + { + "name": "a pattern defined for the site and the organization refuses what either definition refuses", + "path": "/docs", + "rules": [ + { + "pattern": "/docs", + "refuse_behaviors": [ + "training" + ] + }, + { + "pattern": "/docs", + "refuse_behaviors": [ + "agent" + ] + } + ], + "want": { + "covering": [ + "/docs" + ], + "deciding_mode": "enforce", + "attributed": "/docs", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [ + "agent", + "training" + ] + } + }, + { + "name": "a pattern defined twice, one definition excepting the request, refuses only what the covering definition refuses", + "path": "/docs/open", + "rules": [ + { + "pattern": "/docs/*", + "refuse_behaviors": [ + "training" + ], + "exceptions": [ + "/docs/open" + ] + }, + { + "pattern": "/docs/*", + "refuse_behaviors": [ + "agent" + ] + } + ], + "want": { + "covering": [ + "/docs/*" + ], + "deciding_mode": "enforce", + "attributed": "/docs/*", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [ + "agent" + ] + } + }, + { + "name": "refused behaviors and a verification requirement are independent", + "path": "/account", + "rules": [ + { + "pattern": "/account", + "min_trust": "human-likely", + "refuse_behaviors": [ + "training" + ] + } + ], + "want": { + "covering": [ + "/account" + ], + "deciding_mode": "enforce", + "attributed": "/account", + "required_trust": "human-likely", + "requirement_source": "/account", + "previews": [], + "excepted_by": "", + "refused_behaviors": [ + "training" + ] + } + }, + { + "name": "an uncovered path refuses nothing", + "path": "/about", + "rules": [ + { + "pattern": "/premium/*", + "refuse_behaviors": [ + "training" + ] + } + ], + "want": { + "covering": [], + "deciding_mode": "", + "attributed": "", + "required_trust": "", + "requirement_source": "", + "previews": [], + "excepted_by": "", + "refused_behaviors": [] + } + } + ] +} diff --git a/uninstall.php b/uninstall.php index e8e8f0b..aaa596a 100644 --- a/uninstall.php +++ b/uninstall.php @@ -47,6 +47,7 @@ delete_option('webdecoy_api_last_error'); delete_option('webdecoy_encryption_key'); delete_option('webdecoy_entitlements'); + delete_option('webdecoy_cloud_policy'); delete_option('webdecoy_actor_feed_cursor'); delete_option('webdecoy_critical_moment_last'); diff --git a/webdecoy.php b/webdecoy.php index bdbc889..0a7332c 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -71,6 +71,7 @@ function str_starts_with(string $haystack, string $needle): bool require_once $sdk_path . 'src/DetectionResult.php'; require_once $sdk_path . 'src/AgentRegistry.php'; require_once $sdk_path . 'src/GoodBotList.php'; + require_once $sdk_path . 'src/RouteResolution.php'; require_once $sdk_path . 'src/SignalCollector.php'; require_once $sdk_path . 'src/BotDetector.php'; require_once $sdk_path . 'src/Client.php'; @@ -1045,6 +1046,7 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-rate-limit-rule.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-wp-traps.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-cloud-connect.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-cloud-policy.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-actor-intel.php'; // WP-CLI surface for agency deploy scripts: wp webdecoy status|config| @@ -1061,6 +1063,11 @@ public function load_includes(): void $this->cloud_connect = new WebDecoy_Cloud_Connect(); $this->cloud_connect->register(); + // The site's cloud enforcement policy (per-path crawler refusals), + // refreshed on the entitlements cron. Read-only, public config; makes + // no request unless the site is connected. + (new WebDecoy_Cloud_Policy())->register(); + // Actor feed: hourly network-block sync. Self-guards to make no external // request unless connected AND entitled to the actor feed (Pro+). $this->actor_feed = new WebDecoy_Actor_Feed();