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 */