Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ export interface Protection {
* dropped, and `blockLogHealth()` reports those.
*/
stop: () => Promise<void>;
/**
* How often the guard passed something through without inspecting it, in the cases it can observe,
* keyed `<phase>:<reason>` (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<string, number> };
/** 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<void>;
Expand Down Expand Up @@ -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
Expand All @@ -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 <pkg>`, 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(). */
Expand Down Expand Up @@ -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<string, unknown>;
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`.
Expand Down
99 changes: 99 additions & 0 deletions tests/protect/declaration-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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 <name> { … }`. */
function declaredMembers(name: string): Set<string> {
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<string>();
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<string> {
const names = new Set<string>();
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 },
// 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.
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<string>();
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([]);
});
});
Loading