diff --git a/admin/css/webdecoy-admin.css b/admin/css/webdecoy-admin.css index f14b2c5..3f41061 100644 --- a/admin/css/webdecoy-admin.css +++ b/admin/css/webdecoy-admin.css @@ -495,7 +495,14 @@ /* Column Widths */ .column-date { width: 150px; } +.column-what { width: 260px; } .column-ip { width: 130px; } + +/* The plain-language decoy hit sentence leads the row (WebDecoy/app#443). */ +.webdecoy-what { + color: #1d2327; + line-height: 1.4; +} .column-score { width: 60px; } .column-level { width: 90px; } .column-source { width: 130px; } diff --git a/admin/partials/detections-page.php b/admin/partials/detections-page.php index 9382e8b..c1fe59b 100644 --- a/admin/partials/detections-page.php +++ b/admin/partials/detections-page.php @@ -284,6 +284,7 @@ class="button "> + @@ -304,6 +305,22 @@ class="button "> + + + + + — + + @@ -460,7 +477,7 @@ class="button button-small"> - +
diff --git a/includes/class-webdecoy-wp-traps.php b/includes/class-webdecoy-wp-traps.php index 2574fa3..3d17818 100644 --- a/includes/class-webdecoy-wp-traps.php +++ b/includes/class-webdecoy-wp-traps.php @@ -119,7 +119,7 @@ public function guard_author_enum(): void return; // /?author=slug is not numeric enumeration } - $this->record('tripwire', '/?author=' . $author); + $this->record('tripwire', '/?author=' . $author, 'author_lookup'); $canary = WebDecoy_Decoy_Response::canaries()['author_user'] ?? 'editor'; @@ -159,7 +159,7 @@ public function guard_rest_users($response, $handler, $request) return $response; } - $this->record('tripwire', $route); + $this->record('tripwire', $route, 'user_list'); $canary = WebDecoy_Decoy_Response::canaries()['author_user'] ?? 'editor'; $fake = [[ @@ -172,12 +172,13 @@ public function guard_rest_users($response, $handler, $request) } /** - * Record a synthetic tripwire hit through the supplied recorder. + * Record a synthetic tripwire hit through the supplied recorder, with the + * trap kind the plain-language sentence is chosen by (WebDecoy/app#443). */ - private function record(string $rule, string $path): void + private function record(string $rule, string $path, string $trap_kind): void { if (is_callable($this->recorder)) { - call_user_func($this->recorder, $rule, $path); + call_user_func($this->recorder, $rule, $path, $trap_kind); } } } diff --git a/sdk/src/HitExplanation.php b/sdk/src/HitExplanation.php new file mode 100644 index 0000000..9e696dd --- /dev/null +++ b/sdk/src/HitExplanation.php @@ -0,0 +1,129 @@ +|null */ + private static $table = null; + + /** @return array */ + public static function table(): array + { + if (self::$table === null) { + $loaded = require __DIR__ . '/registry/hit-explanations.generated.php'; + self::$table = is_array($loaded) ? $loaded : []; + } + return self::$table; + } + + /** + * Which trap a hit touched, or '' when it is not a decoy hit. + */ + public static function classify(string $source, string $trapKind, string $path): string + { + $t = self::table(); + if ($source === $t['source_decoy_link']) { + return 'decoy_url'; + } + if ($source === $t['source_decoy_endpoint']) { + return 'decoy_endpoint'; + } + if ($source !== $t['source_tripwire']) { + return ''; + } + if (in_array($trapKind, $t['trap_kinds'], true)) { + return $trapKind; + } + if (strpos(self::safePath($path), (string) $t['hidden_link_prefix']) === 0) { + return 'hidden_link'; + } + return 'trap_path'; + } + + /** + * The sentence for a hit, or '' when it is not a decoy hit. + */ + public static function explain(string $source, string $trapKind, string $path): string + { + $kind = self::classify($source, $trapKind, $path); + if ($kind === '') { + return ''; + } + $template = self::table()['templates'][$kind]; + $safe = self::safePath($path); + if ($safe === '') { + return (string) $template['without_path']; + } + return str_replace('{path}', $safe, (string) $template['with_path']); + } + + /** + * The sentence for a row of the local detections table, from its flags + * JSON. Only rule-engine tripwire rows (flag "tripwire_rule") are decoy + * hits; every other row, including the "decoy_served" follow-up row and + * test triggers, gets ''. + */ + public static function forLocalFlags(?string $flagsJson): string + { + $data = json_decode((string) $flagsJson, true); + if (!is_array($data) || !isset($data['flags']) || !is_array($data['flags'])) { + return ''; + } + if (!in_array('tripwire_rule', $data['flags'], true)) { + return ''; + } + $meta = isset($data['metadata']) && is_array($data['metadata']) ? $data['metadata'] : []; + $key = (string) self::table()['metadata_key']; + $kind = isset($meta[$key]) && is_string($meta[$key]) ? $meta[$key] : ''; + $path = isset($meta['path']) && is_string($meta['path']) ? $meta['path'] : ''; + return self::explain(self::SOURCE_TRIPWIRE, $kind, $path); + } + + /** + * The part of a stored path a sentence may show: no query string or + * fragment, no control or text-direction characters, at most + * max_path_runes characters. Invalid UTF-8 counts as not recorded. + */ + public static function safePath(string $path): string + { + if (preg_match('//u', $path) !== 1) { + return ''; + } + $path = substr($path, 0, strcspn($path, '?#')); + $path = (string) preg_replace( + '/[\x{0}-\x{1F}\x{7F}-\x{9F}\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]/u', + '', + $path + ); + $path = trim($path, ' '); + $max = (int) self::table()['max_path_runes']; + $chars = preg_split('//u', $path, -1, PREG_SPLIT_NO_EMPTY); + if (is_array($chars) && count($chars) > $max) { + $path = implode('', array_slice($chars, 0, $max)) . '...'; + } + return $path; + } +} diff --git a/sdk/src/Rules/TripwireRule.php b/sdk/src/Rules/TripwireRule.php index 6549609..8c4e7b7 100644 --- a/sdk/src/Rules/TripwireRule.php +++ b/sdk/src/Rules/TripwireRule.php @@ -63,6 +63,20 @@ class TripwireRule implements RuleInterface /** @var bool */ private $dryRun; + /** @var string Which trap this rule guards; '' to derive it per hit. */ + private $trapKind; + + /** @var bool Whether DEFAULT_TRIPWIRE_PATHS were merged in. */ + private $includeDefaults; + + /** + * Trap kinds recorded under metadata "trap_kind" (WebDecoy/app#443). The + * names belong to the generated sdk/src/registry/hit-explanations.generated.php; + * tests/HitExplanationTest.php fails on one it does not know. + */ + public const TRAP_KIND_PROBE_PATH = 'probe_path'; + public const TRAP_KIND_TRAP_PATH = 'trap_path'; + /** * @param array $config { * @type string[] $paths Extra exact paths. @@ -71,12 +85,16 @@ class TripwireRule implements RuleInterface * @type bool $includeDefaults Merge DEFAULT_TRIPWIRE_PATHS (default true). * @type string $action DENY (default) or THROTTLE. * @type bool $dryRun Record but don't block (default false). + * @type string $trapKind Which trap this rule guards (hidden_link, + * fake_plugin, ...). Default: probe_path for a + * default path, trap_path for anything else. * } */ public function __construct(array $config = []) { $paths = $config['paths'] ?? []; - if ($config['includeDefaults'] ?? true) { + $this->includeDefaults = (bool) ($config['includeDefaults'] ?? true); + if ($this->includeDefaults) { $paths = array_merge($paths, self::DEFAULT_TRIPWIRE_PATHS); } @@ -89,6 +107,24 @@ public function __construct(array $config = []) $this->patterns = $config['patterns'] ?? []; $this->action = $config['action'] ?? RuleResult::DENY; $this->dryRun = $config['dryRun'] ?? false; + $this->trapKind = isset($config['trapKind']) && is_string($config['trapKind']) ? $config['trapKind'] : ''; + } + + /** + * Which trap a hit on $path touched, so the Detections list and the digest + * email can say what happened in plain words (WebDecoy/app#443). Not part + * of @webdecoy/node's tripwire. A kind the shared table does not know falls + * back to the generic sentence wherever it is read. + */ + private function trapKindFor(string $path): string + { + if ($this->trapKind !== '') { + return $this->trapKind; + } + if ($this->includeDefaults && in_array($path, self::DEFAULT_TRIPWIRE_PATHS, true)) { + return self::TRAP_KIND_PROBE_PATH; + } + return self::TRAP_KIND_TRAP_PATH; } public function getName(): string @@ -139,7 +175,7 @@ public function evaluate(RuleContext $context): RuleResult $this->dryRun ? RuleResult::ALLOW : $this->action, 'tripwire', 'Tripwire hit: ' . $path . ' — hidden honeypot path, deterministic automated-intent signal', - ['path' => $path, 'dryRun' => $this->dryRun, 'confidence' => 100] + ['path' => $path, 'dryRun' => $this->dryRun, 'confidence' => 100, 'trap_kind' => $this->trapKindFor($path)] ); } diff --git a/sdk/src/registry/hit-explanation-vectors.generated.json b/sdk/src/registry/hit-explanation-vectors.generated.json new file mode 100644 index 0000000..95c2853 --- /dev/null +++ b/sdk/src/registry/hit-explanation-vectors.generated.json @@ -0,0 +1,335 @@ +[ + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "hidden_link", + "path": "/trap/hidden_link" + }, + "kind": "hidden_link", + "explanation": "A visitor requested /trap/hidden_link, the address of a hidden link on your pages. People can't see or click that link." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "hidden_link", + "path": "" + }, + "kind": "hidden_link", + "explanation": "A visitor requested the address of a hidden link on your pages. People can't see or click that link." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "fake_plugin", + "path": "/trap/fake_plugin" + }, + "kind": "fake_plugin", + "explanation": "A visitor requested /trap/fake_plugin. That plugin is not installed on your site, so the path exists only as a trap." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "fake_plugin", + "path": "" + }, + "kind": "fake_plugin", + "explanation": "A visitor requested a path inside a plugin that is not installed on your site. The path exists only as a trap." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "author_lookup", + "path": "/trap/author_lookup" + }, + "kind": "author_lookup", + "explanation": "A visitor asked your site for the user name behind an author number, without logging in." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "author_lookup", + "path": "" + }, + "kind": "author_lookup", + "explanation": "A visitor asked your site for the user name behind an author number, without logging in." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "user_list", + "path": "/trap/user_list" + }, + "kind": "user_list", + "explanation": "A visitor asked your site's API for its list of users, without logging in." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "user_list", + "path": "" + }, + "kind": "user_list", + "explanation": "A visitor asked your site's API for its list of users, without logging in." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "probe_path", + "path": "/trap/probe_path" + }, + "kind": "probe_path", + "explanation": "A visitor requested /trap/probe_path, a path that scanners commonly try. It is set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "probe_path", + "path": "" + }, + "kind": "probe_path", + "explanation": "A visitor requested a path that scanners commonly try. It is set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/trap/trap_path" + }, + "kind": "trap_path", + "explanation": "A visitor requested /trap/trap_path, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "" + }, + "kind": "trap_path", + "explanation": "A visitor requested a path set up as a trap on your site. The path was not recorded." + }, + { + "hit": { + "source": "decoy_link", + "trap_kind": "", + "path": "/pricing-archive" + }, + "kind": "decoy_url", + "explanation": "A visitor opened /pricing-archive, a decoy URL that exists only as a trap." + }, + { + "hit": { + "source": "decoy_link", + "trap_kind": "", + "path": "" + }, + "kind": "decoy_url", + "explanation": "A visitor opened a decoy URL that exists only as a trap. The path was not recorded." + }, + { + "hit": { + "source": "endpoint", + "trap_kind": "", + "path": "/api/v2/export" + }, + "kind": "decoy_endpoint", + "explanation": "A visitor called /api/v2/export, a decoy API address that exists only as a trap." + }, + { + "hit": { + "source": "endpoint", + "trap_kind": "hidden_link", + "path": "/api/v2/export" + }, + "kind": "decoy_endpoint", + "explanation": "A visitor called /api/v2/export, a decoy API address that exists only as a trap." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "", + "path": "/__wd/3f9a2c71b0de" + }, + "kind": "hidden_link", + "explanation": "A visitor requested /__wd/3f9a2c71b0de, the address of a hidden link on your pages. People can't see or click that link." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "", + "path": "/__wd" + }, + "kind": "trap_path", + "explanation": "A visitor requested /__wd, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "", + "path": "/.env" + }, + "kind": "trap_path", + "explanation": "A visitor requested /.env, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "", + "path": "" + }, + "kind": "trap_path", + "explanation": "A visitor requested a path set up as a trap on your site. The path was not recorded." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "decoy_url", + "path": "/x" + }, + "kind": "trap_path", + "explanation": "A visitor requested /x, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "Hidden_Link", + "path": "/x" + }, + "kind": "trap_path", + "explanation": "A visitor requested /x, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "made_up", + "path": "/x" + }, + "kind": "trap_path", + "explanation": "A visitor requested /x, a path set up as a trap on your site." + }, + { + "hit": { + "source": "bot_scanner", + "trap_kind": "", + "path": "/pricing" + }, + "kind": "", + "explanation": "" + }, + { + "hit": { + "source": "", + "trap_kind": "hidden_link", + "path": "/__wd/abc" + }, + "kind": "", + "explanation": "" + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/reset?token=secret\u0026email=a@b.c" + }, + "kind": "trap_path", + "explanation": "A visitor requested /reset, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/page#frag" + }, + "kind": "trap_path", + "explanation": "A visitor requested /page, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "?only=query" + }, + "kind": "trap_path", + "explanation": "A visitor requested a path set up as a trap on your site. The path was not recorded." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": " /spaced " + }, + "kind": "trap_path", + "explanation": "A visitor requested /spaced, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/a\r\nInjected: header\t/b" + }, + "kind": "trap_path", + "explanation": "A visitor requested /aInjected: header/b, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/evil‮gnp.exe" + }, + "kind": "trap_path", + "explanation": "A visitor requested /evilgnp.exe, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/\u003cscript\u003ealert(1)\u003c/script\u003e" + }, + "kind": "trap_path", + "explanation": "A visitor requested /\u003cscript\u003ealert(1)\u003c/script\u003e, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/café/日本" + }, + "kind": "trap_path", + "explanation": "A visitor requested /café/日本, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "kind": "trap_path", + "explanation": "A visitor requested /aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + }, + "kind": "trap_path", + "explanation": "A visitor requested /ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé..., a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "trap_path", + "path": "/{path}" + }, + "kind": "trap_path", + "explanation": "A visitor requested /{path}, a path set up as a trap on your site." + }, + { + "hit": { + "source": "sdk_tripwire", + "trap_kind": "", + "path": " /__wd/abc" + }, + "kind": "hidden_link", + "explanation": "A visitor requested /__wd/abc, the address of a hidden link on your pages. People can't see or click that link." + } +] diff --git a/sdk/src/registry/hit-explanations.generated.php b/sdk/src/registry/hit-explanations.generated.php new file mode 100644 index 0000000..074bb7f --- /dev/null +++ b/sdk/src/registry/hit-explanations.generated.php @@ -0,0 +1,56 @@ + 'trap_kind', + 'hidden_link_prefix' => '/__wd/', + 'max_path_runes' => 80, + 'source_tripwire' => 'sdk_tripwire', + 'source_decoy_link' => 'decoy_link', + 'source_decoy_endpoint' => 'endpoint', + 'trap_kinds' => ['hidden_link', 'fake_plugin', 'author_lookup', 'user_list', 'probe_path', 'trap_path'], + 'templates' => [ + 'hidden_link' => [ + 'with_path' => 'A visitor requested {path}, the address of a hidden link on your pages. People can\'t see or click that link.', + 'without_path' => 'A visitor requested the address of a hidden link on your pages. People can\'t see or click that link.', + ], + 'fake_plugin' => [ + 'with_path' => 'A visitor requested {path}. That plugin is not installed on your site, so the path exists only as a trap.', + 'without_path' => 'A visitor requested a path inside a plugin that is not installed on your site. The path exists only as a trap.', + ], + 'author_lookup' => [ + 'with_path' => 'A visitor asked your site for the user name behind an author number, without logging in.', + 'without_path' => 'A visitor asked your site for the user name behind an author number, without logging in.', + ], + 'user_list' => [ + 'with_path' => 'A visitor asked your site\'s API for its list of users, without logging in.', + 'without_path' => 'A visitor asked your site\'s API for its list of users, without logging in.', + ], + 'probe_path' => [ + 'with_path' => 'A visitor requested {path}, a path that scanners commonly try. It is set up as a trap on your site.', + 'without_path' => 'A visitor requested a path that scanners commonly try. It is set up as a trap on your site.', + ], + 'trap_path' => [ + 'with_path' => 'A visitor requested {path}, a path set up as a trap on your site.', + 'without_path' => 'A visitor requested a path set up as a trap on your site. The path was not recorded.', + ], + 'decoy_url' => [ + 'with_path' => 'A visitor opened {path}, a decoy URL that exists only as a trap.', + 'without_path' => 'A visitor opened a decoy URL that exists only as a trap. The path was not recorded.', + ], + 'decoy_endpoint' => [ + 'with_path' => 'A visitor called {path}, a decoy API address that exists only as a trap.', + 'without_path' => 'A visitor called a decoy API address that exists only as a trap. The path was not recorded.', + ], + ], +]; diff --git a/tests/HitExplanationTest.php b/tests/HitExplanationTest.php new file mode 100644 index 0000000..94ccd1a --- /dev/null +++ b/tests/HitExplanationTest.php @@ -0,0 +1,116 @@ + ['tripwire_rule'], + 'metadata' => ['rule' => 'tripwire', 'path' => '/__wd/3f9a2c71b0de', 'trap_kind' => 'hidden_link', 'dry_run' => true], + ]); + $same( + HitExplanation::forLocalFlags($row), + 'A visitor requested /__wd/3f9a2c71b0de, the address of a hidden link on your pages. People can\'t see or click that link.' + ); + + // A row written before the kind was recorded still gets an honest sentence. + $old = json_encode(['flags' => ['tripwire_rule'], 'metadata' => ['rule' => 'tripwire', 'path' => '/.env']]); + $same(HitExplanation::forLocalFlags($old), 'A visitor requested /.env, a path set up as a trap on your site.'); + + // Not decoy hits: the decoy-served follow-up row, test triggers, heuristic + // scoring rows, and junk. + $same(HitExplanation::forLocalFlags(json_encode(['flags' => ['decoy_served'], 'metadata' => ['rule' => 'tripwire', 'decoy_path' => '/.env']])), ''); + $same(HitExplanation::forLocalFlags(json_encode(['flags' => ['test_trigger'], 'metadata' => ['test' => true]])), ''); + $same(HitExplanation::forLocalFlags(json_encode(['headless_browser', 'no_cookies'])), ''); + $same(HitExplanation::forLocalFlags(json_encode(['f' => ['admin_probing']])), ''); + $same(HitExplanation::forLocalFlags(null), ''); + $same(HitExplanation::forLocalFlags('{not json'), ''); +}); + +$t('a tripwire hit records which trap it was', function () use ($same) { + $hit = static function (TripwireRule $rule, string $path) { + $result = $rule->evaluate(new RuleContext('203.0.113.9', $path, 'GET')); + return $result->metadata['trap_kind'] ?? null; + }; + $defaults = new TripwireRule(['paths' => ['/my-secret-admin']]); + $same($hit($defaults, '/.env'), 'probe_path'); + $same($hit($defaults, '/.env?x=1'), 'probe_path'); + $same($hit($defaults, '/my-secret-admin'), 'trap_path'); + + $honeytoken = new TripwireRule(['paths' => ['/__wd/abc'], 'includeDefaults' => false, 'trapKind' => 'hidden_link']); + $same($hit($honeytoken, '/__wd/abc'), 'hidden_link'); + + $plugins = new TripwireRule(['prefixes' => ['/wp-content/plugins/revslider/'], 'includeDefaults' => false, 'trapKind' => 'fake_plugin']); + $same($hit($plugins, '/wp-content/plugins/revslider/x.php'), 'fake_plugin'); + + // A kind the shared table does not know cannot pick a sentence. + $bogus = new TripwireRule(['paths' => ['/x'], 'includeDefaults' => false, 'trapKind' => 'decoy_url']); + $same(HitExplanation::classify('sdk_tripwire', (string) $hit($bogus, '/x'), '/x'), 'trap_path'); +}); + +$t('every trap kind the plugin records is one the shared table knows', function () use ($same) { + $known = HitExplanation::table()['trap_kinds']; + $root = dirname(__DIR__); + $sources = [ + $root . '/webdecoy.php', + $root . '/includes/class-webdecoy-wp-traps.php', + $root . '/sdk/src/Rules/TripwireRule.php', + ]; + $found = 0; + foreach ($sources as $file) { + $src = (string) file_get_contents($file); + preg_match_all("/'trapKind'\\s*=>\\s*'([a-z_]+)'|->record\\('tripwire',[^,]+,\\s*'([a-z_]+)'\\)|TRAP_KIND_[A-Z_]+\\s*=\\s*'([a-z_]+)'/", $src, $m); + foreach (array_merge($m[1], $m[2], $m[3]) as $kind) { + if ($kind === '') { + continue; + } + $found++; + $same(in_array($kind, $known, true), true, basename($file) . ' records unknown trap kind ' . $kind); + } + } + $same($found >= 6, true, 'expected the plugin to record at least six trap kinds, found ' . $found); +}); diff --git a/webdecoy.php b/webdecoy.php index 4538ea2..c9dbb36 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/LlmReferral.php'; + require_once $sdk_path . 'src/HitExplanation.php'; require_once $sdk_path . 'src/GoodBotList.php'; require_once $sdk_path . 'src/RouteResolution.php'; require_once $sdk_path . 'src/SignalCollector.php'; @@ -1281,27 +1282,33 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine 'includeDefaults' => false, 'action' => $action, 'dryRun' => $dryRun, + 'trapKind' => 'hidden_link', ]); } // WordPress-native path traps: fake vulnerable-plugin routes (only for - // absent plugins) and, optionally, xmlrpc.php. Armed as a tripwire so + // absent plugins) and, optionally, xmlrpc.php. Armed as tripwires so // they get the full DENY + violation + clearance + optional-decoy path. - $trap_prefixes = []; - $trap_paths = []; - if (!empty($this->options['traps_fake_plugins'])) { - $trap_prefixes = array_merge($trap_prefixes, WebDecoy_WP_Traps::plugin_path_prefixes()); + // Two rules rather than one so each hit records which trap it was + // (WebDecoy/app#443): xmlrpc.php is a real WordPress endpoint, not a + // missing plugin. + $trap_prefixes = !empty($this->options['traps_fake_plugins']) ? WebDecoy_WP_Traps::plugin_path_prefixes() : []; + if ($trap_prefixes !== []) { + $rules[] = new \WebDecoy\Rules\TripwireRule([ + 'prefixes' => $trap_prefixes, + 'includeDefaults' => false, + 'action' => $action, + 'dryRun' => $dryRun, + 'trapKind' => 'fake_plugin', + ]); } if (!empty($this->options['traps_xmlrpc'])) { - $trap_paths[] = '/xmlrpc.php'; - } - if ($trap_prefixes !== [] || $trap_paths !== []) { $rules[] = new \WebDecoy\Rules\TripwireRule([ - 'paths' => $trap_paths, - 'prefixes' => $trap_prefixes, + 'paths' => ['/xmlrpc.php'], 'includeDefaults' => false, 'action' => $action, 'dryRun' => $dryRun, + 'trapKind' => 'trap_path', ]); } @@ -1378,8 +1385,10 @@ public function register_wp_traps(): void * * @param string $rule * @param string $path + * @param string $trap_kind Which trap it was (WebDecoy/app#443), for the + * plain-language sentence; '' for the generic one. */ - public function record_synthetic_tripwire(string $rule, string $path): void + public function record_synthetic_tripwire(string $rule, string $path, string $trap_kind = ''): void { $ip = $this->get_client_ip(); $ua = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; @@ -1397,7 +1406,7 @@ public function record_synthetic_tripwire(string $rule, string $path): void $ua !== '' ? $ua : null, 'WordPress trap hit: ' . $path, $clearance, - ['path' => $path, 'confidence' => 100, 'trap' => true], + ['path' => $path, 'confidence' => 100, 'trap' => true, 'trap_kind' => $trap_kind !== '' ? $trap_kind : \WebDecoy\Rules\TripwireRule::TRAP_KIND_TRAP_PATH], false, gmdate('Y-m-d\TH:i:s') . '.000Z' );