Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions admin/css/webdecoy-admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
19 changes: 18 additions & 1 deletion admin/partials/detections-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ class="button <?php echo empty($active_range) ? 'button-primary' : ''; ?>">
<tr>
<th class="check-column"><input type="checkbox" id="webdecoy-select-all" /></th>
<th class="column-date"><?php esc_html_e('Date', 'webdecoy'); ?></th>
<th class="column-what"><?php esc_html_e('What happened', 'webdecoy'); ?></th>
<th class="column-ip"><?php esc_html_e('IP Address', 'webdecoy'); ?></th>
<th class="column-score"><?php esc_html_e('Score', 'webdecoy'); ?></th>
<th class="column-level"><?php esc_html_e('Level', 'webdecoy'); ?></th>
Expand All @@ -304,6 +305,22 @@ class="button <?php echo empty($active_range) ? 'button-primary' : ''; ?>">
<?php // created_at is stored UTC (#55); convert before formatting or the site shows UTC labelled as local. ?>
<?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime(get_date_from_gmt($detection['created_at'])))); ?>
</td>
<td class="column-what">
<?php
// Plain-language sentence for a decoy hit (WebDecoy/app#443), the
// same words the WordPress digest email uses for it. Rows that
// are not decoy hits have no sentence; their detail is in the
// technical columns.
$wd_what = class_exists('\WebDecoy\HitExplanation')
? \WebDecoy\HitExplanation::forLocalFlags(isset($detection['flags']) ? (string) $detection['flags'] : null)
: '';
if ($wd_what !== '') :
?>
<span class="webdecoy-what"><?php echo esc_html($wd_what); ?></span>
<?php else : ?>
<span class="webdecoy-mitre-na">—</span>
<?php endif; ?>
</td>
<td>
<code><?php echo esc_html($detection['ip_address']); ?></code>
</td>
Expand Down Expand Up @@ -460,7 +477,7 @@ class="button button-small">
</td>
</tr>
<tr class="webdecoy-detail-row" style="display:none;">
<td colspan="10">
<td colspan="11">
<div class="webdecoy-detection-detail">
<strong><?php esc_html_e('User Agent:', 'webdecoy'); ?></strong>
<code><?php echo esc_html($detection['user_agent']); ?></code>
Expand Down
11 changes: 6 additions & 5 deletions includes/class-webdecoy-wp-traps.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 = [[
Expand All @@ -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);
}
}
}
129 changes: 129 additions & 0 deletions sdk/src/HitExplanation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace WebDecoy;

if (!defined('ABSPATH')) {
exit;
}

/**
* The plain-language sentence for a decoy hit (WebDecoy/app#443).
*
* The Detections list shows it, and the WordPress digest email shows the same
* sentence for the same hit. The sentences are not written here: they are
* generated from pkg/hitexplain in WebDecoy/app into
* registry/hit-explanations.generated.php, and this port of Explain is pinned
* case for case by the generated golden vectors (tests/HitExplanationTest.php).
*
* A sentence says only what the stored hit records: the path and which trap
* it was. It never says how the visitor found the path, why it asked, or that
* anything was blocked. The result is plain text; escape it on output.
*/
final class HitExplanation
{
/** The detection source a local tripwire row becomes when reported. */
public const SOURCE_TRIPWIRE = 'sdk_tripwire';

/** @var array<string, mixed>|null */
private static $table = null;

/** @return array<string, mixed> */
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;
}
}
40 changes: 38 additions & 2 deletions sdk/src/Rules/TripwireRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed> $config {
* @type string[] $paths Extra exact paths.
Expand All @@ -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);
}

Expand All @@ -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
Expand Down Expand Up @@ -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)]
);
}

Expand Down
Loading
Loading