From 1bed0435a3dc534a647e15f6ce8bcb3265b17064 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 28 Sep 2026 16:14:00 +0200 Subject: [PATCH 1/2] Declare every runtime option and keep the declarations in step Add the options the runtime reads but protect.d.ts did not declare, and coverage(). Correct the fetchImpl and reportManifest descriptions. A test now compares the option names the runtime reads, and the members the protection object carries, with the declarations in both directions. Co-Authored-By: Claude Opus 5.5 --- src/protect/protect.d.ts | 42 ++++++++++- tests/protect/declaration-drift.test.ts | 97 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 tests/protect/declaration-drift.test.ts diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index aeb0e24..8617517 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -88,6 +88,12 @@ export interface Protection { * dropped, and `blockLogHealth()` reports those. */ stop: () => Promise; + /** + * How often the guard passed something through without inspecting it, in the cases it can observe, + * keyed `:` (for example `request:body-cap`, `response:live-stream`). Zero skips means + * nothing the guard could see was bypassed, not that nothing was. + */ + coverage(): { skipped: Record }; /** Stops the rule refresh only — the poll loop and its recovery retries. The reporters and this * protection's outbound screening keep running; use `stop()` to end those too. */ stopRefresh: () => Promise; @@ -269,7 +275,8 @@ export interface CreateProtectionOptions { detectionFlushMs?: number; /** Optional Source-Host header for connector hostname checks. */ sourceHost?: string; - /** Optional fetch override (tests). */ + /** Optional fetch override for the block-log and detection reporters (tests). The rules client and + * the manifest re-post use the global `fetch`. */ fetchImpl?: typeof fetch; /** * Re-fetch and hot-swap the live rules every N ms. For long-lived runtimes that aren't restarted @@ -288,7 +295,8 @@ export interface CreateProtectionOptions { * During a refresh, also re-post the dependency manifest (the runtime counterpart to `scan`) so a * dependency added after boot — e.g. via `npm install `, which fires no npm lifecycle hook — * is reported and enforced without a restart. Defaults on when a `siteUuid` is set; set false to - * refresh rules only. Only meaningful with `refreshMs > 0` and a Pulse `siteUuid`. + * refresh rules only. Only meaningful with a Pulse `siteUuid` and a refresh path: `refreshMs > 0` or a + * refresh secret. A manual `refresh()` re-posts too when either is configured. */ reportManifest?: boolean; /** Directory the manifest re-scan reads the lockfile from during a refresh. Default process.cwd(). */ @@ -350,6 +358,36 @@ export interface CreateProtectionOptions { header?: string; isTrusted?: (ip: string) => boolean; }; + /** How long the boot-time rules fetch may take before the guard starts on its cache or bundled + * fallback. Default 5000ms. Refreshes use `refreshTimeoutMs`. */ + bootTimeoutMs?: number; + /** How long a refresh's rules fetch may take. Default 30000ms. */ + refreshTimeoutMs?: number; + /** Largest request body the Fetch path buffers for inspection. A larger body is passed through + * uninspected and counted as a `request:body-cap` skip. Default 1 MiB. The Node guard takes its own + * `node({ maxBodyBytes })`. */ + maxBodyBytes?: number; + /** + * Apply the valid part of a live rules update when some of its rules fail validation. By default the + * whole update is refused, the previous rules stay in force and the response is not cached; either + * way every rejected rule is reported through `onRuleRejected`. + */ + acceptPartialBundle?: boolean; + /** Permit whitelist entries with no `rule_id`, which apply to every rule. Refused by default. */ + allowGlobalWhitelists?: boolean; + /** Called for each delivered rule that failed validation and is not enforced. Without it, rejections + * are written to the console. */ + onRuleRejected?: (rejection: { id: string | number; reason: string; accepted?: boolean }) => void; + /** Called each time the guard passes something through without inspecting it (an oversized or + * encoded body, a live stream, a binary body, an outbound call it could not resolve). `detail` carries + * operational context such as sizes and hostnames — keep it server-side. `protection.coverage()` + * holds the running counts. */ + onSkip?: (skip: { + phase: Phase; + reason: string; + detail?: Record; + count: number; + }) => void; /** * Override the default response-phase (secret-leak) rule set. A rule that reads only response headers is * enforced from the headers, and masks only the header it matched — see `screenResponse`. diff --git a/tests/protect/declaration-drift.test.ts b/tests/protect/declaration-drift.test.ts new file mode 100644 index 0000000..1816800 --- /dev/null +++ b/tests/protect/declaration-drift.test.ts @@ -0,0 +1,97 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; +import { sourceIdentity } from '../../src/protect/rules/store.js'; + +const SITE = '00000000-0000-4000-8000-000000000000'; + +// The runtime is plain JavaScript and its types are declared by hand, so nothing but this test keeps the +// two in step. It compares what the runtime reads and returns with what the declarations say. + +const read = (path: string) => readFileSync(new URL(`../../src/protect/${path}`, import.meta.url), 'utf8'); +const declarations = read('protect.d.ts'); + +/** The member names declared directly inside `export interface { … }`. */ +function declaredMembers(name: string): Set { + const start = declarations.indexOf(`export interface ${name} {`); + expect(start, `interface ${name}`).toBeGreaterThanOrEqual(0); + const body = declarations.slice(declarations.indexOf('{', start) + 1); + const members = new Set(); + let depth = 0; + for (const line of body.split('\n')) { + if (depth === 0) { + if (line.startsWith('}')) break; + const member = /^ {2}(?:readonly )?([A-Za-z_$][\w$]*)\??(?:\(|:)/.exec(line); + if (member) members.add(member[1]); + } + for (const ch of line.replace(/\/\*.*?\*\/|\/\/.*$|"[^"]*"|'[^']*'|`[^`]*`/g, '')) { + if (ch === '{' || ch === '(' || ch === '[') depth++; + else if (ch === '}' || ch === ')' || ch === ']') depth--; + } + } + return members; +} + +/** Option names the runtime and its rule lifecycle read from the `createProtection` options object. */ +function optionsRead(): Set { + const names = new Set(); + for (const file of ['runtime.js', 'rules/source.js', 'rules/store.js']) { + for (const match of read(file).matchAll(/\boptions\.([A-Za-z_$][\w$]*)/g)) names.add(match[1]); + } + // Credential fields are read by name through one helper. + for (const match of read('runtime.js').matchAll(/readCredentialField\(options, '([A-Za-z_$][\w$]*)'\)/g)) names.add(match[1]); + return names; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('protect.d.ts', () => { + it('declares every option the runtime reads', () => { + const declared = declaredMembers('CreateProtectionOptions'); + expect([...optionsRead()].filter((name) => !declared.has(name)).sort()).toEqual([]); + }); + + it('declares no option the runtime ignores', () => { + const readNames = optionsRead(); + expect([...declaredMembers('CreateProtectionOptions')].filter((name) => !readNames.has(name)).sort()).toEqual([]); + }); + + it('declares every member of the protection object, and nothing it lacks', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 503 }))); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const bundle = { firewall: [], whitelists: [], whitelist_keys: {} }; + const configurations = [ + { rules: bundle, reportFirewallLog: false }, + { rules: bundle, reportFirewallLog: false, egress: true }, + { rules: bundle, reportFirewallLog: false, token: 'sample-token', refreshSecret: 'sample-secret', bootTimeoutMs: 50, cacheDir: false as any }, + { + // Patchstack-delivered rules from the cache, so detection reporting is on. + siteUuid: SITE, + pulseAuth: 'sample-credential', + reportManifest: false, + bootTimeoutMs: 50, + fetchImpl: async () => new Response('{}', { status: 503 }), + ruleCache: { + read: async () => ({ bundle, etag: null, source: await sourceIdentity({ siteUuid: SITE }) }), + write: async () => {}, + }, + }, + ]; + const present = new Set(); + for (const options of configurations) { + const protection: any = await createProtection(options); + for (let o = protection; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) { + for (const key of Object.getOwnPropertyNames(o)) { + const descriptor = Object.getOwnPropertyDescriptor(o, key)!; + const value = descriptor.get ? descriptor.get.call(protection) : descriptor.value; + if (value !== undefined) present.add(key); + } + } + protection.uninstallEgress?.(); + await protection.stop(); + } + const declared = declaredMembers('Protection'); + expect([...present].filter((name) => !declared.has(name)).sort()).toEqual([]); + expect([...declared].filter((name) => !present.has(name)).sort()).toEqual([]); + }); +}); From bf7d553b15326a94271e4459613f15075c611b33 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 28 Sep 2026 16:16:25 +0200 Subject: [PATCH 2/2] Cover the block-log configuration in the declaration check Co-Authored-By: Claude Opus 5.5 --- tests/protect/declaration-drift.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/protect/declaration-drift.test.ts b/tests/protect/declaration-drift.test.ts index 1816800..8041224 100644 --- a/tests/protect/declaration-drift.test.ts +++ b/tests/protect/declaration-drift.test.ts @@ -63,6 +63,8 @@ describe('protect.d.ts', () => { const configurations = [ { rules: bundle, reportFirewallLog: false }, { rules: bundle, reportFirewallLog: false, egress: true }, + // Block-log reporting on. + { rules: bundle, apiKey: 'samplesamplesamplesamplesamplesamplesamp-7', fetchImpl: async () => new Response('{}') }, { rules: bundle, reportFirewallLog: false, token: 'sample-token', refreshSecret: 'sample-secret', bootTimeoutMs: 50, cacheDir: false as any }, { // Patchstack-delivered rules from the cache, so detection reporting is on.