From c05d7d2010389380765c945f8096bb3333408bfb Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Fri, 21 Aug 2026 21:03:55 -0500 Subject: [PATCH] feat(sdk): a typed decision, and characteristics for who the caller is protect() returned { allowed, detection } and the adapters typed the value they handed to onBlocked as `any`, so integrating meant destructuring a blob and string-matching to find out which rule fired. Three things the SDK already knew had nowhere to go: - Which rules ran. The engine collapsed to one deciding rule plus a violations array, so a filter rule that never ran for want of enrichment was indistinguishable from one that ran and passed. Both reported ALLOW. Every rule now appears in results[] with RUN / DRY_RUN / NOT_RUN / CACHED, and a dry-run rule that matched reports DENY -- what it WOULD have done is the reason you turned it on. - "Challenge this one." We ship a proof-of-work captcha and the decision type could not express it, so it was reachable only by hand. - A stable id. 'rule_' + Date.now() is not unique under concurrency and correlates with nothing. characteristics answers a separate question the SDK had also hard-coded: rate limits keyed on IP, which is the wrong subject for an authenticated API and the wrong subject for us specifically, since the actor model exists because IP is not identity. An absent characteristic falls back to the IP rather than keying under empty -- otherwise one tenant's limit takes out all anonymous traffic, invisibly. Decision caching is deliberately narrow: server-derived DENY and CHALLENGE only. Caching ALLOW is how a client that has since started misbehaving keeps sailing through, and rule outcomes must not be cached because a rate limiter has to see every request. allowed is unchanged, including failing open on error. 327 tests pass, and the any budgets drop (fastify to zero). Closes WebDecoy/app#730 Closes WebDecoy/app#731 --- CHANGELOG.md | 13 + README.md | 62 +++-- packages/express/package.json | 2 +- packages/express/src/middleware.ts | 24 +- packages/fastify/package.json | 2 +- packages/fastify/src/plugin.ts | 31 ++- packages/nextjs/package.json | 2 +- packages/nextjs/src/middleware.ts | 20 +- packages/webdecoy/src/characteristics.ts | 80 ++++++ packages/webdecoy/src/decision-cache.ts | 100 +++++++ packages/webdecoy/src/decision.test.ts | 247 ++++++++++++++++++ packages/webdecoy/src/decision.ts | 237 +++++++++++++++++ packages/webdecoy/src/index.ts | 14 + packages/webdecoy/src/rules/filter-rule.ts | 13 + .../webdecoy/src/rules/rate-limit-rule.ts | 5 +- packages/webdecoy/src/rules/rule-engine.ts | 27 +- packages/webdecoy/src/rules/types.ts | 22 ++ .../webdecoy/src/rules/web-bot-auth-rule.ts | 13 +- packages/webdecoy/src/sdk.ts | 205 +++++++++------ packages/webdecoy/src/types.ts | 66 +++-- 20 files changed, 1039 insertions(+), 146 deletions(-) create mode 100644 packages/webdecoy/src/characteristics.ts create mode 100644 packages/webdecoy/src/decision-cache.ts create mode 100644 packages/webdecoy/src/decision.test.ts create mode 100644 packages/webdecoy/src/decision.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1942d81..cf90941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`. + - `conclusion: 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'`, with `isAllowed()` / `isDenied()` / `isChallenged()` / `isErrored()` and `deniedBy(rule)`. `ERROR` is a distinct conclusion, so a caller can tell "allowed" from "never decided" — both still serve the request. + - `results` — every configured rule in evaluation order with a `state` of `RUN`, `DRY_RUN`, `NOT_RUN` or `CACHED`. `NOT_RUN` is new information: a `filter()` rule with no IP enrichment, or a `webBotAuth()` rule on a request with no host, used to report ALLOW, which reads as "checked and fine" rather than "never checked". A dry-run rule that matched now reports `conclusion: 'DENY'` with `state: 'DRY_RUN'`, rather than the ALLOW its action said. + - `id` — a random `dec_…` id, also stamped on `detection.detection_id`. The old `'rule_' + Date.now()` was not unique under concurrency and correlated with nothing. + - `onBlocked` receives the full decision as a trailing argument in all three adapters, and `detection` is typed. Existing handlers are unaffected. + - `allowed` is unchanged, including failing open on error, so existing middleware keeps working. + +- **`characteristics`** — what the SDK treats as the same caller, for keyed rules and the decision cache. Defaults to `['ip']`; accepts `'path'`, `'method'`, `'userAgent'`, or a function over the rule context. A rule's own `keyBy` still wins. When a characteristic is absent the key falls back to the IP, rather than bucketing every request missing that field into one bucket — which is how a limit meant for one tenant takes out anonymous traffic site-wide. + +- **Decision caching.** A server-derived `DENY` or `CHALLENGE` is reused for its TTL instead of re-asking the service about a caller it just answered for. Deliberately narrow: `ALLOW` is never cached (that is how a client that has since started misbehaving keeps sailing through, and it saves the cheap request), and rule outcomes are never cached (a rate limiter has to see every request, and a cached tripwire hit would stop the violation being reported). Configure with `decisionCache: { ttl, max }` or disable with `false`. + ## [0.12.0] - 2026-08-22 ### Fixed diff --git a/README.md b/README.md index 0d1d5e2..09c3006 100644 --- a/README.md +++ b/README.md @@ -255,26 +255,58 @@ Additional local rules for the `rules` array. `filter()` requires an API key for Local Web Bot Auth verification (RFC 9421). `webBotAuth()` returns a `Rule` that denies agent impersonation; `detectBot(request)` returns the verdict directly for custom handling. See the [Web Bot Auth guide](docs/verify-ai-agents-web-bot-auth.md). Exported types: `AgentVerdict`, `AgentStatus`, `AgentCategory`, `WebBotAuthConfig`, `AgentVerifierOptions`, `SignedAgentDirectory`. -### `protect(metadata, options?): Promise` +### `protect(metadata, options?): Promise` -Full analysis of a request (platform feature). Returns a decision: +Full analysis of a request. Returns a typed decision: ```typescript -interface ProtectResult { - allowed: boolean; - detection: { - decision: 'allow' | 'block' | 'challenge'; - confidence: number; // 0–100 threat score - threat_level: 'MINIMAL' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; - bot_detected: boolean; - bot_type?: string; // e.g. "curl", "selenium" - detection_id: string; - rule_enforced: boolean; - }; - error?: string; -} +const d = await wd.protect(metadata); + +d.conclusion // 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR' +d.allowed // true for ALLOW and ERROR (fail open) +d.id // 'dec_…', correlates with the dashboard +d.isDenied() // narrowing helpers +d.deniedBy('tripwire') // which rule, without string-matching +d.results // every rule, in order, and what it concluded +d.detection // the service's response, as before +d.edge // what the edge validator said +``` + +`results` is the part worth knowing about. Every configured rule appears, with a +`state`: + +| `state` | Meaning | +|---|---| +| `RUN` | Evaluated, and its conclusion counts. | +| `DRY_RUN` | Evaluated; conclusion recorded but not enforced. | +| `NOT_RUN` | Could not evaluate — a signal it needs was absent (a `filter()` with no IP enrichment, a `webBotAuth()` on a request with no host). | +| `CACHED` | Not evaluated; a prior decision for this key was reused. | + +`NOT_RUN` is the one that used to be invisible: such a rule reported ALLOW, which +reads as "checked and fine" rather than "never checked". A dry-run rule that +matched reports `conclusion: 'DENY'` with `state: 'DRY_RUN'` — what it *would* +have done is the reason you turned it on. + +`ERROR` is not a synonym for `DENY`. It means no verdict was reached, and the +request is allowed through. + +### `characteristics` — what counts as the same caller + +Rate limits and the decision cache key on the client IP by default. On an +authenticated API that is usually the wrong subject: + +```typescript +const wd = new WebDecoy({ + characteristics: [(ctx) => ctx.headers['x-api-key']], + rules: [rateLimit({ max: 100, window: 60 })], +}); ``` +Built-ins are `'ip'`, `'path'`, `'method'`, `'userAgent'`; a function derives +anything else. A rule's own `keyBy` still wins. If a characteristic is absent on +a request the key falls back to the IP, rather than bucketing every request +missing that field together. + All TypeScript types are exported (`WebDecoyConfig`, `RequestMetadata`, `ProtectResult`, `Rule`, `TripwireConfig`, `RateLimitConfig`, `FilterConfig`, `Honeytoken`, …). ## Examples diff --git a/packages/express/package.json b/packages/express/package.json index 83879ae..eb1bc45 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -18,7 +18,7 @@ "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "test": "jest --passWithNoTests", - "lint": "eslint src --max-warnings 14", + "lint": "eslint src --max-warnings 12", "clean": "rm -rf dist" }, "keywords": [ diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index ad6a47e..d800133 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -4,7 +4,13 @@ import { Request, Response, NextFunction } from 'express'; import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node'; -import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node'; +import type { + EdgeVerdict, + SiteHoneytoken, + TrustedProxies, + ProtectResult, + SDKDetectionResponse, +} from '@webdecoy/node'; import { siteHoneytoken, injectHoneytokenLink, @@ -85,8 +91,18 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { * `next` is passed so a handler can record the verdict and continue — the * omission that made monitoring impossible. Call exactly one of `next()` or a * response method. + * + * `decision` is the full typed verdict — `conclusion`, every rule's outcome + * including the ones that dry-ran or never ran, and `deniedBy('tripwire')` — + * for handlers that need to know WHY rather than just THAT. */ - onBlocked?: (req: Request, res: Response, detection: any, next: NextFunction) => void; + onBlocked?: ( + req: Request, + res: Response, + detection: SDKDetectionResponse, + next: NextFunction, + decision: ProtectResult, + ) => void; /** * Custom function to handle errors @@ -135,7 +151,7 @@ function resolveIP(req: Request, trustProxy: TrustedProxies | undefined): string function defaultOnBlocked( req: Request, res: Response, - detection: any, + detection: SDKDetectionResponse, _next: NextFunction, ): void { res.status(403).json({ @@ -369,7 +385,7 @@ export function webdecoy( return next(); } else { // Block the request - return onBlocked(req, res, result.detection, next); + return onBlocked(req, res, result.detection, next, result); } } catch (error) { onError(req, res, error as Error); diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 7cddb71..8393ac1 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -18,7 +18,7 @@ "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "test": "jest --passWithNoTests", - "lint": "eslint src --max-warnings 2", + "lint": "eslint src --max-warnings 0", "clean": "rm -rf dist" }, "keywords": [ diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index be09a03..eb1878c 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -16,7 +16,13 @@ import { resolveClientIp, normalizeIp, } from '@webdecoy/node'; -import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node'; +import type { + EdgeVerdict, + SiteHoneytoken, + TrustedProxies, + ProtectResult, + SDKDetectionResponse, +} from '@webdecoy/node'; export interface WebDecoyPluginOptions extends ProtectOptions { /** @@ -72,10 +78,19 @@ export interface WebDecoyPluginOptions extends ProtectOptions { getIP?: (req: FastifyRequest) => string; /** - * Custom function to handle blocked requests - * By default, returns 403 Forbidden + * Called when a request would be blocked. + * + * `detection` is the detection response, as before. `decision` is the full + * typed verdict — `conclusion`, every rule's outcome including the ones that + * dry-ran or never ran, and `deniedBy('tripwire')` — for handlers that need to + * know WHY rather than just THAT. */ - onBlocked?: (req: FastifyRequest, reply: FastifyReply, detection: any) => void; + onBlocked?: ( + req: FastifyRequest, + reply: FastifyReply, + detection: SDKDetectionResponse, + decision: ProtectResult, + ) => void; /** * Custom function to handle errors @@ -118,7 +133,11 @@ function resolveIP(req: FastifyRequest, trustProxy: TrustedProxies | undefined): /** * Default blocked request handler */ -function defaultOnBlocked(req: FastifyRequest, reply: FastifyReply, detection: any): void { +function defaultOnBlocked( + req: FastifyRequest, + reply: FastifyReply, + detection: SDKDetectionResponse, +): void { reply.status(403).send({ error: 'Forbidden', message: 'Access denied by Web Decoy protection', @@ -303,7 +322,7 @@ async function webdecoyPluginImpl( req.webdecoyEdge = result.edge; } else { // Block the request - onBlocked(req, reply, result.detection); + onBlocked(req, reply, result.detection, result); } } catch (error) { onError(req, reply, error as Error); diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 010c0c6..4f3191b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -18,7 +18,7 @@ "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "test": "jest --passWithNoTests", - "lint": "eslint src --max-warnings 5", + "lint": "eslint src --max-warnings 3", "clean": "rm -rf dist", "check:edge": "node ../../scripts/check-edge.mjs src/index.ts" }, diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index 869a465..00875cb 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -11,7 +11,7 @@ import { resolveClientIp, normalizeIp, } from '@webdecoy/node'; -import type { TrustedProxies } from '@webdecoy/node'; +import type { TrustedProxies, ProtectResult, SDKDetectionResponse } from '@webdecoy/node'; export interface WebDecoyMiddlewareOptions extends ProtectOptions { /** @@ -58,7 +58,19 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { * Custom function to handle blocked requests * By default, returns 403 Forbidden JSON response */ - onBlocked?: (req: NextRequest, detection: any) => NextResponse; + /** + * Called when a request would be blocked. + * + * `detection` is the detection response, as before. `decision` is the full + * typed verdict — `conclusion`, every rule's outcome including the ones that + * dry-ran or never ran, and `deniedBy('tripwire')` — for handlers that need to + * know WHY rather than just THAT. + */ + onBlocked?: ( + req: NextRequest, + detection: SDKDetectionResponse, + decision: ProtectResult, + ) => NextResponse; /** * Custom function to handle errors @@ -103,7 +115,7 @@ function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): st /** * Default blocked request handler */ -function defaultOnBlocked(req: NextRequest, detection: any): NextResponse { +function defaultOnBlocked(req: NextRequest, detection: SDKDetectionResponse): NextResponse { return NextResponse.json( { error: 'Forbidden', @@ -274,7 +286,7 @@ export function withWebDecoy( } return NextResponse.next({ request: { headers: requestHeaders } }); } else { - return onBlocked(req, result.detection); + return onBlocked(req, result.detection, result); } } catch (error) { const errorResponse = onError(req, error as Error); diff --git a/packages/webdecoy/src/characteristics.ts b/packages/webdecoy/src/characteristics.ts new file mode 100644 index 0000000..795bc7c --- /dev/null +++ b/packages/webdecoy/src/characteristics.ts @@ -0,0 +1,80 @@ +/** + * What the SDK considers "the same caller". + * + * WHY THIS MODULE EXISTS + * + * `rateLimit({ keyBy })` was the only place a caller could change what a rule + * keyed on, and it was per-rule. Everything else — the decision cache, and any + * future keyed rule — was IP-only. + * + * That is the wrong subject for exactly the traffic worth limiting. On an + * authenticated API the meaningful caller is a user id or an API key, not an + * address shared by a whole office or rotated through a proxy pool. It is also + * the wrong subject for us specifically: the actor model exists because IP is + * not identity. + */ + +import type { RuleContext } from './rules/types'; + +/** + * One component of the key that identifies a caller. + * + * A string names a field of the request; a function derives whatever you like + * from the context (a decoded JWT subject, a tenant id, an API key header). + */ +export type Characteristic = + | 'ip' + | 'path' + | 'method' + | 'userAgent' + | ((context: RuleContext) => string | undefined); + +/** The default: one bucket per client address. */ +export const DEFAULT_CHARACTERISTICS: readonly Characteristic[] = ['ip']; + +function resolveOne(context: RuleContext, c: Characteristic): string | undefined { + if (typeof c === 'function') { + try { + return c(context) || undefined; + } catch { + // A characteristic that throws is a bug in the caller's code, but it must + // not take the request down. Treat it as absent and fall back below. + return undefined; + } + } + switch (c) { + case 'ip': + return context.ip || undefined; + case 'path': + return context.path || undefined; + case 'method': + return context.method || undefined; + case 'userAgent': + return context.userAgent || undefined; + } +} + +/** + * Derive the key identifying this caller. + * + * If any characteristic is absent the whole key falls back to the IP. The + * alternative — a key with an empty component — silently merges every request + * missing that field into one bucket, so an unauthenticated request would share + * a rate limit with every other unauthenticated request. That is the failure + * mode where a limit meant for one tenant takes out anonymous traffic site-wide, + * and it is invisible until it happens. + */ +export function deriveKey( + context: RuleContext, + characteristics: readonly Characteristic[] = DEFAULT_CHARACTERISTICS, +): string { + if (characteristics.length === 0) return context.ip; + + const parts: string[] = []; + for (const c of characteristics) { + const value = resolveOne(context, c); + if (value === undefined) return context.ip; + parts.push(value); + } + return parts.join('|'); +} diff --git a/packages/webdecoy/src/decision-cache.ts b/packages/webdecoy/src/decision-cache.ts new file mode 100644 index 0000000..579054f --- /dev/null +++ b/packages/webdecoy/src/decision-cache.ts @@ -0,0 +1,100 @@ +/** + * Reusing a decision we already paid for. + * + * WHY THIS IS NARROW + * + * Only decisions that cost a network round trip are cached, and only when they + * came back DENY or CHALLENGE. Two deliberate exclusions: + * + * - **Rule decisions are never cached.** A rate limiter has to see every + * request to advance its window, and a cached tripwire DENY would stop the + * violation being reported. Rules are evaluated in-process and cost + * microseconds, so there is nothing to save. + * - **ALLOW is never cached.** Caching an allow is how a client that has since + * started misbehaving keeps sailing through, and the request it saves is the + * cheap one — a low-risk request already returns without calling out. + * + * So this exists for one case: a client we have decided against, hammering the + * origin, where each request would otherwise re-ask the service the question it + * just answered. + */ + +import type { Decision } from './decision'; + +export interface DecisionCacheOptions { + /** + * How long a denial may be reused, in milliseconds. + * @default 60_000 + */ + ttl?: number; + /** + * Maximum entries held. When full, the oldest insertions are dropped. + * A bound rather than a target: this must not become a way for a caller + * cycling keys to grow the process's memory without limit. + * @default 10_000 + */ + max?: number; +} + +interface Entry { + decision: Decision; + expiresAt: number; +} + +export class DecisionCache { + private readonly ttl: number; + private readonly max: number; + // Insertion-ordered, which is what makes the eviction below oldest-first. + private entries = new Map(); + + constructor(options: DecisionCacheOptions = {}) { + this.ttl = options.ttl ?? 60_000; + this.max = options.max ?? 10_000; + } + + /** A cached decision for this key, or undefined. Expired entries are dropped. */ + get(key: string): Decision | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + this.entries.delete(key); + return undefined; + } + return entry.decision; + } + + /** + * Remember a decision, if it is one of the kinds worth remembering. + * Returns whether it was stored, so a caller can assert on the policy rather + * than infer it. + */ + set(key: string, decision: Decision): boolean { + if (decision.conclusion !== 'DENY' && decision.conclusion !== 'CHALLENGE') return false; + if (this.ttl <= 0) return false; + + // Refresh insertion order on overwrite so a repeatedly-denied key is not + // evicted ahead of a key nothing has touched since. + this.entries.delete(key); + this.entries.set(key, { decision, expiresAt: Date.now() + this.ttl }); + + while (this.entries.size > this.max) { + const oldest = this.entries.keys().next(); + if (oldest.done) break; + this.entries.delete(oldest.value); + } + return true; + } + + clear(): void { + this.entries.clear(); + } + + get size(): number { + return this.entries.size; + } + + /** How long entries live, in milliseconds. Reported on the decision. */ + get lifetime(): number { + return this.ttl; + } +} diff --git a/packages/webdecoy/src/decision.test.ts b/packages/webdecoy/src/decision.test.ts new file mode 100644 index 0000000..7f348c9 --- /dev/null +++ b/packages/webdecoy/src/decision.test.ts @@ -0,0 +1,247 @@ +import { WebDecoy } from './sdk'; +import { Decision } from './decision'; +import { DecisionCache } from './decision-cache'; +import { deriveKey } from './characteristics'; +import { rateLimit, tripwire, filter } from './rules'; +import type { RequestMetadata } from './types'; +import type { RuleContext } from './rules/types'; + +const req = (over: Partial = {}): RequestMetadata => ({ + method: 'GET', + path: '/', + ip: '203.0.113.9', + user_agent: 'Mozilla/5.0', + headers: {}, + timestamp: Date.now(), + ...over, +}); + +const ctx = (over: Partial = {}): RuleContext => ({ + ip: '203.0.113.9', + path: '/', + method: 'GET', + headers: {}, + timestamp: Date.now(), + ...over, +}); + +describe('the decision the SDK returns', () => { + it('keeps `allowed` meaning what it meant before', async () => { + const wd = new WebDecoy({ rules: [] }); + const d = await wd.protect(req()); + expect(d.allowed).toBe(true); + expect(d.conclusion).toBe('ALLOW'); + expect(d.isAllowed()).toBe(true); + expect(d.isDenied()).toBe(false); + }); + + it('survives the withEdge copy with its helpers intact', async () => { + // protect() attaches the edge verdict to whatever decide() produced. When + // that was a spread of a plain object the methods would have been lost; + // this is the test that would have caught it. + const wd = new WebDecoy({ rules: [tripwire()] }); + const d = await wd.protect(req({ path: '/.env' })); + expect(typeof d.isDenied).toBe('function'); + expect(d.isDenied()).toBe(true); + expect(d.edge).toBeDefined(); + expect(d.edge?.present).toBe(false); + }); + + it('names the rule that denied, without string-matching', async () => { + const wd = new WebDecoy({ rules: [tripwire()] }); + const d = await wd.protect(req({ path: '/.env' })); + expect(d.deniedBy('tripwire')).toBe(true); + expect(d.deniedBy('rate-limit:1/60s')).toBe(false); + }); + + it('gives every decision a unique id', async () => { + const wd = new WebDecoy({ rules: [] }); + const ids = new Set(); + for (let i = 0; i < 50; i++) ids.add((await wd.protect(req())).id); + expect(ids.size).toBe(50); + expect([...ids][0]).toMatch(/^dec_[0-9a-f]{24}$/); + }); + + it('reports the id on the detection too, so the two correlate', async () => { + const wd = new WebDecoy({ rules: [tripwire()] }); + const d = await wd.protect(req({ path: '/.env' })); + expect(d.detection.detection_id).toBe(d.id); + }); +}); + +describe('per-rule results', () => { + it('records rules that allowed, not only the one that fired', async () => { + const wd = new WebDecoy({ + rules: [rateLimit({ max: 100, window: 60 }), tripwire()], + }); + const d = await wd.protect(req({ path: '/.env' })); + + expect(d.results.map((r) => r.rule)).toEqual(['rate-limit:100/60s', 'tripwire']); + expect(d.results[0]).toMatchObject({ state: 'RUN', conclusion: 'ALLOW' }); + expect(d.results[1]).toMatchObject({ state: 'RUN', conclusion: 'DENY' }); + }); + + it('shows a dry-run rule as DENY it did not enforce', async () => { + const wd = new WebDecoy({ rules: [tripwire({ dryRun: true })] }); + const d = await wd.protect(req({ path: '/.env' })); + + // The whole point of dry run is seeing what it WOULD have done. Reporting + // the rule's action verbatim would show it as ALLOW. + expect(d.conclusion).toBe('ALLOW'); + expect(d.allowed).toBe(true); + expect(d.results[0]).toMatchObject({ state: 'DRY_RUN', conclusion: 'DENY' }); + expect(d.deniedBy('tripwire')).toBe(false); + }); + + it('distinguishes a filter that could not run from one that passed', async () => { + // No API key means no IP enrichment, so `ip.tor` has nothing to read. This + // used to report ALLOW, indistinguishable from "checked, and not Tor". + const wd = new WebDecoy({ rules: [filter({ expression: 'ip.tor' })] }); + const d = await wd.protect(req()); + expect(d.results[0].state).toBe('NOT_RUN'); + expect(d.results[0].reason).toMatch(/API key/); + }); +}); + +describe('the decision cache', () => { + const denial = () => + new Decision({ + conclusion: 'DENY', + detection: { + decision: 'block', + confidence: 95, + threat_level: 'HIGH', + bot_detected: true, + detection_id: 'dec_test', + rule_enforced: false, + }, + results: [{ rule: 'server', state: 'RUN', conclusion: 'DENY' }], + }); + + const allowance = () => + new Decision({ + conclusion: 'ALLOW', + detection: { + decision: 'allow', + confidence: 5, + threat_level: 'MINIMAL', + bot_detected: false, + detection_id: 'dec_test', + rule_enforced: false, + }, + }); + + it('remembers a denial and re-states its rules as CACHED', () => { + const cache = new DecisionCache(); + expect(cache.set('k', denial())).toBe(true); + const hit = cache.get('k')?.asCached(); + expect(hit?.conclusion).toBe('DENY'); + expect(hit?.results[0].state).toBe('CACHED'); + }); + + it('refuses to cache an allow', () => { + // Caching an allow is how a client that has since started misbehaving keeps + // sailing through, and it saves the cheap request rather than the expensive + // one. + const cache = new DecisionCache(); + expect(cache.set('k', allowance())).toBe(false); + expect(cache.get('k')).toBeUndefined(); + }); + + it('expires entries', () => { + const cache = new DecisionCache({ ttl: 1 }); + cache.set('k', denial()); + const now = Date.now(); + jest.spyOn(Date, 'now').mockReturnValue(now + 10); + expect(cache.get('k')).toBeUndefined(); + jest.restoreAllMocks(); + }); + + it('is bounded, and evicts oldest first', () => { + const cache = new DecisionCache({ max: 2 }); + cache.set('a', denial()); + cache.set('b', denial()); + cache.set('c', denial()); + expect(cache.size).toBe(2); + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('c')).toBeDefined(); + }); + + it('keeps a repeatedly-denied key alive over an untouched one', () => { + const cache = new DecisionCache({ max: 2 }); + cache.set('a', denial()); + cache.set('b', denial()); + cache.set('a', denial()); // refreshes a's position + cache.set('c', denial()); + expect(cache.get('a')).toBeDefined(); + expect(cache.get('b')).toBeUndefined(); + }); + + it('a zero ttl means never', () => { + const cache = new DecisionCache({ ttl: 0 }); + expect(cache.set('k', denial())).toBe(false); + }); +}); + +describe('characteristics', () => { + it('defaults to the IP', () => { + expect(deriveKey(ctx())).toBe('203.0.113.9'); + }); + + it('composes several into one key', () => { + expect(deriveKey(ctx({ path: '/api' }), ['ip', 'path'])).toBe('203.0.113.9|/api'); + }); + + it('takes a custom accessor', () => { + const key = deriveKey(ctx({ headers: { 'x-api-key': 'tenant-7' } }), [ + (c) => c.headers['x-api-key'], + ]); + expect(key).toBe('tenant-7'); + }); + + it('falls back to the IP when a characteristic is absent', () => { + // Not '' and not 'undefined' — either would bucket every unauthenticated + // request together, so one tenant's limit would take out anonymous traffic. + expect(deriveKey(ctx(), [(c) => c.headers['x-api-key']])).toBe('203.0.113.9'); + }); + + it('falls back to the IP when a characteristic throws', () => { + expect( + deriveKey(ctx(), [ + () => { + throw new Error('bad accessor'); + }, + ]), + ).toBe('203.0.113.9'); + }); + + it('rate-limits on the characteristic rather than the IP', async () => { + const wd = new WebDecoy({ + characteristics: [(c) => c.headers['x-api-key']], + rules: [rateLimit({ max: 1, window: 60, action: 'DENY' })], + }); + + // Two callers behind one NAT: separate buckets, because the key is the API + // key rather than the address they share. + const a1 = await wd.protect(req({ headers: { 'x-api-key': 'a' } })); + const b1 = await wd.protect(req({ headers: { 'x-api-key': 'b' } })); + const a2 = await wd.protect(req({ headers: { 'x-api-key': 'a' } })); + + expect(a1.allowed).toBe(true); + expect(b1.allowed).toBe(true); + expect(a2.allowed).toBe(false); + }); + + it("lets a rule's own keyBy win over the SDK characteristics", async () => { + const wd = new WebDecoy({ + characteristics: [(c) => c.headers['x-api-key']], + rules: [rateLimit({ max: 1, window: 60, action: 'DENY', keyBy: (c) => c.ip })], + }); + + const first = await wd.protect(req({ headers: { 'x-api-key': 'a' } })); + const second = await wd.protect(req({ headers: { 'x-api-key': 'b' } })); + + expect(first.allowed).toBe(true); + expect(second.allowed).toBe(false); // same IP, and keyBy said IP + }); +}); diff --git a/packages/webdecoy/src/decision.ts b/packages/webdecoy/src/decision.ts new file mode 100644 index 0000000..44d5de7 --- /dev/null +++ b/packages/webdecoy/src/decision.ts @@ -0,0 +1,237 @@ +/** + * What the SDK concluded about one request, and why. + * + * WHY THIS MODULE EXISTS + * + * `protect()` used to return `{ allowed, detection }` and the adapters typed the + * value they handed to `onBlocked` as `any`. A developer integrating us had to + * destructure a blob and string-match to find out which rule fired, and there + * was nowhere to put three things the SDK already knew: + * + * - **Which rules ran.** The engine collapsed to a single deciding rule plus a + * violations array, so a filter rule that never ran for want of enrichment was + * indistinguishable from one that ran and passed. + * - **"Challenge this one."** The captcha in `@webdecoy/client` had no verdict + * that could route to it, so it was reachable only by wiring it up by hand. + * - **A stable id.** `'rule_' + Date.now()` is not unique under concurrency and + * correlates with nothing in the dashboard. + * + * `allowed` still means exactly what it meant before, including failing open on + * ERROR, so existing middleware keeps working unchanged. + */ + +import type { SDKDetectionResponse } from './types'; +import type { RuleEngineResult } from './rules/types'; +import type { AgentVerdict } from './agent/types'; +import type { EdgeVerdict } from './edge'; +import { randomHex } from './webcrypto'; + +/** + * The outcome of a decision. + * + * `ERROR` is not a synonym for `DENY`. It means the SDK could not reach a + * verdict — the detection call failed, the request was malformed — and the + * request is allowed through, because a security control that takes the site + * down when it has a bad day is worse than the traffic it was filtering. + */ +export type Conclusion = 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'; + +/** + * Whether a rule actually contributed to the decision. + * + * - `RUN` — evaluated, and its conclusion counts. + * - `DRY_RUN` — evaluated, and its conclusion is recorded but not enforced. + * - `NOT_RUN` — could not evaluate, because a signal it needs was absent. A + * filter rule with no IP enrichment, a Web Bot Auth rule on a request with no + * host. This is the state that used to be invisible: such a rule reported + * ALLOW, which reads as "checked and fine" rather than "never checked". + * - `CACHED` — not evaluated; a prior decision for this key was reused. + */ +export type RuleState = 'RUN' | 'DRY_RUN' | 'NOT_RUN' | 'CACHED'; + +/** One rule's contribution to a decision. */ +export interface RuleOutcome { + /** The rule's name, e.g. `tripwire` or `rate-limit:100/60s`. */ + rule: string; + state: RuleState; + conclusion: Conclusion; + /** The raw rule action, for rules that distinguish throttling from denial. */ + action?: 'ALLOW' | 'DENY' | 'THROTTLE'; + reason?: string; + metadata?: Record; +} + +/** + * The result of `protect()`. + * + * Kept as an interface separate from the class so that the shape is what + * consumers depend on. The class exists to carry the narrowing helpers. + */ +export interface ProtectResult { + /** Unique id for this decision. Correlates with the dashboard. */ + readonly id: string; + + readonly conclusion: Conclusion; + + /** + * Whether to serve the request. + * + * True for `ALLOW` and for `ERROR` (fail open); false for `DENY` and + * `CHALLENGE`. Unchanged from before the typed decision existed. + */ + readonly allowed: boolean; + + /** Every rule that was configured, and what it concluded. */ + readonly results: readonly RuleOutcome[]; + + /** The deciding reason, when there is one. */ + readonly reason?: string; + + /** Detection response from the service, or the locally-synthesised one. */ + readonly detection: SDKDetectionResponse; + + /** Error message when `conclusion` is `ERROR`. */ + readonly error?: string; + + /** The rule engine's raw result, for callers that were already using it. */ + readonly ruleResult?: RuleEngineResult; + + /** + * Web Bot Auth verdict, present when a `webBotAuth()` rule triggered local + * agent verification. Lets middleware treat a `verified` agent specially + * without re-verifying. + */ + readonly agent?: AgentVerdict; + + /** + * What the edge validator said about this request, parsed from + * `x-wd-clearance` and `x-wd-class`. + * + * Always present. Check `edge.present` before branching: false means the edge + * did not front this request, which is no information rather than a clean + * bill of health. + */ + readonly edge?: EdgeVerdict; + + /** The characteristic key this decision was made for. */ + readonly key?: string; + + /** How long this decision may be reused, in milliseconds. 0 means never. */ + readonly ttl: number; + + isAllowed(): boolean; + isDenied(): boolean; + isChallenged(): boolean; + isErrored(): boolean; + + /** Whether a named rule denied the request. */ + deniedBy(rule: string): boolean; +} + +export interface DecisionInit { + conclusion: Conclusion; + detection: SDKDetectionResponse; + results?: RuleOutcome[]; + reason?: string; + error?: string; + ruleResult?: RuleEngineResult; + agent?: AgentVerdict; + edge?: EdgeVerdict; + key?: string; + ttl?: number; + id?: string; +} + +/** + * A decision id. + * + * Random rather than sequential: ids leave the process (they go on the + * detection row and into customers' logs), and a counter would leak request + * volume to anyone who saw two of them. + */ +export function newDecisionId(): string { + return `dec_${randomHex(12)}`; +} + +export class Decision implements ProtectResult { + readonly id: string; + readonly conclusion: Conclusion; + readonly results: readonly RuleOutcome[]; + readonly reason?: string; + readonly detection: SDKDetectionResponse; + readonly error?: string; + readonly ruleResult?: RuleEngineResult; + readonly agent?: AgentVerdict; + readonly edge?: EdgeVerdict; + readonly key?: string; + readonly ttl: number; + + constructor(init: DecisionInit) { + this.id = init.id ?? newDecisionId(); + this.conclusion = init.conclusion; + this.results = Object.freeze([...(init.results ?? [])]); + this.reason = init.reason; + this.detection = init.detection; + this.error = init.error; + this.ruleResult = init.ruleResult; + this.agent = init.agent; + this.edge = init.edge; + this.key = init.key; + this.ttl = init.ttl ?? 0; + } + + get allowed(): boolean { + return this.conclusion === 'ALLOW' || this.conclusion === 'ERROR'; + } + + isAllowed(): boolean { + return this.conclusion === 'ALLOW'; + } + + isDenied(): boolean { + return this.conclusion === 'DENY'; + } + + isChallenged(): boolean { + return this.conclusion === 'CHALLENGE'; + } + + isErrored(): boolean { + return this.conclusion === 'ERROR'; + } + + deniedBy(rule: string): boolean { + return this.results.some( + (r) => r.rule === rule && r.state === 'RUN' && r.conclusion === 'DENY', + ); + } + + /** A copy of this decision with the edge verdict attached. */ + withEdge(edge: EdgeVerdict | undefined): Decision { + return new Decision({ ...this.init(), edge }); + } + + /** A copy marked as served from cache, with every rule re-stated as CACHED. */ + asCached(): Decision { + return new Decision({ + ...this.init(), + results: this.results.map((r) => ({ ...r, state: 'CACHED' as const })), + }); + } + + private init(): DecisionInit { + return { + id: this.id, + conclusion: this.conclusion, + results: [...this.results], + reason: this.reason, + detection: this.detection, + error: this.error, + ruleResult: this.ruleResult, + agent: this.agent, + edge: this.edge, + key: this.key, + ttl: this.ttl, + }; + } +} diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index bd23505..4d64fe8 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -37,6 +37,20 @@ export type { ProtectOptions, } from './types'; +// The decision `protect()` returns. Exported as a value because the class +// carries the narrowing helpers (`isDenied()`, `deniedBy()`) that keep an +// adapter's blocked path from being typed `any`. +export { Decision, newDecisionId } from './decision'; +export type { Conclusion, RuleState, RuleOutcome, DecisionInit } from './decision'; + +// What counts as the same caller. Exported so an application can derive the +// same key the SDK does — two answers to "who is this" is one too many. +export { deriveKey, DEFAULT_CHARACTERISTICS } from './characteristics'; +export type { Characteristic } from './characteristics'; + +export { DecisionCache } from './decision-cache'; +export type { DecisionCacheOptions } from './decision-cache'; + // The reserved test trigger: `curl -A "WebDecoy-Test/1.0" ` // always produces a labeled test detection through the real pipeline. export { diff --git a/packages/webdecoy/src/rules/filter-rule.ts b/packages/webdecoy/src/rules/filter-rule.ts index c297d41..d702c93 100644 --- a/packages/webdecoy/src/rules/filter-rule.ts +++ b/packages/webdecoy/src/rules/filter-rule.ts @@ -26,6 +26,19 @@ export class FilterRule implements Rule { } evaluate(context: RuleContext): RuleResult { + // Nearly every filter expression reads `ip.*`, which only exists once IP + // enrichment has been fetched — and that needs an API key. Without it the + // expression evaluates against nothing and quietly returns false, which + // reads as "checked, and this IP is fine". Say NOT_RUN instead. + if (!context.enrichment && this.expression.includes('ip.')) { + return { + action: 'ALLOW', + rule: this.name, + state: 'NOT_RUN', + reason: 'No IP enrichment available — filter needs an API key', + }; + } + const result = evaluate(this.ast, context); // If the filter expression evaluates to truthy, the rule triggers diff --git a/packages/webdecoy/src/rules/rate-limit-rule.ts b/packages/webdecoy/src/rules/rate-limit-rule.ts index 0b39b4e..58abba3 100644 --- a/packages/webdecoy/src/rules/rate-limit-rule.ts +++ b/packages/webdecoy/src/rules/rate-limit-rule.ts @@ -28,7 +28,10 @@ export class RateLimitRule implements Rule { } evaluate(context: RuleContext): RuleResult { - const key = this.config.keyBy ? this.config.keyBy(context) : context.ip; + // Precedence: this rule's own keyBy, then the SDK-wide characteristics, + // then the IP. `context.key` is always populated, so the last fallback only + // matters for a context built by hand. + const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip); const windowMs = this.config.window * 1000; const result = diff --git a/packages/webdecoy/src/rules/rule-engine.ts b/packages/webdecoy/src/rules/rule-engine.ts index eb7fc46..0053569 100644 --- a/packages/webdecoy/src/rules/rule-engine.ts +++ b/packages/webdecoy/src/rules/rule-engine.ts @@ -4,6 +4,7 @@ */ import { Rule, RuleContext, RuleResult, RuleEngineResult, ViolationEvent } from './types'; +import type { RuleOutcome } from '../decision'; /** Pull the wd_clearance token from a request's Cookie header, if present. */ function extractClearance(headers: Record): string | undefined { @@ -43,10 +44,30 @@ export class RuleEngine { */ evaluate(context: RuleContext): RuleEngineResult { const violations: ViolationEvent[] = []; + const results: RuleOutcome[] = []; let decidingResult: RuleResult | null = null; for (const rule of this.rules) { const result = rule.evaluate(context); + const dryRun = result.metadata?.dryRun === true; + + // Recorded for every rule, not only the ones that fired. A rule that + // allowed and a rule that never ran both used to leave no trace, and the + // difference between them is the difference between "checked and fine" + // and "never checked". + // + // A dry-run rule reports `action: 'ALLOW'` because it must not block, but + // its conclusion is DENY — that is the whole point of watching it. Reading + // the action alone would show every dry-run rule as passing, which is the + // opposite of what the operator turned it on to see. + results.push({ + rule: result.rule, + state: result.state ?? (dryRun ? 'DRY_RUN' : 'RUN'), + conclusion: dryRun || result.action !== 'ALLOW' ? 'DENY' : 'ALLOW', + action: result.action, + reason: result.reason, + metadata: result.metadata, + }); if (result.action !== 'ALLOW') { // Record violation. Tripwire hits (a real user can't reach a honeypot @@ -62,12 +83,12 @@ export class RuleEngine { reason: result.reason, clearance: result.rule === 'tripwire' ? extractClearance(context.headers) : undefined, metadata: result.metadata, - dryRun: result.metadata?.dryRun === true, + dryRun, timestamp: new Date(context.timestamp).toISOString(), }); // First non-ALLOW result that is not dry-run decides the outcome - if (!decidingResult && !result.metadata?.dryRun) { + if (!decidingResult && !dryRun) { decidingResult = result; } } @@ -80,12 +101,14 @@ export class RuleEngine { reason: decidingResult.reason, metadata: decidingResult.metadata, violations, + results, }; } return { action: 'ALLOW', violations, + results, }; } diff --git a/packages/webdecoy/src/rules/types.ts b/packages/webdecoy/src/rules/types.ts index 7503d6d..7dd6d7a 100644 --- a/packages/webdecoy/src/rules/types.ts +++ b/packages/webdecoy/src/rules/types.ts @@ -6,6 +6,7 @@ import type { AgentVerdict } from '../agent/types'; import type { EdgeVerdict } from '../edge'; import type { BotVerdict, BotCategory } from '../bots'; +import type { RuleOutcome, RuleState } from '../decision'; /** * Context available to rules during evaluation @@ -47,6 +48,12 @@ export interface RuleContext { * See {@link BotVerdict}. */ bot?: BotVerdict; + /** + * The key identifying this caller, derived from the SDK's `characteristics`. + * Keyed rules use it unless they were given their own `keyBy`. Defaults to + * the IP, so a rule can read it unconditionally. + */ + key?: string; } /** @@ -61,6 +68,15 @@ export interface RuleResult { reason?: string; /** Additional metadata */ metadata?: Record; + /** + * Set by a rule that could not evaluate because a signal it needs was absent + * — a filter rule with no IP enrichment, a Web Bot Auth rule on a request + * with no host. Such a rule returns ALLOW, and without this the result is + * indistinguishable from "checked and fine". + * + * Rules that ran leave this unset; the engine fills in `RUN` or `DRY_RUN`. + */ + state?: RuleState; } /** @@ -227,4 +243,10 @@ export interface RuleEngineResult { metadata?: Record; /** All violations generated during evaluation */ violations: ViolationEvent[]; + /** + * Every configured rule and what it concluded, in evaluation order — + * including the ones that allowed, dry-ran, or could not run. `violations` + * only ever held the non-ALLOW subset. + */ + results: RuleOutcome[]; } diff --git a/packages/webdecoy/src/rules/web-bot-auth-rule.ts b/packages/webdecoy/src/rules/web-bot-auth-rule.ts index 41cebad..eea872d 100644 --- a/packages/webdecoy/src/rules/web-bot-auth-rule.ts +++ b/packages/webdecoy/src/rules/web-bot-auth-rule.ts @@ -55,7 +55,18 @@ export class WebBotAuthRule implements Rule { evaluate(context: RuleContext): RuleResult { const verdict = context.agent; - if (!verdict || verdict.status === 'none') return this.allow(); + // No verdict at all means verification never happened — the request had no + // host to build a signature base from. That is different from `none`, which + // means it was checked and carried no signature. + if (!verdict) { + return { + action: 'ALLOW', + rule: this.name, + state: 'NOT_RUN', + reason: 'Request lacked the host information needed to verify a signature', + }; + } + if (verdict.status === 'none') return this.allow(); if (verdict.status === 'impersonation') { return this.act(this.onImpersonation, 'agent_impersonation', { diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index e7a63ea..cc1a7db 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -12,13 +12,16 @@ import { IPEnrichmentClient } from './ip-enrichment'; import { AgentVerifier } from './agent/verifier'; import type { AgentRequestInput, AgentVerdict } from './agent/types'; import { readEdgeVerdict } from './edge'; +import { Decision, newDecisionId } from './decision'; +import type { Conclusion } from './decision'; +import { deriveKey, DEFAULT_CHARACTERISTICS } from './characteristics'; +import { DecisionCache } from './decision-cache'; import { classifyUserAgent } from './bots'; import { isTestTriggerUserAgent } from './test-trigger'; import type { RuleContext, RuleEngineResult, ViolationEvent } from './rules/types'; import { WebDecoyConfig, RequestMetadata, - ProtectResult, ProtectOptions, SDKDetectionRequest, SDKDetectionResponse, @@ -26,7 +29,10 @@ import { export class WebDecoy { private client: WebDecoyClient | null; - private config: Omit, 'apiKey' | 'rules' | 'webBotAuth'> & { + private config: Omit< + Required, + 'apiKey' | 'rules' | 'webBotAuth' | 'characteristics' | 'decisionCache' + > & { apiKey?: string; }; private ruleEngine: RuleEngine | null; @@ -36,6 +42,8 @@ export class WebDecoy { private _hasAgentRules = false; private agentVerifier: AgentVerifier | null = null; private readonly webBotAuthOptions?: WebDecoyConfig['webBotAuth']; + private readonly characteristics: readonly import('./characteristics').Characteristic[]; + private readonly decisionCache: DecisionCache | null; constructor(config: WebDecoyConfig) { const hasApiKey = !!config.apiKey; @@ -77,6 +85,9 @@ export class WebDecoy { } this.webBotAuthOptions = config.webBotAuth; + this.characteristics = config.characteristics ?? DEFAULT_CHARACTERISTICS; + this.decisionCache = + config.decisionCache === false ? null : new DecisionCache(config.decisionCache ?? {}); // Rules. When none are configured, tripwires are switched on rather than // leaving the SDK with nothing to detect. @@ -177,7 +188,7 @@ export class WebDecoy { /** Build the base (synchronous) rule context from request metadata. */ private buildContext(metadata: RequestMetadata): RuleContext { - return { + const context: RuleContext = { ip: metadata.ip, path: metadata.path, method: metadata.method, @@ -194,6 +205,10 @@ export class WebDecoy { // ran. bot: classifyUserAgent(metadata.user_agent), }; + // Derived after the rest of the context exists, because a custom + // characteristic is handed the context and may read any of it. + context.key = deriveKey(context, this.characteristics); + return context; } /** @@ -291,26 +306,31 @@ export class WebDecoy { * * @param metadata - Request metadata to analyze * @param options - Optional configuration for this specific request - * @returns Protection result with decision and detection details + * @returns The decision — `conclusion`, every rule's outcome, and the + * narrowing helpers. Satisfies `ProtectResult`. */ async protect( metadata: RequestMetadata, options: ProtectOptions = {} - ): Promise { + ): Promise { // The edge verdict is attached here rather than at each return inside - // decide(), which has seven of them including two fail-open paths. It is + // decide(), which has six of them including two fail-open paths. It is // information ABOUT the request, not a product of the decision, so it must be // present on every outcome — and a per-return copy is a line someone would // eventually forget on the branch that mattered. const edge = readEdgeVerdict(metadata.headers); - const result = await this.decide(metadata, options); - return { ...result, edge }; + return (await this.decide(metadata, options)).withEdge(edge); } private async decide( metadata: RequestMetadata, options: ProtectOptions = {} - ): Promise { + ): Promise { + // Declared out here so the catch below can stamp the same id and key onto + // an ERROR decision. An error is still a decision about a caller. + const id = newDecisionId(); + let key = metadata.ip || 'unknown'; + try { // Validate required fields if (!metadata.ip) { @@ -328,74 +348,72 @@ export class WebDecoy { // and it must never fire the customer's rules — ingest marks the row // is_test and keeps it out of stats, billing, and enforcement. if (isTestTriggerUserAgent(metadata.user_agent)) { - return this.reportTestTrigger(metadata); - } - - // Evaluate rules first (if configured). Use async evaluation when a rule - // needs a pre-fetched signal — IP enrichment (filter rules) or Web Bot - // Auth verification (webBotAuth rules). Capture the agent verdict so it - // can be surfaced on the result for downstream allow decisions. - let ruleResult: RuleEngineResult | null; - let agentVerdict: AgentVerdict | undefined; - if (this.ruleEngine && (this._hasFilterRules || this._hasAgentRules)) { - const context = await this.buildAsyncContext(metadata); - agentVerdict = context.agent; - ruleResult = this.runRules(context); - } else { - ruleResult = this.evaluateRules(metadata); - } - - // If rules denied the request, return immediately without API call - if (ruleResult && ruleResult.action === 'DENY') { - return { - allowed: false, - detection: { - decision: 'block', - confidence: 100, - threat_level: 'HIGH', - bot_detected: false, - detection_id: 'rule_' + Date.now(), - rule_enforced: true, - }, - ruleResult, - agent: agentVerdict, - }; + return this.reportTestTrigger(metadata, id); } - // If rules throttled, return a throttle response - if (ruleResult && ruleResult.action === 'THROTTLE') { - return { - allowed: false, + // One context for the whole decision. Async only when a rule needs a + // pre-fetched signal — IP enrichment (filter rules) or Web Bot Auth + // verification (webBotAuth rules). + const needsAsync = this._hasFilterRules || this._hasAgentRules; + const context = needsAsync + ? await this.buildAsyncContext(metadata) + : this.buildContext(metadata); + key = context.key ?? key; + const agentVerdict: AgentVerdict | undefined = context.agent; + const ruleResult: RuleEngineResult | null = this.ruleEngine + ? this.runRules(context) + : null; + + // Rules denied or throttled: decided locally, no API call. Not cached — + // a rate limiter has to see every request to advance its window, and a + // cached tripwire hit would stop the violation being reported. + if (ruleResult && ruleResult.action !== 'ALLOW') { + const throttled = ruleResult.action === 'THROTTLE'; + return new Decision({ + conclusion: 'DENY', + reason: ruleResult.reason, detection: { decision: 'block', confidence: 100, - threat_level: 'MEDIUM', + threat_level: throttled ? 'MEDIUM' : 'HIGH', bot_detected: false, - detection_id: 'rule_' + Date.now(), + detection_id: id, rule_enforced: true, }, + id, + results: ruleResult.results, ruleResult, agent: agentVerdict, - }; + key, + }); } - // No API client — return fail-open default + // No API client — local rules are all there is, and they allowed. if (!this.client) { - return { - allowed: true, + return new Decision({ + conclusion: 'ALLOW', detection: { decision: 'allow', confidence: 0, threat_level: 'MINIMAL', bot_detected: false, - detection_id: 'local_' + Date.now(), + detection_id: id, rule_enforced: false, }, + id, + results: ruleResult?.results ?? [], ruleResult: ruleResult ?? undefined, agent: agentVerdict, - }; + key, + }); } + // A decision we already paid a round trip for. Checked here rather than + // at the top of the method so the rules still run: the limiter has to see + // every request, and a tripwire hit still has to be reported. + const cached = this.decisionCache?.get(key); + if (cached) return cached.asCached(); + // Perform local analysis (unless explicitly skipped) const localAnalysis = options.skipLocalAnalysis ? { @@ -425,19 +443,22 @@ export class WebDecoy { if (!shouldCallServer && localAnalysis.local_score < 50) { // Low risk, allow without server verification - return { - allowed: true, + return new Decision({ + conclusion: 'ALLOW', detection: { decision: 'allow', confidence: 100 - localAnalysis.local_score, threat_level: 'MINIMAL', bot_detected: false, - detection_id: 'local_' + Date.now(), + detection_id: id, rule_enforced: false, }, + id, + results: ruleResult?.results ?? [], ruleResult: ruleResult ?? undefined, agent: agentVerdict, - }; + key, + }); } // Call the detection API @@ -455,31 +476,55 @@ export class WebDecoy { }); } - return { - allowed, + // A server verdict of "challenge" is the one case that can route to the + // captcha, and it only counts when the score cleared the threshold — + // below it, the request is allowed and there is nothing to challenge. + const conclusion: Conclusion = allowed + ? 'ALLOW' + : detection.decision === 'challenge' + ? 'CHALLENGE' + : 'DENY'; + + const decision = new Decision({ + conclusion, + reason: allowed ? undefined : `Threat score ${detection.confidence} (threshold ${threshold})`, detection, + id, + results: ruleResult?.results ?? [], ruleResult: ruleResult ?? undefined, agent: agentVerdict, - }; + key, + ttl: this.decisionCache && !allowed ? this.decisionCache.lifetime : 0, + }); + + // Only the network-derived verdicts are worth remembering; see + // decision-cache.ts for why ALLOW and rule outcomes are excluded. + if (this.decisionCache) this.decisionCache.set(key, decision); + + return decision; } catch (error) { // Log error if debug is enabled if (this.config.debug) { console.error('[WebDecoy] Protection error:', error); } - // Return error result - return { - allowed: true, // Fail open to avoid blocking legitimate users + // Fail open: a security control that takes the site down when it has a + // bad day is worse than the traffic it was filtering. ERROR is a distinct + // conclusion so a caller can tell "allowed" from "never decided". + return new Decision({ + conclusion: 'ERROR', detection: { decision: 'allow', confidence: 0, threat_level: 'MINIMAL', bot_detected: false, - detection_id: 'error_' + Date.now(), + detection_id: id, rule_enforced: false, }, + id, + key, error: error instanceof Error ? error.message : 'Unknown error', - }; + }); } } @@ -494,23 +539,26 @@ export class WebDecoy { * Without an API key nothing can reach the dashboard, so the verdict says * so via `error` instead of pretending the test ran. */ - private async reportTestTrigger(metadata: RequestMetadata): Promise { + private async reportTestTrigger(metadata: RequestMetadata, id: string): Promise { const blocked: SDKDetectionResponse = { decision: 'block', confidence: 100, threat_level: 'HIGH', bot_detected: true, bot_type: 'test_trigger', - detection_id: 'test_' + Date.now(), + detection_id: id, rule_enforced: false, }; if (!this.client) { - return { - allowed: false, + return new Decision({ + id, + conclusion: 'DENY', + reason: 'Reserved test trigger', detection: blocked, - error: 'Test trigger recognized, but no apiKey is configured — nothing was reported to the dashboard.', - }; + error: + 'Test trigger recognized, but no apiKey is configured — nothing was reported to the dashboard.', + }); } try { @@ -525,15 +573,22 @@ export class WebDecoy { flags: ['test_trigger'], }, }); - return { allowed: false, detection }; + return new Decision({ + id, + conclusion: 'DENY', + reason: 'Reserved test trigger', + detection, + }); } catch (error) { // Still block — the developer asked for a visible reaction — but say // why the dashboard may show nothing. - return { - allowed: false, + return new Decision({ + id, + conclusion: 'DENY', + reason: 'Reserved test trigger', detection: blocked, error: error instanceof Error ? error.message : 'Failed to report test detection', - }; + }); } } diff --git a/packages/webdecoy/src/types.ts b/packages/webdecoy/src/types.ts index 819e5bd..6c51a44 100644 --- a/packages/webdecoy/src/types.ts +++ b/packages/webdecoy/src/types.ts @@ -4,7 +4,11 @@ */ import type { Rule } from './rules/types'; -import type { AgentVerifierOptions, AgentVerdict } from './agent/types'; +import type { AgentVerifierOptions } from './agent/types'; +import type { Characteristic } from './characteristics'; +import type { DecisionCacheOptions } from './decision-cache'; + +export type { ProtectResult, Conclusion, RuleState, RuleOutcome } from './decision'; /** * Configuration options for the Web Decoy SDK @@ -67,6 +71,32 @@ export interface WebDecoyConfig { * Defaults are sensible; override only to curate your own agent allowlist. */ webBotAuth?: AgentVerifierOptions; + + /** + * What counts as "the same caller" — the components of the key that keyed + * rules and the decision cache use. + * + * Defaults to `['ip']`. On an authenticated API the meaningful subject is + * usually not an address: + * + * ```ts + * characteristics: [(ctx) => ctx.headers['x-api-key']] + * ``` + * + * A rule's own `keyBy` still wins over this. If a characteristic is absent on + * a request the key falls back to the IP rather than bucketing every such + * request together. + */ + characteristics?: Characteristic[]; + + /** + * Reuse of decisions that cost a network round trip. `false` disables it. + * + * Only server-derived DENY and CHALLENGE verdicts are cached — never ALLOW, + * and never a rule outcome, because a rate limiter has to see every request. + * @default { ttl: 60_000, max: 10_000 } + */ + decisionCache?: DecisionCacheOptions | false; } /** @@ -181,40 +211,6 @@ export interface SDKDetectionResponse { rule_enforced: boolean; } -/** - * Result of the protect() method - */ -export interface ProtectResult { - /** Whether to allow the request */ - allowed: boolean; - - /** Detection response from the service */ - detection: SDKDetectionResponse; - - /** Error message if the request failed */ - error?: string; - - /** Rule engine result (if rules were evaluated) */ - ruleResult?: import('./rules/types').RuleEngineResult; - - /** - * Web Bot Auth verdict, present when a `webBotAuth()` rule triggered the - * local agent verification for this request. Lets middleware treat a - * `verified` agent specially (e.g. allow) without re-verifying. - */ - agent?: AgentVerdict; - - /** - * What the edge validator said about this request, parsed from - * `x-wd-clearance` and `x-wd-class`. - * - * Always present. Check `edge.present` before branching: false means the edge - * did not front this request, which is no information rather than a clean bill - * of health. - */ - edge?: import('./edge').EdgeVerdict; -} - /** * Options for the protect() method */