From f2bfa1cd3c459cfb1d6352d8a37377adda9105a1 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 16 Jul 2026 08:07:49 -0500 Subject: [PATCH] feat(rules): forward wd_clearance on tripwire violations (#136 SDK half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes tripwires drive enforcement. When a tripwire (honeypot path — intent a real user can't fake) fires, the violation now carries the request's wd_clearance token so the backend can deny the actor's device fingerprint — the same durable, rotation-proof lockout a decoy hit produces. - ViolationEvent gains clearance?: string. - rule-engine extracts wd_clearance from the request Cookie header and attaches it ONLY to tripwire violations — heuristic rules (filter, rate-limit) never carry it, so only the deception signal drives the deny-list. - Tests cover the extract + the tripwire-only gating. --- .../webdecoy/src/rules/rule-engine.test.ts | 53 +++++++++++++++++++ packages/webdecoy/src/rules/rule-engine.ts | 19 ++++++- packages/webdecoy/src/rules/types.ts | 7 +++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 packages/webdecoy/src/rules/rule-engine.test.ts diff --git a/packages/webdecoy/src/rules/rule-engine.test.ts b/packages/webdecoy/src/rules/rule-engine.test.ts new file mode 100644 index 0000000..cfbf9be --- /dev/null +++ b/packages/webdecoy/src/rules/rule-engine.test.ts @@ -0,0 +1,53 @@ +import { RuleEngine } from './rule-engine'; +import type { Rule, RuleContext, RuleResult } from './types'; + +/** A stub rule that always returns a fixed result. */ +function fixedRule(result: RuleResult): Rule { + return { name: result.rule, evaluate: (): RuleResult => result }; +} + +function ctx(cookie?: string): RuleContext { + return { + ip: '203.0.113.5', + path: '/wp-admin.php', + method: 'GET', + headers: cookie ? { cookie } : {}, + timestamp: 1_700_000_000_000, + }; +} + +describe('RuleEngine clearance forwarding (#136)', () => { + it('attaches the wd_clearance token to a tripwire violation', () => { + const engine = new RuleEngine([ + fixedRule({ action: 'DENY', rule: 'tripwire', reason: 'honeypot path' }), + ]); + const res = engine.evaluate(ctx('foo=1; wd_clearance=TOK123; bar=2')); + expect(res.violations[0].rule).toBe('tripwire'); + expect(res.violations[0].clearance).toBe('TOK123'); + }); + + it('does NOT attach clearance to non-tripwire rules (heuristics never deny fp)', () => { + const engine = new RuleEngine([ + fixedRule({ action: 'DENY', rule: 'filter', reason: 'ip.tor' }), + ]); + const res = engine.evaluate(ctx('wd_clearance=TOK123')); + expect(res.violations[0].rule).toBe('filter'); + expect(res.violations[0].clearance).toBeUndefined(); + }); + + it('leaves clearance undefined for a tripwire when there is no cookie', () => { + const engine = new RuleEngine([ + fixedRule({ action: 'DENY', rule: 'tripwire', reason: 'honeypot path' }), + ]); + const res = engine.evaluate(ctx()); + expect(res.violations[0].clearance).toBeUndefined(); + }); + + it('leaves clearance undefined when the cookie has no wd_clearance', () => { + const engine = new RuleEngine([ + fixedRule({ action: 'DENY', rule: 'tripwire', reason: 'honeypot path' }), + ]); + const res = engine.evaluate(ctx('session=abc; theme=dark')); + expect(res.violations[0].clearance).toBeUndefined(); + }); +}); diff --git a/packages/webdecoy/src/rules/rule-engine.ts b/packages/webdecoy/src/rules/rule-engine.ts index 2e186e7..1ef390c 100644 --- a/packages/webdecoy/src/rules/rule-engine.ts +++ b/packages/webdecoy/src/rules/rule-engine.ts @@ -5,6 +5,20 @@ import { Rule, RuleContext, RuleResult, RuleEngineResult, ViolationEvent } from './types'; +/** Pull the wd_clearance token from a request's Cookie header, if present. */ +function extractClearance(headers: Record): string | undefined { + const cookie = headers['cookie']; + if (!cookie) return undefined; + for (const part of cookie.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + if (part.slice(0, eq).trim() === 'wd_clearance') { + return part.slice(eq + 1).trim() || undefined; + } + } + return undefined; +} + export class RuleEngine { private rules: Rule[]; @@ -24,7 +38,9 @@ export class RuleEngine { const result = rule.evaluate(context); if (result.action !== 'ALLOW') { - // Record violation + // Record violation. Tripwire hits (a real user can't reach a honeypot + // path) carry the actor's wd_clearance token so the backend can deny its + // device fingerprint — the deception signal driving enforcement (#136). violations.push({ rule: result.rule, action: result.action, @@ -33,6 +49,7 @@ export class RuleEngine { method: context.method, userAgent: context.userAgent, reason: result.reason, + clearance: result.rule === 'tripwire' ? extractClearance(context.headers) : undefined, metadata: result.metadata, dryRun: result.metadata?.dryRun === true, timestamp: new Date(context.timestamp).toISOString(), diff --git a/packages/webdecoy/src/rules/types.ts b/packages/webdecoy/src/rules/types.ts index 45c4129..21cdfd9 100644 --- a/packages/webdecoy/src/rules/types.ts +++ b/packages/webdecoy/src/rules/types.ts @@ -115,6 +115,13 @@ export interface ViolationEvent { userAgent?: string; /** Reason for the violation */ reason?: string; + /** + * The request's wd_clearance token, when present. Lets the backend bind a + * tripwire hit to the actor's device fingerprint and deny it — the same + * durable lockout a decoy hit produces (enforcement issue #136). Only carried + * for deception rules (tripwires); undefined otherwise. + */ + clearance?: string; /** Additional metadata */ metadata?: Record; /** Whether this was a dry run */