diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..932a15a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,148 @@ +# Installing WebDecoy + +Instructions for a coding agent asked to add bot protection to an application. +For the full API surface see [`llms.txt`](./llms.txt); this file is the install, +what to verify, and the mistakes that are expensive. + +## Pick the package + +| The app uses | Install | Import | +|---|---|---| +| Express | `@webdecoy/express` | `webdecoy()` middleware | +| Fastify | `@webdecoy/fastify` | `webdecoy` plugin | +| Next.js | `@webdecoy/nextjs` | `withWebDecoy()` in `middleware.ts` | +| Hono, Workers, Bun, Deno | `@webdecoy/hono` | `webdecoy()` middleware | +| Anything else with a `fetch` handler | `@webdecoy/node` | `createFetchGuard()` | + +Every adapter depends on `@webdecoy/node`, which comes in automatically. + +## Install + +```typescript +import { webdecoy } from '@webdecoy/express'; +import { tripwire, rateLimit } from '@webdecoy/node'; + +app.use(webdecoy({ + rules: [tripwire(), rateLimit({ max: 100, window: 60 })], + skipPaths: ['/health', '/metrics'], +})); +``` + +Mount it **before** the application's routes and after any body parser. No API +key is required — tripwires and rate limiting run entirely in-process. + +## Verify, and show the user the receipt + +```bash +curl -A "WebDecoy-Test/1.0" http://localhost:3000/ +``` + +`WebDecoy-Test/` is a reserved User-Agent. It always produces a labelled test +detection through the real pipeline, and never fires the application's own rules. +With an API key it appears in the dashboard; without one, the result says so +explicitly rather than pretending. + +Then confirm a real tripwire: + +```bash +curl -i http://localhost:3000/.env # 403 in enforce mode; logged in monitor mode +curl -i http://localhost:3000/ # 200, unaffected +``` + +An install is not finished until one of these has produced a visible result. +Report which one you ran and what it returned. + +## Do not do these + +- **Do not set `mode: 'enforce'` on a first install.** The default is `'monitor'` + and it is the default deliberately: the middleware records what it would have + done and serves the request. Switching to enforce is the user's decision once + they have seen a day of traffic. Say so rather than deciding for them. +- **Do not invent an API key or add one the user has not given you.** The local + rules are the whole product for a keyless install. If the user wants the + dashboard, point them at https://app.webdecoy.com and let them paste the key. +- **Do not add `filter()` without a key.** It needs IP enrichment; without one it + reports `NOT_RUN` and does nothing. +- **Do not leave a proxied app on the default `trustProxy`.** Forwarding headers + are not believed by default because the client writes the leftmost value. If + the app sits behind Cloudflare, a load balancer or a platform proxy, either set + the framework's own trust-proxy setting (Express `app.set('trust proxy', 1)`, + Fastify `{ trustProxy: 1 }`) or pass `trustProxy` to the middleware. Getting + this wrong attributes every request to the proxy and applies rate limits + site-wide. +- **Do not describe `attackSignatures()` as a WAF.** It is a small curated set of + unambiguous injection payloads. Turning on `inspect: ['body']` on a CMS will + produce false positives; start it with `dryRun: true`. +- **Do not rely on in-process rate limits across replicas.** On more than one + process the limit becomes `max × instances`. Use `upstashRateLimitStore()` or + say plainly that the limit is per-instance. + +## Common additions + +Control AI crawlers, publishing and enforcing one policy: + +```typescript +import { botPolicy } from '@webdecoy/node'; + +const policy = botPolicy({ deny: ['training_crawler'], allow: ['perplexitybot'] }); +app.get('/robots.txt', (_req, res) => res.type('text/plain').send(policy.robotsTxt())); +app.use(webdecoy({ rules: [policy.rule(), tripwire()] })); +``` + +Verify AI agents cryptographically (RFC 9421, no key, no network on the warm +path): + +```typescript +import { webBotAuth } from '@webdecoy/node'; +app.use(webdecoy({ rules: [webBotAuth(), tripwire()] })); +``` + +Shared rate limits across replicas: + +```typescript +import { rateLimit, upstashRateLimitStore } from '@webdecoy/node'; + +rateLimit({ + max: 100, + window: 60, + store: upstashRateLimitStore({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, + }), +}); +``` + +## Write a test for it + +```typescript +import { createTestHarness, get, expectDenied, expectAllowed } from '@webdecoy/node/testing'; +import { tripwire } from '@webdecoy/node'; + +const wd = createTestHarness({ rules: [tripwire()] }); + +test('a scanner is denied and a visitor is not', async () => { + expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' }); + expectAllowed(await wd.protect(get('/'))); +}); +``` + +Offline by default — an API key in the environment is ignored unless +`allowNetwork: true`, so this never files test traffic as a real detection. + +## Reading a verdict in application code + +```typescript +const decision = req.webdecoyDecision; // Express, Fastify, Next.js +const decision = c.get('webdecoyDecision'); // Hono + +decision?.conclusion // 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR' +decision?.deniedBy('tripwire') +decision?.results // every rule: RUN | DRY_RUN | NOT_RUN | CACHED +``` + +`webdecoyDecision` means the same thing in every adapter. `req.webdecoy` is the +older, narrower detection response and is still populated — do not confuse the +two. + +In monitor mode this is the only place the verdict surfaces, so an app that +wants to log or meter denials reads it here. diff --git a/CHANGELOG.md b/CHANGELOG.md index f9af499..88be733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`@webdecoy/node/testing`** — helpers for the *application's* test suite. The SDK had hundreds of tests and a customer had none: there was no supported way to write "assert this request would be denied" against your own rules, so the first time anyone learned what the middleware does to their traffic was in production. `createTestHarness()` is offline by default (an API key in the environment is ignored, so a unit test never becomes a live call or files test traffic as a real detection) and gives each harness its own rule state. `request()`/`get()`/`post()`/`botRequest()` build metadata; `expectDenied`/`expectAllowed`/`expectRuleState` assert on the decision and print every rule and its state on failure; `protectMany()` runs a rate limit to its edge without sleeping. + +- **A pluggable logger.** `logger` accepts anything with `debug`/`info`/`warn`/`error`, defaulting to the previous console behaviour. Warnings and errors are no longer gated on `debug` — a violation that failed to report is not diagnostic output. `fromPino()` wraps a pino-style logger, whose argument order is reversed; passing one directly type-checks and then silently drops every structured field. + +- **`req.webdecoyDecision`** (Express, Fastify, Next.js) and `c.get('webdecoyDecision')` (Hono) carry the full typed decision, under the same name in every adapter. `req.webdecoy` remains the narrower detection response. Populated in monitor mode too, which is where it matters — that is the only place a verdict surfaces when nothing is blocked. + +- **`llms.txt` and `AGENTS.md`.** Coding agents install dependencies now, and the repo gave them nothing to read. Both are written for that reader: the install, the reserved `WebDecoy-Test/1.0` verification one-liner, and the mistakes that are expensive — do not enable enforce mode on a first install, do not invent an API key, do not leave a proxied app on the default `trustProxy`, do not call `attackSignatures()` a WAF. + - **`@webdecoy/hono`** — middleware for Hono, which is the default on Cloudflare Workers, Bun and Deno. Those are the runtimes the rest of the stack already sits in front of: the Cloudflare edge sensor tags every request it forwards and `readEdgeVerdict()` exists so the origin can act on that tag, but there was no origin middleware there to do it. Honeytoken injection, skip paths, monitor/enforce and the 429 with `Retry-After` all work as they do elsewhere; the decision is on `c.get('webdecoy')`. - **`createFetchGuard()`** — one adapter over WHATWG `Request`/`Response`, which `@webdecoy/hono` is a thin wrapper around and which covers Bun, Deno, Astro, Nitro, SvelteKit and Remix with no package at all. Express, Fastify and Next.js had each grown their own copy of the same decision tree — skip paths, monitor/enforce, honeytoken arming, the 429, fail-open error handling — and three copies is three places for the branch that matters to differ, which is how the leftmost-`X-Forwarded-For` bug survived in two adapters after the WordPress plugin had fixed it. Included in the edge-compatibility gate. diff --git a/README.md b/README.md index 39d8179..f2187f6 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,42 @@ missing that field together. All TypeScript types are exported (`WebDecoyConfig`, `RequestMetadata`, `ProtectResult`, `Rule`, `TripwireConfig`, `RateLimitConfig`, `FilterConfig`, `Honeytoken`, …). +## Testing your rules + +```typescript +import { createTestHarness, get, expectDenied, expectAllowed } from '@webdecoy/node/testing'; +import { tripwire } from '@webdecoy/node'; + +const wd = createTestHarness({ rules: [tripwire()] }); + +test('a scanner is denied and a visitor is not', async () => { + expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' }); + expectAllowed(await wd.protect(get('/'))); +}); +``` + +The harness is **offline by default** — an API key in the environment is ignored +unless you pass `allowNetwork: true`, so a unit test never turns into a live call +or files test traffic as a real detection. Each harness gets its own rule state, +so rate-limit counters do not leak between cases. + +`protectMany(sdk, request, n)` runs a rate limit to its edge without sleeping. +Assertion failures print every rule and its state, because "expected false to be +true" tells you nothing about which of six rules was supposed to fire. + +## Logging + +Diagnostics default to `console`, with everything below `warn` gated on `debug`. +Pass any object with `debug`/`info`/`warn`/`error`: + +```typescript +new WebDecoy({ logger: myLogger }); +new WebDecoy({ logger: fromPino(pino()) }); // pino's argument order is reversed +``` + +`fromPino()` exists because passing a pino instance directly type-checks and then +silently drops every structured field. + ## Examples See [examples](./examples) for complete working setups — e.g. [express-basic](./examples/express-basic). diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..673d8bf --- /dev/null +++ b/llms.txt @@ -0,0 +1,140 @@ +# WebDecoy Node SDK + +> Deterministic bot detection for Node.js, Express, Fastify, Next.js and Hono. +> Catches scrapers with honeypot paths rather than fingerprinting. The local +> rules need no account, no API key and no network. + +The core idea: a hidden path a real user can never reach, so any request for it +is automated *by construction*. That detects intent, which a better fingerprint +cannot spoof away. Fingerprint- and challenge-based detection loses to +purpose-built stealth scrapers; a tripwire does not. + +## Install and verify in three lines + +```bash +npm install @webdecoy/express +``` + +```typescript +import express from 'express'; +import { webdecoy } from '@webdecoy/express'; +import { tripwire, rateLimit } from '@webdecoy/node'; + +const app = express(); + +app.use(webdecoy({ + rules: [tripwire(), rateLimit({ max: 100, window: 60 })], + skipPaths: ['/health'], +})); +``` + +Confirm it works: + +```bash +curl -A "WebDecoy-Test/1.0" http://localhost:3000/ +``` + +That User-Agent is reserved. It always produces a labelled test detection +through the real pipeline, and is excluded from stats, billing and enforcement. +Without an API key the verdict says so rather than pretending the test reached a +dashboard. + +## Packages + +- `@webdecoy/node` — core SDK, all rules, `createFetchGuard()` +- `@webdecoy/express` — Express middleware +- `@webdecoy/fastify` — Fastify plugin +- `@webdecoy/nextjs` — Next.js middleware +- `@webdecoy/hono` — Hono middleware (Cloudflare Workers, Bun, Deno) +- `@webdecoy/client` — browser signal collector and proof-of-work captcha +- `@webdecoy/node/testing` — test-suite helpers, offline by default + +For Bun, Deno, Astro, Nitro, SvelteKit or Remix use `createFetchGuard()` from +`@webdecoy/node` directly — no adapter package needed. + +## Rules + +All local, all keyless unless noted. First DENY or THROTTLE wins. + +- `tripwire({ paths?, prefixes?, patterns?, includeDefaults? })` — honeypot + paths. On by default when no rules are configured. +- `rateLimit({ max, window, algorithm?, keyBy?, store? })` — fixed or sliding + window. In-process by default; pass `upstashRateLimitStore()` when running + more than one replica, or the limit becomes `max × instances`. +- `bots({ categories?, agents?, ai?, allow? })` — act on self-declared agents. +- `botPolicy({ deny, allow })` — one object producing both `robotsTxt()` and + `rule()`, so the published policy and the enforced one cannot drift. +- `webBotAuth()` — verify AI-agent HTTP signatures locally (RFC 9421). Denies + impersonation of known agents. +- `attackSignatures({ inspect?, exclude? })` — a small curated set of injection + payloads. Not a WAF, and should not be described as one. +- `filter({ expression })` — expression language over IP reputation and geo. + **Requires an API key** for enrichment. +- `honeytoken()` / `siteHoneytoken()` — the hidden decoy link a tripwire guards. + The framework middleware injects it automatically when an API key is present. + +## Config that matters + +```typescript +new WebDecoy({ + apiKey: process.env.WEBDECOY_API_KEY, // optional; local rules work without it + rules: [...], + characteristics: ['ip'], // what counts as the same caller + decisionCache: { ttl: 60_000 }, // reuse of server-derived denials + logger: myLogger, // defaults to console, gated on debug +}); +``` + +Middleware options: `mode` (`'monitor'` default, `'enforce'` to block), +`skipPaths`, `trustProxy`, `getIP`, `onBlocked`, `honeytoken`. + +## What `protect()` returns + +```typescript +const d = await wd.protect(metadata); + +d.conclusion // 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR' +d.allowed // true for ALLOW and ERROR (fail open) +d.deniedBy('tripwire') // which rule, without string-matching +d.results // every rule: RUN | DRY_RUN | NOT_RUN | CACHED +d.id // 'dec_…' +``` + +`ERROR` means no verdict was reached; the request is served anyway. + +The same object is on `req.webdecoyDecision` (Express, Fastify, Next.js) and +`c.get('webdecoyDecision')` (Hono), in monitor mode too. `req.webdecoy` is the +older, narrower detection response. + +## Things to get right + +- **Default to `mode: 'monitor'`.** Do not enable enforce on a first install. + Nobody adopts a defence that breaks their site on day one. +- **Set `trustProxy` if the app is behind a proxy.** Forwarding headers are not + believed by default, because the client writes the leftmost value. Express and + Fastify defer to the framework's own trust-proxy setting; Next.js defaults to + one hop. +- **`filter()` needs an API key.** Without enrichment it reports `NOT_RUN`. +- **Rate limits are per-process** unless given a shared store. +- **`attackSignatures()` inspects path and query only** by default. Turn on + bodies or headers with `dryRun: true` first. + +## Testing an install + +```typescript +import { createTestHarness, get, expectDenied, expectAllowed } from '@webdecoy/node/testing'; + +const wd = createTestHarness({ rules: [tripwire()] }); +expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' }); +expectAllowed(await wd.protect(get('/'))); +``` + +Offline by default: an API key in the environment is ignored unless +`allowNetwork: true`, so a unit test never files traffic as a real detection. + +## Links + +- README: https://github.com/WebDecoy/node#readme +- Web Bot Auth guide: https://github.com/WebDecoy/node/blob/main/docs/verify-ai-agents-web-bot-auth.md +- Dashboard: https://app.webdecoy.com +- Docs: https://docs.webdecoy.com diff --git a/packages/express/package.json b/packages/express/package.json index eb1bc45..57c7aa3 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 12", + "lint": "eslint src --max-warnings 10", "clean": "rm -rf dist" }, "keywords": [ diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 0e89a5d..525890a 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -347,7 +347,12 @@ export function webdecoy( // rules". An earlier draft of this put the check after them and would // have shipped exactly the bug it exists to fix. if (mode === 'monitor') { - (req as any).webdecoy = result.detection; + // `webdecoy` is the detection response and has been since 0.1, so it + // stays what it is. `webdecoyDecision` is the full typed verdict — + // conclusion, every rule's outcome, deniedBy() — and carries the same + // name in every adapter, which `webdecoy` cannot. + req.webdecoy = result.detection; + req.webdecoyDecision = result; (req as any).webdecoyEdge = result.edge; (req as any).webdecoyWouldBlock = !result.allowed; return next(); @@ -381,7 +386,12 @@ export function webdecoy( // Handle the result if (result.allowed) { // Attach detection info to request for downstream use - (req as any).webdecoy = result.detection; + // `webdecoy` is the detection response and has been since 0.1, so it + // stays what it is. `webdecoyDecision` is the full typed verdict — + // conclusion, every rule's outcome, deniedBy() — and carries the same + // name in every adapter, which `webdecoy` cannot. + req.webdecoy = result.detection; + req.webdecoyDecision = result; // And what the edge validator said, typed. A handler can branch on // req.webdecoyEdge.isScript instead of string-matching x-wd-class, and // `present: false` tells it the edge was never in front of this request — @@ -415,6 +425,13 @@ declare global { detection_id: string; rule_enforced: boolean; }; + /** + * The full typed verdict: `conclusion`, every rule's outcome including the + * ones that dry-ran or never ran, and `deniedBy()`. Populated in monitor + * mode too, which is where it matters — that is the only place a verdict + * surfaces when nothing is blocked. + */ + webdecoyDecision?: import('@webdecoy/node').ProtectResult; /** What the edge validator said about this request. */ webdecoyEdge?: EdgeVerdict; } diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index d652e5e..416104c 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -186,6 +186,13 @@ interface WebDecoyDetection { declare module 'fastify' { interface FastifyRequest { webdecoy?: WebDecoyDetection; + /** + * The full typed verdict: `conclusion`, every rule's outcome including the + * ones that dry-ran or never ran, and `deniedBy()`. Populated in monitor + * mode too, which is where it matters — that is the only place a verdict + * surfaces when nothing is blocked. + */ + webdecoyDecision?: ProtectResult; /** What the edge validator said about this request. */ webdecoyEdge?: EdgeVerdict; } @@ -288,7 +295,12 @@ async function webdecoyPluginImpl( // Monitor mode: record the verdict, change nothing. Checked before the // rule branches so a THROTTLE is an observation too. if (mode === 'monitor') { + // `webdecoy` is the detection response and has been since 0.1, so it + // stays what it is. `webdecoyDecision` is the full typed verdict — + // conclusion, every rule's outcome, deniedBy() — and carries the same + // name in every adapter, which `webdecoy` cannot. req.webdecoy = result.detection as WebDecoyDetection; + req.webdecoyDecision = result; req.webdecoyEdge = result.edge; return; } @@ -321,7 +333,12 @@ async function webdecoyPluginImpl( // Handle the result if (result.allowed) { // Attach detection info to request for downstream use + // `webdecoy` is the detection response and has been since 0.1, so it + // stays what it is. `webdecoyDecision` is the full typed verdict — + // conclusion, every rule's outcome, deniedBy() — and carries the same + // name in every adapter, which `webdecoy` cannot. req.webdecoy = result.detection as WebDecoyDetection; + req.webdecoyDecision = result; req.webdecoyEdge = result.edge; } else { // Block the request diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts index 4d6a86b..1d57d63 100644 --- a/packages/hono/src/index.ts +++ b/packages/hono/src/index.ts @@ -45,6 +45,7 @@ export const WEBDECOY_CONTEXT_KEY = 'webdecoy'; declare module 'hono' { interface ContextVariableMap { webdecoy?: Decision; + webdecoyDecision?: Decision; } } @@ -64,6 +65,9 @@ export function webdecoy(options: WebDecoyHonoOptions = {}): MiddlewareHandler { // and `trustProxy: 'cloudflare'` is the stronger choice behind Cloudflare. const { decision, response } = await guard.check(c.req.raw); c.set(WEBDECOY_CONTEXT_KEY, decision); + // The same value under the name the Node adapters use, so documentation and + // application code do not have to branch on which framework they are in. + c.set('webdecoyDecision', decision); if (response) { return onBlocked ? await onBlocked(c, decision) : response; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 4f3191b..c0818b1 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 3", + "lint": "eslint src --max-warnings 2", "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 a98fb4e..5a1f9bc 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -385,7 +385,14 @@ export function withBotProtection any>( } // Attach detection info to request - (req as any).webdecoy = result.detection; + // See the adapters' note: `webdecoy` is the detection, `webdecoyDecision` + // is the full typed verdict and means the same thing everywhere. + const annotated = req as typeof req & { + webdecoy?: SDKDetectionResponse; + webdecoyDecision?: ProtectResult; + }; + annotated.webdecoy = result.detection; + annotated.webdecoyDecision = result; } catch (error) { console.error('[WebDecoy] Protection error:', error); // Fail open diff --git a/packages/webdecoy/package.json b/packages/webdecoy/package.json index 2c6a1d9..3db512a 100644 --- a/packages/webdecoy/package.json +++ b/packages/webdecoy/package.json @@ -9,14 +9,19 @@ "require": "./dist/index.js", "import": "./dist/index.mjs", "types": "./dist/index.d.ts" + }, + "./testing": { + "require": "./dist/testing.js", + "import": "./dist/testing.mjs", + "types": "./dist/testing.d.ts" } }, "files": [ "dist" ], "scripts": { - "build": "tsup src/index.ts --format cjs,esm --dts --clean", - "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "build": "tsup src/index.ts src/testing.ts --format cjs,esm --dts --clean", + "dev": "tsup src/index.ts src/testing.ts --format cjs,esm --dts --watch", "test": "jest", "lint": "eslint src --max-warnings 9", "clean": "rm -rf dist", diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 67d8c61..1f04526 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -83,6 +83,9 @@ export type { BotVerdict, BotAgent, BotCategory } from './bots'; // A guard over WHATWG Request/Response — the one adapter that covers every // runtime with a fetch handler. `@webdecoy/hono` is a thin wrapper over it; Bun, // Deno, Astro and Nitro need no package at all. +export { consoleLogger, silentLogger, fromPino } from './logger'; +export type { Logger, LogFields } from './logger'; + export { createFetchGuard } from './fetch-guard'; export type { FetchGuard, FetchGuardOptions, GuardOutcome } from './fetch-guard'; diff --git a/packages/webdecoy/src/logger.ts b/packages/webdecoy/src/logger.ts new file mode 100644 index 0000000..e88045e --- /dev/null +++ b/packages/webdecoy/src/logger.ts @@ -0,0 +1,91 @@ +/** + * Where the SDK's diagnostics go. + * + * WHY THIS EXISTS + * + * Observability was `debug: boolean` writing to `console.log`. In any real + * deployment that is either off or noise: it cannot be routed into the logger + * the app already runs, it cannot be sampled, and the lines it emits are + * unstructured strings that a log aggregator can only match on. + * + * The interface is deliberately the smallest thing every logger already + * implements — pino, winston, bunyan, `console` — so wiring one up is passing + * it, not writing an adapter. + */ + +/** A structured payload attached to a log line. */ +export type LogFields = Record; + +export interface Logger { + debug(message: string, fields?: LogFields): void; + info(message: string, fields?: LogFields): void; + warn(message: string, fields?: LogFields): void; + error(message: string, fields?: LogFields): void; +} + +const PREFIX = '[WebDecoy]'; + +/** + * The historical behaviour: `console`, and silent below `warn` unless `debug` is + * on. Kept exactly as it was so an existing install sees no change in output. + */ +export function consoleLogger(debug: boolean): Logger { + const emit = + (fn: (...args: unknown[]) => void, gated: boolean) => + (message: string, fields?: LogFields): void => { + if (gated && !debug) return; + if (fields) fn(`${PREFIX} ${message}`, fields); + else fn(`${PREFIX} ${message}`); + }; + + return { + debug: emit(console.log, true), + info: emit(console.log, true), + // Warnings and errors are not debug output. A violation that failed to + // report, or a key that was rejected, is something the operator needs to + // see whether or not they opted into diagnostics. + warn: emit(console.warn, false), + error: emit(console.error, false), + }; +} + +/** Discards everything. Useful in tests and in the testing utilities. */ +export const silentLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +/** + * Wrap a pino-style logger, which takes `(fields, message)` — the opposite + * order to this interface and to `console`. + * + * Passing a pino instance directly would type-check and then quietly mangle + * every line: pino reads the first argument as the message and treats the second + * as printf interpolation, so the structured fields vanish. Nobody notices until + * they need the log. One explicit wrapper is better than argument-order + * guesswork that is wrong for somebody. + * + * ```ts + * new WebDecoy({ logger: fromPino(pino()) }); + * ``` + */ +export function fromPino(pino: { + debug(fields: LogFields, message: string): void; + info(fields: LogFields, message: string): void; + warn(fields: LogFields, message: string): void; + error(fields: LogFields, message: string): void; +}): Logger { + return { + debug: (m, f) => pino.debug(f ?? {}, m), + info: (m, f) => pino.info(f ?? {}, m), + warn: (m, f) => pino.warn(f ?? {}, m), + error: (m, f) => pino.error(f ?? {}, m), + }; +} + +/** The configured logger, or the historical console behaviour. */ +export function resolveLogger(provided: Logger | undefined, debug: boolean): Logger { + return provided ?? consoleLogger(debug); +} diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index 982104e..03d2401 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -12,6 +12,8 @@ import { IPEnrichmentClient } from './ip-enrichment'; import { AgentVerifier } from './agent/verifier'; import type { AgentRequestInput, AgentVerdict } from './agent/types'; import { readEdgeVerdict } from './edge'; +import { resolveLogger } from './logger'; +import type { Logger } from './logger'; import { Decision, newDecisionId } from './decision'; import type { Conclusion } from './decision'; import { deriveKey, DEFAULT_CHARACTERISTICS } from './characteristics'; @@ -31,7 +33,7 @@ export class WebDecoy { private client: WebDecoyClient | null; private config: Omit< Required, - 'apiKey' | 'rules' | 'webBotAuth' | 'characteristics' | 'decisionCache' + 'apiKey' | 'rules' | 'webBotAuth' | 'characteristics' | 'decisionCache' | 'logger' > & { apiKey?: string; }; @@ -44,6 +46,8 @@ export class WebDecoy { private agentVerifier: AgentVerifier | null = null; private readonly webBotAuthOptions?: WebDecoyConfig['webBotAuth']; private readonly characteristics: readonly import('./characteristics').Characteristic[]; + /** Where diagnostics go. Never console directly — see logger.ts. */ + readonly log: Logger; private readonly decisionCache: DecisionCache | null; constructor(config: WebDecoyConfig) { @@ -69,6 +73,8 @@ export class WebDecoy { tlsRejectUnauthorized: config.tlsRejectUnauthorized ?? true, }; + this.log = resolveLogger(config.logger, this.config.debug); + // Initialize API client only when apiKey is provided if (hasApiKey) { this.client = new WebDecoyClient({ @@ -80,9 +86,7 @@ export class WebDecoy { }); } else { this.client = null; - if (this.config.debug) { - console.log('[WebDecoy] Running in local-only mode (no API key). Rules will still evaluate.'); - } + this.log.info('Running in local-only mode (no API key). Rules will still evaluate.'); } this.webBotAuthOptions = config.webBotAuth; @@ -141,15 +145,13 @@ export class WebDecoy { this.setViolationReporter(reporter); } - if (this.config.debug) { - console.log('[WebDecoy] Initialized with config:', { - apiUrl: this.config.apiUrl, - enableTLSFingerprinting: this.config.enableTLSFingerprinting, - threatScoreThreshold: this.config.threatScoreThreshold, - hasApiKey, - rulesCount: rules.length, - }); - } + this.log.debug('Initialized', { + apiUrl: this.config.apiUrl, + enableTLSFingerprinting: this.config.enableTLSFingerprinting, + threatScoreThreshold: this.config.threatScoreThreshold, + hasApiKey, + rulesCount: rules.length, + }); } /** @@ -441,9 +443,7 @@ export class WebDecoy { } : analyzeRequest(metadata); - if (this.config.debug) { - console.log('[WebDecoy] Local analysis result:', localAnalysis); - } + this.log.debug('Local analysis', { ...localAnalysis }); // Build detection request const detectionRequest: SDKDetectionRequest = { @@ -483,13 +483,11 @@ export class WebDecoy { const threshold = options.threshold ?? this.config.threatScoreThreshold; const allowed = detection.decision === 'allow' || detection.confidence < threshold; - if (this.config.debug) { - console.log('[WebDecoy] Server detection result:', { - decision: detection.decision, - confidence: detection.confidence, - allowed, - }); - } + this.log.debug('Server detection', { + decision: detection.decision, + confidence: detection.confidence, + 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 — @@ -518,10 +516,11 @@ export class WebDecoy { return decision; } catch (error) { - // Log error if debug is enabled - if (this.config.debug) { - console.error('[WebDecoy] Protection error:', error); - } + // An error here means no verdict was reached, which the operator wants to + // know about whether or not they turned debug on. + this.log.error('Protection error', { + error: error instanceof Error ? error.message : String(error), + }); // 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 diff --git a/packages/webdecoy/src/testing.test.ts b/packages/webdecoy/src/testing.test.ts new file mode 100644 index 0000000..dcf1b15 --- /dev/null +++ b/packages/webdecoy/src/testing.test.ts @@ -0,0 +1,264 @@ +import { + createTestHarness, + request, + get, + post, + botRequest, + expectDenied, + expectAllowed, + expectRuleState, + protectMany, +} from './testing'; +import { tripwire, rateLimit, filter, bots, attackSignatures } from './rules'; +import { consoleLogger, silentLogger, fromPino } from './logger'; + +describe('the request builders', () => { + it('fills in the boilerplate', () => { + const r = request(); + expect(r.method).toBe('GET'); + expect(r.ip).toBeTruthy(); + expect(r.user_agent).toMatch(/Chrome/); + }); + + it('splits a query off the path, which is where signatures look', () => { + const r = get('/search?q=hello&page=2'); + expect(r.path).toBe('/search'); + expect(r.query).toBe('q=hello&page=2'); + }); + + it('leaves query undefined when there is none', () => { + expect(get('/plain').query).toBeUndefined(); + }); + + it('builds a POST with a body', () => { + const r = post('/submit', '{"a":1}'); + expect(r).toMatchObject({ method: 'POST', path: '/submit', body: '{"a":1}' }); + }); + + it('builds a bot request', () => { + expect(botRequest('GPTBot/1.0').user_agent).toBe('GPTBot/1.0'); + }); +}); + +describe('the harness', () => { + it('does not reach the network even with a key in the environment', async () => { + // A WEBDECOY_API_KEY in CI would otherwise turn every unit test into a live + // call, and file test traffic as real detections in the dashboard. + const fetchSpy = jest.fn(); + const original = globalThis.fetch; + globalThis.fetch = fetchSpy as unknown as typeof fetch; + try { + const wd = createTestHarness({ apiKey: 'sk_live_should_be_ignored', rules: [tripwire()] }); + await wd.protect(get('/')); + await wd.protect(get('/.env')); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = original; + } + }); + + it('opts into the network explicitly', () => { + const wd = createTestHarness({ apiKey: 'sk_test_abc', allowNetwork: true }); + expect(wd.getClient()).not.toBeNull(); + }); + + it('gives each harness its own rule state', async () => { + // A shared module-level SDK leaks rate-limit counters between cases, and the + // second test to run fails for reasons belonging to the first. + const config = { rules: [rateLimit({ max: 1, window: 60, action: 'DENY' as const })] }; + const a = createTestHarness(config); + const b = createTestHarness({ rules: [rateLimit({ max: 1, window: 60, action: 'DENY' })] }); + + expectAllowed(await a.protect(get('/'))); + expectDenied(await a.protect(get('/'))); + expectAllowed(await b.protect(get('/'))); + }); + + it('is silent', async () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const wd = createTestHarness({ debug: true, rules: [tripwire()] }); + await wd.protect(get('/.env')); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('the assertions', () => { + const wd = createTestHarness({ rules: [tripwire(), attackSignatures()] }); + + it('passes on the outcomes they name', async () => { + expectDenied(await wd.protect(get('/.env'))); + expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' }); + expectAllowed(await wd.protect(get('/'))); + }); + + it('matches a reason by substring or pattern', async () => { + const d = await wd.protect(get('/?x=${jndi:ldap://e/a}')); + expectDenied(d, { reason: 'JNDI' }); + expectDenied(d, { reason: /jndi/i }); + }); + + it('fails with the rules and their states, not just true/false', async () => { + // "expected false to be true" tells you nothing about which of six rules was + // supposed to fire. + let message = ''; + try { + expectDenied(await wd.protect(get('/'))); + } catch (e) { + message = (e as Error).message; + } + expect(message).toContain('conclusion=ALLOW'); + expect(message).toContain('tripwire=ALLOW'); + expect(message).toContain('attack-signatures=ALLOW'); + }); + + it('says which rule was expected to deny when another one did', async () => { + let message = ''; + try { + expectDenied(await wd.protect(get('/.env')), { rule: 'attack-signatures' }); + } catch (e) { + message = (e as Error).message; + } + expect(message).toMatch(/Expected attack-signatures to deny/); + }); + + it('fails expectAllowed on ERROR, which is not the same as allowed', async () => { + const broken = createTestHarness({ rules: [tripwire()] }); + // No IP is a malformed request, which the SDK fails open on — allowed to + // serve, but no verdict was reached, and a test should not accept that as a + // pass. + const d = await broken.protect(request({ ip: '' })); + expect(d.conclusion).toBe('ERROR'); + expect(d.allowed).toBe(true); + expect(() => expectAllowed(d)).toThrow(/conclusion=ERROR/); + }); + + it('asserts a rule state', async () => { + const dry = createTestHarness({ rules: [tripwire({ dryRun: true })] }); + const d = await dry.protect(get('/.env')); + expectAllowed(d); + expectRuleState(d, 'tripwire', 'DRY_RUN'); + expect(() => expectRuleState(d, 'tripwire', 'RUN')).toThrow(/got DRY_RUN/); + expect(() => expectRuleState(d, 'nope', 'RUN')).toThrow(/No rule named nope/); + }); + + it('sees NOT_RUN for a filter with no enrichment', async () => { + const wd2 = createTestHarness({ rules: [filter({ expression: 'ip.tor' })] }); + expectRuleState(await wd2.protect(get('/')), 'filter:ip.tor', 'NOT_RUN'); + }); +}); + +describe('protectMany', () => { + it('runs a rate limit to its edge without sleeping', async () => { + const wd = createTestHarness({ rules: [rateLimit({ max: 3, window: 60, action: 'DENY' })] }); + const results = await protectMany(wd, get('/'), 5); + expect(results.map((r) => r.conclusion)).toEqual([ + 'ALLOW', + 'ALLOW', + 'ALLOW', + 'DENY', + 'DENY', + ]); + }); + + it('takes a factory for per-request variation', async () => { + const wd = createTestHarness({ + characteristics: [(c) => c.headers['x-api-key']], + rules: [rateLimit({ max: 1, window: 60, action: 'DENY' })], + }); + let n = 0; + const results = await protectMany(wd, () => request({ headers: { 'x-api-key': `k${n++}` } }), 3); + expect(results.every((r) => r.conclusion === 'ALLOW')).toBe(true); + }); +}); + +describe('the logger', () => { + it('gates debug and info, but never warnings or errors', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const quiet = consoleLogger(false); + quiet.debug('d'); + quiet.info('i'); + quiet.warn('w'); + expect(log).not.toHaveBeenCalled(); + // A violation that failed to report is not debug output. + expect(warn).toHaveBeenCalledWith('[WebDecoy] w'); + } finally { + log.mockRestore(); + warn.mockRestore(); + } + }); + + it('passes structured fields through', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + consoleLogger(true).debug('hello', { a: 1 }); + expect(log).toHaveBeenCalledWith('[WebDecoy] hello', { a: 1 }); + } finally { + log.mockRestore(); + } + }); + + it('routes through a provided logger instead of console', async () => { + const lines: string[] = []; + const custom = { + debug: (m: string) => lines.push(`debug:${m}`), + info: (m: string) => lines.push(`info:${m}`), + warn: (m: string) => lines.push(`warn:${m}`), + error: (m: string) => lines.push(`error:${m}`), + }; + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const { WebDecoy } = await import('./sdk'); + new WebDecoy({ logger: custom, debug: true, rules: [tripwire()] }); + expect(lines.some((l) => l.startsWith('debug:Initialized'))).toBe(true); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it('flips the argument order for a pino-style logger', () => { + const calls: unknown[][] = []; + const pino = { + debug: (...a: unknown[]) => calls.push(a), + info: (...a: unknown[]) => calls.push(a), + warn: (...a: unknown[]) => calls.push(a), + error: (...a: unknown[]) => calls.push(a), + }; + // Passing pino directly would type-check and then quietly drop every field. + fromPino(pino as never).info('hello', { a: 1 }); + expect(calls[0]).toEqual([{ a: 1 }, 'hello']); + }); + + it('silentLogger discards everything', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + silentLogger.debug('x'); + silentLogger.error('y'); + expect(log).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); +}); + +describe('the harness under real rules', () => { + it('reads naturally end to end', async () => { + const wd = createTestHarness({ + rules: [bots({ categories: ['training_crawler'] }), tripwire(), attackSignatures()], + }); + + expectDenied(await wd.protect(botRequest('GPTBot/1.0')), { rule: 'bots' }); + expectDenied(await wd.protect(get('/.git/config')), { rule: 'tripwire' }); + expectDenied(await wd.protect(get("/search?q=1' UNION SELECT p FROM u")), { + rule: 'attack-signatures', + }); + expectAllowed(await wd.protect(get('/search?q=coffee%20or%20tea'))); + expectAllowed(await wd.protect(botRequest('Googlebot/2.1'))); + }); +}); diff --git a/packages/webdecoy/src/testing.ts b/packages/webdecoy/src/testing.ts new file mode 100644 index 0000000..b722cf7 --- /dev/null +++ b/packages/webdecoy/src/testing.ts @@ -0,0 +1,205 @@ +/** + * Testing utilities, for the application's test suite rather than ours. + * + * WHY THIS EXISTS + * + * The SDK has hundreds of tests and a customer had none of them. There was no + * supported way to write "assert this request would be denied" against your own + * rules, so the first time anyone learned what the middleware does to their + * traffic was in production — which is also why `mode: 'monitor'` had to become + * the default. + * + * Three things make that test hard to write by hand, and each is solved here: + * + * - **Assembling `RequestMetadata`** is seven fields of boilerplate per case. + * - **Rate-limit tests would have to sleep.** A window is real time, and a test + * that waits sixty seconds is a test nobody runs. + * - **An API key in the environment turns a unit test into a network call**, and + * worse, files test traffic as real detections in the customer's dashboard. + * + * Import from `@webdecoy/node/testing` so none of it reaches production bundles. + * + * @example + * ```ts + * import { createTestHarness, get, expectDenied } from '@webdecoy/node/testing'; + * + * const wd = createTestHarness({ rules: [tripwire()] }); + * + * expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' }); + * expectAllowed(await wd.protect(get('/'))); + * ``` + */ + +import { WebDecoy } from './sdk'; +import type { WebDecoyConfig, RequestMetadata } from './types'; +import type { ProtectResult, RuleState } from './decision'; +import { silentLogger } from './logger'; + +/** Build request metadata without seven lines of boilerplate. */ +export function request(over: Partial = {}): RequestMetadata { + return { + method: 'GET', + path: '/', + ip: '203.0.113.1', + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', + headers: {}, + timestamp: Date.now(), + ...over, + }; +} + +/** `GET path`, with an optional query string taken from the path. */ +export function get(path: string, over: Partial = {}): RequestMetadata { + const q = path.indexOf('?'); + return request({ + method: 'GET', + path: q === -1 ? path : path.slice(0, q), + query: q === -1 ? undefined : path.slice(q + 1), + ...over, + }); +} + +/** `POST path`, with an optional text body. */ +export function post( + path: string, + body?: string, + over: Partial = {}, +): RequestMetadata { + return { ...get(path, over), method: 'POST', body }; +} + +/** A request that presents itself as a named bot. */ +export function botRequest(userAgent: string, over: Partial = {}): RequestMetadata { + return request({ user_agent: userAgent, ...over }); +} + +export interface TestHarnessOptions extends WebDecoyConfig { + /** + * Allow the harness to reach the network. Off by default. + * + * A `WEBDECOY_API_KEY` in the environment would otherwise turn a unit test + * into a live call — slow, flaky, and it files test traffic as real detections + * in the customer's dashboard. + */ + allowNetwork?: boolean; +} + +/** + * An SDK wired for tests: offline, silent, and with its own rule state. + * + * Each harness gets fresh in-memory stores, so rate-limit counters do not leak + * between test cases the way a shared module-level SDK's would. + */ +export function createTestHarness(options: TestHarnessOptions = {}): WebDecoy { + const { allowNetwork = false, ...config } = options; + return new WebDecoy({ + ...config, + apiKey: allowNetwork ? config.apiKey : undefined, + logger: config.logger ?? silentLogger, + }); +} + +function describeDecision(decision: ProtectResult): string { + const rules = decision.results + .map((r) => `${r.rule}=${r.conclusion}${r.state === 'RUN' ? '' : `(${r.state})`}`) + .join(', '); + return [ + `conclusion=${decision.conclusion}`, + decision.reason ? `reason="${decision.reason}"` : null, + rules ? `rules[${rules}]` : 'rules[none]', + ] + .filter(Boolean) + .join(' '); +} + +class AssertionError extends Error { + constructor(message: string) { + super(message); + this.name = 'WebDecoyAssertionError'; + } +} + +export interface DeniedExpectation { + /** The rule that must have denied, e.g. `'tripwire'`. */ + rule?: string; + /** A substring or pattern the reason must match. */ + reason?: string | RegExp; +} + +/** + * Assert the request was denied. + * + * The failure message prints every rule and its state, because "expected false + * to be true" tells you nothing about which of six rules was supposed to fire. + */ +export function expectDenied(decision: ProtectResult, expected: DeniedExpectation = {}): void { + if (decision.conclusion !== 'DENY') { + throw new AssertionError( + `Expected the request to be denied, but ${describeDecision(decision)}`, + ); + } + if (expected.rule && !decision.deniedBy(expected.rule)) { + throw new AssertionError( + `Expected ${expected.rule} to deny, but ${describeDecision(decision)}`, + ); + } + if (expected.reason !== undefined) { + const reason = decision.reason ?? ''; + const ok = + typeof expected.reason === 'string' + ? reason.includes(expected.reason) + : expected.reason.test(reason); + if (!ok) { + throw new AssertionError( + `Expected the reason to match ${String(expected.reason)}, got "${reason}"`, + ); + } + } +} + +/** Assert the request was allowed. `ERROR` fails: it means no verdict was reached. */ +export function expectAllowed(decision: ProtectResult): void { + if (decision.conclusion !== 'ALLOW') { + throw new AssertionError( + `Expected the request to be allowed, but ${describeDecision(decision)}`, + ); + } +} + +/** Assert a named rule reached a particular state — `DRY_RUN`, `NOT_RUN`, `CACHED`. */ +export function expectRuleState( + decision: ProtectResult, + rule: string, + state: RuleState, +): void { + const outcome = decision.results.find((r) => r.rule === rule); + if (!outcome) { + throw new AssertionError( + `No rule named ${rule} ran. ${describeDecision(decision)}`, + ); + } + if (outcome.state !== state) { + throw new AssertionError( + `Expected ${rule} to be ${state}, got ${outcome.state}. ${describeDecision(decision)}`, + ); + } +} + +/** + * Run `count` requests and return their conclusions. + * + * For rate-limit tests, which are otherwise a loop everyone writes slightly + * differently. + */ +export async function protectMany( + sdk: WebDecoy, + metadata: RequestMetadata | (() => RequestMetadata), + count: number, +): Promise { + const out: ProtectResult[] = []; + for (let i = 0; i < count; i++) { + out.push(await sdk.protect(typeof metadata === 'function' ? metadata() : { ...metadata })); + } + return out; +} diff --git a/packages/webdecoy/src/types.ts b/packages/webdecoy/src/types.ts index c483bb8..2f74703 100644 --- a/packages/webdecoy/src/types.ts +++ b/packages/webdecoy/src/types.ts @@ -7,6 +7,7 @@ import type { Rule } from './rules/types'; import type { AgentVerifierOptions } from './agent/types'; import type { Characteristic } from './characteristics'; import type { DecisionCacheOptions } from './decision-cache'; +import type { Logger } from './logger'; export type { ProtectResult, Conclusion, RuleState, RuleOutcome } from './decision'; @@ -97,6 +98,17 @@ export interface WebDecoyConfig { * @default { ttl: 60_000, max: 10_000 } */ decisionCache?: DecisionCacheOptions | false; + + /** + * Where the SDK's diagnostics go. Defaults to `console`, gated on `debug` for + * everything below `warn`. + * + * Any object with `debug`/`info`/`warn`/`error` taking `(message, fields?)` + * works. For a pino-style logger, whose argument order is the other way + * round, wrap it with `fromPino()` — passing one directly type-checks and then + * silently drops every structured field. + */ + logger?: Logger; } /**