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 ">
- |
+ |
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": "/evilgnp.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'
);
|