From 82777891afaa0b4b6f82b031dbcc8d569ab5d918 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 31 Aug 2026 17:03:48 +0200 Subject: [PATCH 01/33] Gate security-event reporting on platform enrolment, not a config flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection reporting is retained security-event evidence for sites the platform manages. It now turns on for an enrolled site running managed rules with a credential, and stays off everywhere else — a local install, a guard running its caller's own bundle, a site with no credential. `reportingState` holds the whole decision as a pure function, so every input combination is enumerable in a test rather than reachable only by constructing a guard. Six states, each distinguishable at the platform, because "no events arrived" otherwise covers nothing matched, reporting switched off, never enrolled, and delivery broken. Two precedence rules carry meaning: An explicit opt-out outranks everything, so a deployment that switched reporting off is told that, not that it lacks a credential it never needed. `PATCHSTACK_REPORT_DETECTIONS` and `PATCHSTACK_TELEMETRY` stay distinguishable so an operator who set one is not sent to check the other. The credential is checked before the rule origin, because a missing credential is what causes managed rules to be missing: the fetch is refused and resolution falls back to the caller's bundle or to nothing. Asking about the origin first would report `no-managed-rules` for a site whose real problem is fixable. `reportDetections` becomes an opt-out only. It can switch reporting off; it cannot switch it on, because whether a site is managed is the platform's answer and a guard that could self-declare it would report against rule ids the platform never issued. Rule resolution now reports which leg supplied the rules in force — `api`, `cache`, `bundled` or `empty` — separately from whether resolution was clean. `cache` counts as managed: the rules came from the platform, just not on this call, and excluding it would silence reporting for the sites whose delivery is degraded and whose evidence is most worth having. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/reporting-state.js | 126 ++++++++++++++++++++ src/protect/rules/source.js | 61 +++++++--- src/protect/runtime.js | 55 +++++---- tests/protect/detections.test.ts | 2 +- tests/protect/refresh-backoff.test.ts | 4 +- tests/protect/reporting-state.test.ts | 159 ++++++++++++++++++++++++++ tests/protect/rule-origin.test.ts | 127 ++++++++++++++++++++ 7 files changed, 489 insertions(+), 45 deletions(-) create mode 100644 src/protect/reporting-state.js create mode 100644 tests/protect/reporting-state.test.ts create mode 100644 tests/protect/rule-origin.test.ts diff --git a/src/protect/reporting-state.js b/src/protect/reporting-state.js new file mode 100644 index 00000000..cf04b7fb --- /dev/null +++ b/src/protect/reporting-state.js @@ -0,0 +1,126 @@ +/** + * Whether this guard reports security events, and if not, why not. + * + * Detection reporting is retained security-event evidence for sites the platform manages, not + * lightweight telemetry. So it turns on for an enrolled site with managed rules, and stays off + * everywhere else — a local install, a guard running its caller's own bundle, a site with no + * credential. + * + * The state is a single value with a reason built into it, because "no events arrived" has several + * causes that look identical from the platform: nothing matched, reporting was switched off, the site + * was never enrolled, or delivery is broken. A dashboard that cannot separate them has to describe all + * of them every time. The state is declared on the rules fetch the guard already makes, so the platform + * learns it without an extra request and without waiting for a rule to fire. + */ + +/** + * Reporting states, and what each licenses. + * + * Only `on` reports. Every other value is a reason, and each is distinguishable at the platform so the + * dashboard can say which one it is. + */ +export const REPORTING_STATES = Object.freeze([ + /** Enrolled, managed rules, credential present, not opted out. Events are sent. */ + 'on', + /** `PATCHSTACK_REPORT_DETECTIONS=0`. An explicit, per-deployment opt-out. */ + 'disabled-by-config', + /** `PATCHSTACK_TELEMETRY=0`. The broader switch, which covers this along with everything else. */ + 'disabled-by-telemetry-opt-out', + /** No site identity: a local or unenrolled install. Nothing to report against. */ + 'not-enrolled', + /** + * A site identity, but the rules running are not the platform's — the caller's own bundle, or none. + * There is no managed rule document to attribute a detection to, so a report would name a rule id the + * platform never issued. + */ + 'no-managed-rules', + /** Enrolled with managed rules, but no credential resolved, so a report would be refused. */ + 'unavailable-no-credential', +]); + +/** + * Read an environment opt-out. + * + * Absent and empty both mean "not set" rather than "off": an unset variable is the default state, and a + * deployment that exports an empty value has not made a choice. + */ +function optedOut(value) { + if (value === undefined || value === null || value === '') return false; + + return /^(0|false|off|no)$/i.test(String(value)); +} + +/** + * Decide the reporting state. + * + * Pure, and separate from the runtime, so every combination can be enumerated in a test rather than + * reached by constructing a guard. The order of the checks is the meaning: an explicit opt-out outranks + * everything, because a deployment that switched reporting off should be told that is why — not that it + * lacks a credential it never needed. + * + * @param {{ + * siteUuid?: unknown, + * ruleOrigin?: 'api'|'cache'|'bundled'|'empty', + * hasCredential?: boolean, + * configOptOut?: boolean, + * env?: Record, + * }} input + * @returns {{ state: typeof REPORTING_STATES[number], reports: boolean }} + */ +export function reportingState(input) { + const env = input.env ?? (typeof process !== 'undefined' ? process.env : undefined) ?? {}; + + // Explicit opt-outs first, and reported distinctly. Collapsing them would tell an operator who set + // one variable to check the other. + // + // The programmatic flag is an opt-out ONLY. `reportDetections: false` switches reporting off; + // `true` cannot switch it on, because whether a site is managed is the platform's answer and not a + // caller's to assert. A guard that could self-declare managed status would report against rule ids + // the platform never issued. + if (input.configOptOut === true) return { state: 'disabled-by-config', reports: false }; + if (optedOut(env.PATCHSTACK_REPORT_DETECTIONS)) return { state: 'disabled-by-config', reports: false }; + if (optedOut(env.PATCHSTACK_TELEMETRY)) return { state: 'disabled-by-telemetry-opt-out', reports: false }; + + const siteUuid = input.siteUuid; + if (typeof siteUuid !== 'string' || siteUuid === '') return { state: 'not-enrolled', reports: false }; + + // The credential is checked BEFORE the managed-rules question, because a missing credential is what + // causes managed rules to be missing: the rules fetch is refused, resolution falls back to the + // caller's bundle or to nothing, and the origin is then `bundled` or `empty`. Asking about the origin + // first would report `no-managed-rules` for a site whose real and fixable problem is the credential, + // sending an operator to look for an enrolment that already exists. + if (input.hasCredential !== true) return { state: 'unavailable-no-credential', reports: false }; + + // `cache` counts as managed: the rules came from the platform, just not on this call. Excluding it + // would silence reporting for exactly the sites whose delivery is degraded — the ones whose evidence + // is most worth having. + const managed = input.ruleOrigin === 'api' || input.ruleOrigin === 'cache'; + if (!managed) return { state: 'no-managed-rules', reports: false }; + + return { state: 'on', reports: true }; +} + +/** + * A human-readable reason for `protect --check` and startup diagnostics. + * + * Every non-reporting state gets a sentence, because the state name alone is a label and an operator + * asking "why is nothing arriving" needs the answer, not the category. + */ +export function explainReportingState(state) { + switch (state) { + case 'on': + return 'Security events are reported to Patchstack for this site.'; + case 'disabled-by-config': + return 'Reporting is off because PATCHSTACK_REPORT_DETECTIONS is set to a false value.'; + case 'disabled-by-telemetry-opt-out': + return 'Reporting is off because PATCHSTACK_TELEMETRY is set to a false value, which covers all telemetry.'; + case 'not-enrolled': + return 'Reporting is off because this install has no site identity — it is not enrolled in Patchstack-managed mitigation.'; + case 'no-managed-rules': + return 'Reporting is off because the rules in force did not come from Patchstack, so a detection could not be attributed to a managed rule.'; + case 'unavailable-no-credential': + return 'Reporting is unavailable because no API credential resolved, so a report would be refused.'; + default: + return `Unrecognised reporting state: ${String(state)}.`; + } +} diff --git a/src/protect/rules/source.js b/src/protect/rules/source.js index 3314dfa8..a64cfc7d 100644 --- a/src/protect/rules/source.js +++ b/src/protect/rules/source.js @@ -42,8 +42,31 @@ function reportRejections(rejected, options, label) { } /** A bundle plus the outcome of the attempt that produced it. `source` is never written to the store. */ -function fromSource(bundle, reason) { - return reason === undefined ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } }; +/** + * Wrap a resolved bundle with where it came from and whether the resolution was clean. + * + * `origin` is separate from `ok` because they answer different questions and a caller needs both. + * `ok: false` says the resolution hit a problem; `origin` says which leg actually supplied the rules + * that are now running: + * + * `api` delivered by the platform on this call + * `cache` last-known-good from the store — still platform-delivered, just not on this call + * `bundled` the caller's own `rules` option, which the platform never saw + * `empty` nothing at all + * + * Detection reporting depends on this distinction. Reporting is for sites the platform manages, so a + * guard running bundled or empty rules has nothing to report against: the platform has no rule document + * to attribute a hit to, and a detection naming a rule id it never issued is not evidence of anything. + * + * @param {object} bundle + * @param {'api'|'cache'|'bundled'|'empty'} origin + * @param {string} [reason] + */ +function fromSource(bundle, origin, reason) { + return { + ...bundle, + source: reason === undefined ? { ok: true, origin } : { ok: false, origin, reason }, + }; } export async function resolveRules(options, store, ctx = {}) { @@ -55,66 +78,66 @@ export async function resolveRules(options, store, ctx = {}) { const prior = await store.read(); // { bundle, etag } | null const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true }); const res = await client.getRules(); - if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options)); + if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), 'cache'); if (res.success && !res.notModified) { const rejected = liveUpdateRejections(res, options); if (rejected.length > 0) { reportRejections(rejected, options, 'rule update rejected'); // Reached the source and refused what it sent. Not ok: the running rules are not the delivered // ones, and asking again at the normal interval re-downloads the same rejected bundle. - if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), 'update rejected'); - if (options.rules) return fromSource(normalizeBundle(options.rules, options), 'update rejected'); - return fromSource(emptyBundle(), 'update rejected'); + if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), 'cache', 'update rejected'); + if (options.rules) return fromSource(normalizeBundle(options.rules, options), 'bundled', 'update rejected'); + return fromSource(emptyBundle(), 'empty', 'update rejected'); } const bundle = normalizeBundle(res, options); await store.write({ bundle, etag: res.etag ?? null }); - return fromSource(bundle); + return fromSource(bundle, 'api'); } if (prior?.bundle) { notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? 'no usable response'}); using cached bundle`), 'onError'); - return fromSource(normalizeBundle(prior.bundle, options), res.error ?? 'no usable response'); + return fromSource(normalizeBundle(prior.bundle, options), 'cache', res.error ?? 'no usable response'); } if (options.rules) { notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? 'no usable response'}); using bundled fallback`), 'onError'); - return fromSource(normalizeBundle(options.rules, options), res.error ?? 'no usable response'); + return fromSource(normalizeBundle(options.rules, options), 'bundled', res.error ?? 'no usable response'); } notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? 'no usable response'}); no cache — running with no rules`), 'onError'); - return fromSource(emptyBundle(), res.error ?? 'no usable response'); + return fromSource(emptyBundle(), 'empty', res.error ?? 'no usable response'); } if (options.token) { const prior = await store.read(); const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs }); const res = await client.getRules(); - if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options)); + if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), 'cache'); if (res.success && !res.notModified) { const rejected = liveUpdateRejections(res, options); if (rejected.length > 0) { reportRejections(rejected, options, 'rule update rejected'); // Reached the source and refused what it sent. Not ok: the running rules are not the delivered // ones, and asking again at the normal interval re-downloads the same rejected bundle. - if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), 'update rejected'); - if (options.rules) return fromSource(normalizeBundle(options.rules, options), 'update rejected'); - return fromSource(emptyBundle(), 'update rejected'); + if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), 'cache', 'update rejected'); + if (options.rules) return fromSource(normalizeBundle(options.rules, options), 'bundled', 'update rejected'); + return fromSource(emptyBundle(), 'empty', 'update rejected'); } const bundle = normalizeBundle(res, options); await store.write({ bundle, etag: res.etag ?? null }); - return fromSource(bundle); + return fromSource(bundle, 'api'); } if (prior?.bundle) { notify(options.onError, new Error(`rule fetch failed (${res.error ?? 'no usable response'}); using cached bundle`), 'onError'); - return fromSource(normalizeBundle(prior.bundle, options), res.error ?? 'no usable response'); + return fromSource(normalizeBundle(prior.bundle, options), 'cache', res.error ?? 'no usable response'); } notify(options.onError, new Error(`rule fetch failed (${res.error ?? 'no usable response'}); no cache — running with no rules`), 'onError'); - return fromSource(emptyBundle(), res.error ?? 'no usable response'); + return fromSource(emptyBundle(), 'empty', res.error ?? 'no usable response'); } // No live source configured, so the bundle IS the source and cannot be behind one. if (options.rules) { - return fromSource(normalizeBundle(options.rules, options)); + return fromSource(normalizeBundle(options.rules, options), 'bundled'); } - return fromSource(emptyBundle()); + return fromSource(emptyBundle(), 'empty'); } // Every rule path (live fetch, cache, bundled fallback) funnels through here, so this is where the diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 5a14a6ea..c43cf380 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -29,6 +29,7 @@ import { makeStore } from './rules/store.js'; import { resolveRules } from './rules/source.js'; import { startRefresh, makeRefreshHandler } from './rules/refresh.js'; import { createDetectionReporter } from './detections.js'; +import { reportingState } from './reporting-state.js'; import { notify } from './notify.js'; import { createFirewallLogReporter, resolveApiBase, telemetryEnabled } from './firewall-log.js'; @@ -152,30 +153,36 @@ export async function createProtection(options = {}) { // reporter built without one queues events, posts them, and is refused — spending an outbound request // per batch to accomplish nothing, while `reportDetections: true` in the config says reporting is on. // Refusing to build it is the honest outcome; `protection.detectionReporting` says which it is. - let detectionReporting = 'off'; - if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) { - if (!pulseAuth) { - detectionReporting = 'unavailable-no-credential'; - const message = - 'Patchstack: detection reporting is enabled for site ' + - options.siteUuid + - ' but no API credential resolved, so no report could be delivered. Reporting is off.'; - notify(onError, new Error(message), 'onError'); - console.warn(message); - } else { - detectionReporting = 'on'; - detections = createDetectionReporter({ - siteUuid: options.siteUuid, - baseUrl: options.pulseRulesUrl, - pulseAuth, - // The bundle the guard is actually running, so a hit can be attributed to the rules that produced - // it rather than to whatever is current when the report is read. Kept current across refreshes — - // see the refresh tick below. - rulesEtag: (await store.read())?.etag ?? null, - fetchImpl: options.fetchImpl, - flushMs: options.detectionFlushMs, - }); - } + // + // Derived from enrolment rather than from a config flag: reporting is on for a site the platform + // manages, and off everywhere else. `reportingState` holds the whole decision so every combination is + // enumerable in a test instead of reachable only by constructing a guard. + const reporting = reportingState({ + siteUuid: options.siteUuid, + ruleOrigin: bundle.source?.origin, + hasCredential: Boolean(pulseAuth), + configOptOut: options.reportDetections === false, + }); + let detectionReporting = reporting.state; + if (reporting.state === 'unavailable-no-credential') { + const message = + 'Patchstack: this site is enrolled and running managed rules, but no API credential resolved, ' + + 'so no security event could be delivered. Reporting is off.'; + notify(onError, new Error(message), 'onError'); + console.warn(message); + } + if (reporting.reports) { + detections = createDetectionReporter({ + siteUuid: options.siteUuid, + baseUrl: options.pulseRulesUrl, + pulseAuth, + // The bundle the guard is actually running, so a hit can be attributed to the rules that produced + // it rather than to whatever is current when the report is read. Kept current across refreshes — + // see the refresh tick below. + rulesEtag: (await store.read())?.etag ?? null, + fetchImpl: options.fetchImpl, + flushMs: options.detectionFlushMs, + }); } // Mode is mutable so a Pulse refresh can flip dry-run ↔ block when SaaS enables production. // Precedence: PATCHSTACK_MODE env (local override) > API enforcement > options.mode > dry-run. diff --git a/tests/protect/detections.test.ts b/tests/protect/detections.test.ts index e9dc6e41..805c5440 100644 --- a/tests/protect/detections.test.ts +++ b/tests/protect/detections.test.ts @@ -484,7 +484,7 @@ describe('reporting that cannot be delivered', () => { expect(p.detectionReporting).toBe('unavailable-no-credential'); expect(p.detectionHealth, 'no reporter means no health to report').toBeUndefined(); expect(posted.some((url) => url.includes('/detections/'))).toBe(false); - expect(warnings.some((m) => m.includes('detection reporting is enabled'))).toBe(true); + expect(warnings.some((m) => m.includes('no API credential resolved'))).toBe(true); p.stop(); }); diff --git a/tests/protect/refresh-backoff.test.ts b/tests/protect/refresh-backoff.test.ts index e43caf3a..2258a3ea 100644 --- a/tests/protect/refresh-backoff.test.ts +++ b/tests/protect/refresh-backoff.test.ts @@ -129,7 +129,9 @@ describe('the refresh loop', () => { reportManifest: false, }); - expect(await p.refresh()).toEqual({ ok: true }); + // Exact, including the origin: a successful refresh took the rules from the platform on this call, + // which is what makes the site's detections attributable to a managed rule. + expect(await p.refresh()).toEqual({ ok: true, origin: 'api' }); state.fail = true; const failed = await p.refresh(); diff --git a/tests/protect/reporting-state.test.ts b/tests/protect/reporting-state.test.ts new file mode 100644 index 00000000..87192c3b --- /dev/null +++ b/tests/protect/reporting-state.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { + REPORTING_STATES, + explainReportingState, + reportingState, +} from '../../src/protect/reporting-state.js'; + +/** + * Every combination that decides whether a guard reports security events. + * + * Enumerated rather than sampled. The states are the difference between retained evidence being + * collected and not, and between a dashboard saying "nothing matched" and "reporting is off" — so each + * input combination has one defined answer and there is no combination without one. + */ +type Input = Parameters[0]; + +const base: Input = { siteUuid: 'site-1', ruleOrigin: 'api', hasCredential: true, env: {} }; + +describe('reporting state', () => { + it('reports only for an enrolled site running managed rules with a credential', () => { + expect(reportingState(base)).toEqual({ state: 'on', reports: true }); + }); + + it('treats cached platform rules as managed', () => { + // Excluding `cache` would silence reporting for sites whose delivery is degraded — the ones whose + // evidence is most worth having. + expect(reportingState({ ...base, ruleOrigin: 'cache' })).toEqual({ state: 'on', reports: true }); + }); + + it.each([ + ['bundled', 'no-managed-rules'], + ['empty', 'no-managed-rules'], + ] as const)('does not report when the rules came from %s', (origin, expected) => { + // No managed rule document exists to attribute a detection to. + expect(reportingState({ ...base, ruleOrigin: origin })).toEqual({ state: expected, reports: false }); + }); + + it('blames the credential, not the rule origin, when both are missing', () => { + // A missing credential is what CAUSES managed rules to be missing: the fetch is refused and + // resolution falls back to the caller's bundle or to nothing. Reporting the origin first would send + // an operator looking for an enrolment they already have. + expect( + reportingState({ ...base, hasCredential: false, ruleOrigin: 'empty' }).state, + ).toBe('unavailable-no-credential'); + }); + + it.each(['', undefined, null, 42, {}])('does not report without a site identity (%s)', (siteUuid) => { + expect(reportingState({ ...base, siteUuid } as Input)).toEqual({ state: 'not-enrolled', reports: false }); + }); + + it('does not report without a credential, and says so distinctly', () => { + // Distinct from "off": the deployment intends to report and cannot, which is a delivery problem + // rather than a choice. + expect(reportingState({ ...base, hasCredential: false })).toEqual({ + state: 'unavailable-no-credential', + reports: false, + }); + }); + + it.each(['0', 'false', 'off', 'no', 'FALSE', 'Off'])( + 'honours PATCHSTACK_REPORT_DETECTIONS=%s', + (value) => { + expect(reportingState({ ...base, env: { PATCHSTACK_REPORT_DETECTIONS: value } })).toEqual({ + state: 'disabled-by-config', + reports: false, + }); + }, + ); + + it.each(['', undefined, '1', 'true', 'on', 'yes', 'anything-else'])( + 'does not read PATCHSTACK_REPORT_DETECTIONS=%s as an opt-out', + (value) => { + // An unset or empty variable is the default, not a choice; and only the false-ish words switch it + // off, so a deployment setting it to any other value is not silently disabling evidence. + expect(reportingState({ ...base, env: { PATCHSTACK_REPORT_DETECTIONS: value } }).reports).toBe(true); + }, + ); + + it('keeps the two opt-outs distinguishable', () => { + // An operator who set one variable must not be told to check the other. + expect(reportingState({ ...base, env: { PATCHSTACK_TELEMETRY: '0' } }).state).toBe( + 'disabled-by-telemetry-opt-out', + ); + expect(reportingState({ ...base, env: { PATCHSTACK_REPORT_DETECTIONS: '0' } }).state).toBe( + 'disabled-by-config', + ); + }); + + it('reports an explicit opt-out ahead of a missing credential', () => { + // The order is the meaning: a deployment that switched reporting off should be told that is why, + // not that it lacks a credential it never needed. + expect( + reportingState({ + ...base, + hasCredential: false, + siteUuid: undefined, + env: { PATCHSTACK_REPORT_DETECTIONS: '0' }, + }).state, + ).toBe('disabled-by-config'); + }); + + it.each([true, false, undefined])('honours configOptOut=%s as an opt-out only', (configOptOut) => { + // The programmatic flag can switch reporting off. It must never switch it on: whether a site is + // managed is the platform's answer, and a guard that could self-declare it would report against rule + // ids the platform never issued. + const offSite = { siteUuid: undefined, ruleOrigin: 'bundled', hasCredential: false, env: {}, configOptOut } as Input; + const onSite = { ...base, configOptOut } as Input; + + expect(reportingState(offSite).reports, 'an unmanaged site never reports').toBe(false); + expect(reportingState(onSite).reports).toBe(configOptOut !== true); + if (configOptOut === true) { + expect(reportingState(onSite).state).toBe('disabled-by-config'); + } + }); + + it('has exactly one answer for every combination of inputs', () => { + // Exhaustive over the axes. A combination with no defined state would surface as reporting silently + // on or silently off depending on which check happened to fall through. + const origins = ['api', 'cache', 'bundled', 'empty', undefined] as const; + const envs = [ + {}, + { PATCHSTACK_REPORT_DETECTIONS: '0' }, + { PATCHSTACK_TELEMETRY: '0' }, + { PATCHSTACK_REPORT_DETECTIONS: '0', PATCHSTACK_TELEMETRY: '0' }, + ]; + let count = 0; + + for (const siteUuid of ['site-1', '', undefined]) { + for (const ruleOrigin of origins) { + for (const hasCredential of [true, false]) { + for (const env of envs) { + for (const configOptOut of [true, false, undefined]) { + const result = reportingState({ siteUuid, ruleOrigin, hasCredential, env, configOptOut } as Input); + count++; + + expect(REPORTING_STATES).toContain(result.state); + // `reports` is true for exactly one state, so the two can never disagree. + expect(result.reports).toBe(result.state === 'on'); + // And an opt-out is absolute: no other input combination can override it. + if (configOptOut === true) expect(result.reports).toBe(false); + } + } + } + } + } + + expect(count).toBe(3 * 5 * 2 * 4 * 3); + }); + + it('explains every state it can produce', () => { + // A state name is a label; an operator asking why nothing arrived needs the sentence. + for (const state of REPORTING_STATES) { + const explanation = explainReportingState(state); + + expect(explanation.length).toBeGreaterThan(20); + expect(explanation).not.toContain('Unrecognised'); + } + }); +}); diff --git a/tests/protect/rule-origin.test.ts b/tests/protect/rule-origin.test.ts new file mode 100644 index 00000000..5fb358d9 --- /dev/null +++ b/tests/protect/rule-origin.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolveRules } from '../../src/protect/rules/source.js'; + +/** + * Where the rules in force came from. + * + * This is the fact detection reporting is gated on: security events are collected for sites the platform + * manages, so a guard must be able to say whether the rules that produced a hit were the platform's. + * Mislabelling the caller's own bundle as platform-delivered would start collecting retained evidence + * for a site that never enrolled — and mislabelling the other way would silently collect nothing for one + * that did. + * + * `ok` and `origin` answer different questions and both are asserted: `ok` is whether resolution was + * clean, `origin` is which leg supplied the rules that are now running. A degraded resolution that fell + * back to cache is `ok: false` with `origin: 'cache'` — still managed rules. + */ +const BUNDLE = { + firewall: [{ id: 'r1', title: 't', rule_v2: [{ parameter: 'get.q', match: { type: 'contains', value: 'x' } }] }], + whitelists: [], +}; + +/** A store that starts empty unless primed, and records what was written. */ +function memoryStore(initial: unknown = null) { + let held: any = initial; + + return { + read: async () => held, + write: async (next: any) => { held = next; }, + get held() { return held; }, + }; +} + +const ok = (body: unknown, etag = '"v1"') => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json', ETag: etag } }); + +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('the origin of the rules in force', () => { + it('is `api` when the platform delivered them on this call', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ok({ ...BUNDLE, enforcement: 'dry-run' }))); + + const res: any = await resolveRules({ siteUuid: 's1', pulseRulesUrl: 'https://x.test/p' }, memoryStore()); + + expect(res.source).toEqual({ ok: true, origin: 'api' }); + }); + + it('is `cache` when the platform revalidated with no change', async () => { + // 304: the running rules are the platform's, taken from the store rather than the wire. + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 304 }))); + const store = memoryStore({ bundle: BUNDLE, etag: '"v1"' }); + + const res: any = await resolveRules({ siteUuid: 's1', pulseRulesUrl: 'https://x.test/p' }, store); + + expect(res.source).toEqual({ ok: true, origin: 'cache' }); + }); + + it('is `cache` when the fetch failed and last-known-good applied', async () => { + // Degraded, and still managed. Reporting stays on for exactly these sites: their evidence is the + // most worth having. + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('unreachable'); })); + const store = memoryStore({ bundle: BUNDLE, etag: '"v1"' }); + + const res: any = await resolveRules( + { siteUuid: 's1', pulseRulesUrl: 'https://x.test/p', onError: () => {} }, + store, + ); + + expect(res.source.origin).toBe('cache'); + expect(res.source.ok, 'a fallback is not a clean resolution').toBe(false); + }); + + it('is `bundled` when the fetch failed and the caller supplied its own rules', async () => { + // The platform never saw these rules, so it has no document to attribute a detection to. + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('unreachable'); })); + + const res: any = await resolveRules( + { siteUuid: 's1', pulseRulesUrl: 'https://x.test/p', rules: BUNDLE, onError: () => {} }, + memoryStore(), + ); + + expect(res.source.origin).toBe('bundled'); + }); + + it('is `bundled` when no live source is configured at all', async () => { + // A local install running its own rules: the common unenrolled shape. + const res: any = await resolveRules({ rules: BUNDLE }, memoryStore()); + + expect(res.source).toEqual({ ok: true, origin: 'bundled' }); + }); + + it('is `empty` when there is nothing anywhere', async () => { + const res: any = await resolveRules({}, memoryStore()); + + expect(res.source).toEqual({ ok: true, origin: 'empty' }); + expect(res.firewall).toEqual([]); + }); + + it('is `empty` when the fetch failed with no cache and no bundle', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('unreachable'); })); + + const res: any = await resolveRules( + { siteUuid: 's1', pulseRulesUrl: 'https://x.test/p', onError: () => {} }, + memoryStore(), + ); + + expect(res.source.origin).toBe('empty'); + expect(res.source.ok).toBe(false); + }); + + it('never reports an origin the reporting gate does not know', async () => { + // The gate treats `api` and `cache` as managed and everything else as not. An origin outside the set + // would fall to "not managed" and silently disable reporting for a managed site. + const known = new Set(['api', 'cache', 'bundled', 'empty']); + vi.stubGlobal('fetch', vi.fn(async () => ok(BUNDLE))); + + for (const options of [ + {}, + { rules: BUNDLE }, + { siteUuid: 's1', pulseRulesUrl: 'https://x.test/p' }, + { siteUuid: 's1', pulseRulesUrl: 'https://x.test/p', rules: BUNDLE }, + ]) { + const res: any = await resolveRules(options, memoryStore()); + + expect(known, `origin was ${res.source?.origin}`).toContain(res.source?.origin); + } + }); +}); From 67886160dfe8c0bdbcca817c494057b7f45df8db Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 31 Aug 2026 17:15:00 +0200 Subject: [PATCH 02/33] Carry the reporting state to the platform, and keep it current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state the platform receives is now the state the guard is in. The rules fetch carries the reporting state itself rather than a single bit, so "no events arrived" can be told apart from an explicit opt-out, a site that never enrolled, and a credential that did not resolve. A bit could express none of those, and it asserted the reassuring reading — reporting is on — in cases where it was not. Reporting also follows refreshes. It is recomputed from the origin each refresh resolves, and the reporter is started or stopped accordingly, because enrolment is not a boot-time fact: a guard that started on cached or bundled rules can receive managed rules later, and one that was reporting can have reporting switched off under it. Stopping flushes what is held — those events were collected while reporting was on. `detectionReporting` and `detectionHealth` are getters for the same reason. A property assigned once reported its boot value for the life of the process, including after reporting had started or stopped. The fetch before the first resolution carries the state derived from what the store already knows, which is the honest answer at the moment of asking: a site the platform has delivered rules to before reports managed, and a site with nothing cached reports that it holds no managed rules yet. `protect.d.ts` declares all six states and the `origin` that `refresh()` returns. Runtime tests cover the wiring rather than the calculator: default-on for a managed site with no config flag, each opt-out reaching the runtime, the state travelling on the rules request, and both directions of the transition. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/engine/pulse-client.js | 14 +- src/protect/protect.d.ts | 33 ++- src/protect/rules/source.js | 2 +- src/protect/runtime.js | 124 ++++++++--- tests/protect/detections.test.ts | 13 +- tests/protect/reporting-runtime.test.ts | 263 ++++++++++++++++++++++++ 6 files changed, 404 insertions(+), 45 deletions(-) create mode 100644 tests/protect/reporting-runtime.test.ts diff --git a/src/protect/engine/pulse-client.js b/src/protect/engine/pulse-client.js index 6374916d..e0efcf87 100644 --- a/src/protect/engine/pulse-client.js +++ b/src/protect/engine/pulse-client.js @@ -28,9 +28,9 @@ export class PulseRuleClient { #etag; #pulseAuth; - #reportsDetections; + #detectionState; - constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) { + constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) { // Bounded so app STARTUP can't hang on a slow API: hosted platforms fail a deploy whose health // check is slow, and we always have a cache/bundled fallback to boot from. this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30_000; @@ -49,7 +49,7 @@ export class PulseRuleClient { // // A capability, not a timestamp: the server records when IT saw this, because a client clock is a // value from outside and "alive as of" is exactly the claim a stale or wrong clock would fake. - this.#reportsDetections = reportsDetections === true; + this.#detectionState = typeof detectionState === 'string' ? detectionState : null; if (!this.#siteUuid) { throw new Error('Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.'); } @@ -78,8 +78,12 @@ export class PulseRuleClient { // // A courtesy, never the guarantee: a client-side gate only removes the accidental case. Anything // acting on this header has to require a verified token itself before believing it. - if (this.#reportsDetections && typeof auth.Authorization === 'string') { - headers['X-Patchstack-Detections'] = 'enabled'; + // The state itself, not a bit. "No events arrived" has several causes — nothing matched, an + // explicit opt-out, never enrolled, no credential — and a boolean collapses them into the + // reassuring reading. Sent on the fetch the guard already makes, so the platform learns the state + // without waiting for a rule to fire. + if (this.#detectionState !== null && typeof auth.Authorization === 'string') { + headers['X-Patchstack-Detections'] = this.#detectionState; } if (this.#etag) headers['If-None-Match'] = this.#etag; const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(this.#timeoutMs) }); diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 3a06db7c..6d901adb 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -34,7 +34,14 @@ export interface Protection { /** Present with a live source — re-fetch + hot-swap the rules once (used by the loop + push). * Resolves with the outcome of the attempt: `ok: false` means the rules in force came from the * cache or the bundled fallback, not from the source. It does not reject on a source failure. */ - refresh?: () => Promise<{ ok: boolean; reason?: string }>; + /** Refresh the rules now. `ok` is whether the resolution was clean; `origin` is which source supplied + * the rules now in force — `api` and `cache` are Patchstack-delivered, `bundled` is the caller's own + * `rules` option, `empty` is none. A fallback is `ok: false` with the origin it fell back to. */ + refresh?: () => Promise<{ + ok: boolean; + origin?: "api" | "cache" | "bundled" | "empty"; + reason?: string; + }>; /** Present with a live source — a fetch handler that runs `refresh()` when the request carries * the configured refresh secret (a push/zero-day trigger). No secret set → the handler 404s. */ refreshHandler?: () => (request: Request) => Promise; @@ -43,10 +50,26 @@ export interface Protection { stop: () => void; /** Alias of `stop`, under the name callers already have. */ stopRefresh: () => void; - /** Whether detection reporting is running, requested but undeliverable, or not requested. - * `unavailable-no-credential` means `reportDetections` was set but no credential resolved, so - * nothing is being sent. */ - detectionReporting: "on" | "off" | "unavailable-no-credential"; + /** Whether this guard reports security events, and if not, why not. + * + * Reporting is on for a site enrolled in Patchstack-managed mitigation that is running managed rules + * with a credential, and off everywhere else. Each state is distinct so "no events arrived" can be + * told apart from "reporting is off" — and it follows refreshes, so a guard that starts on cached or + * bundled rules and later receives managed rules begins reporting without a restart. + * + * - `on` — events are being sent + * - `disabled-by-config` — `PATCHSTACK_REPORT_DETECTIONS` is false, or `reportDetections: false` + * - `disabled-by-telemetry-opt-out` — `PATCHSTACK_TELEMETRY` is false + * - `not-enrolled` — no site identity + * - `no-managed-rules` — the rules in force did not come from Patchstack + * - `unavailable-no-credential` — enrolled, but no credential resolved */ + detectionReporting: + | "on" + | "disabled-by-config" + | "disabled-by-telemetry-opt-out" + | "not-enrolled" + | "no-managed-rules" + | "unavailable-no-credential"; /** Present when detection reporting is on — delivery counts (in events) and the last acknowledgement. * Carries no request data. */ detectionHealth?: () => { diff --git a/src/protect/rules/source.js b/src/protect/rules/source.js index a64cfc7d..3304ec9e 100644 --- a/src/protect/rules/source.js +++ b/src/protect/rules/source.js @@ -76,7 +76,7 @@ export async function resolveRules(options, store, ctx = {}) { const timeoutMs = ctx.timeoutMs; if (options.siteUuid) { const prior = await store.read(); // { bundle, etag } | null - const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true }); + const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState }); const res = await client.getRules(); if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), 'cache'); if (res.success && !res.notModified) { diff --git a/src/protect/runtime.js b/src/protect/runtime.js index c43cf380..f7acfdfa 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -142,7 +142,29 @@ export async function createProtection(options = {}) { notify(onError, new Error(message), 'onError'); console.warn(message); } - const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth }); + // The state sent on the fetch, computed from what is knowable before it: the store says whether the + // platform has ever delivered rules here, which is the honest origin at the moment of asking. The + // resolved state is recomputed from the actual origin immediately after, and every later fetch carries + // whatever the guard is in by then. + const cachedOrigin = async () => { + const prior = await store.read(); + if (prior?.bundle) return 'cache'; + + return options.rules ? 'bundled' : 'empty'; + }; + const stateFor = (origin) => + reportingState({ + siteUuid: options.siteUuid, + ruleOrigin: origin, + hasCredential: Boolean(pulseAuth), + configOptOut: options.reportDetections === false, + }); + + const bundle = await resolveRules(options, store, { + timeoutMs: bootTimeoutMs, + pulseAuth, + detectionState: stateFor(await cachedOrigin()).state, + }); // OPT-IN, deliberately. Two reasons, and the first is not about privacy: switching it on adds an // outbound POST to every guard that has a site UUID, which is a change in what an installed app does // on the network — the kind of thing that must be disclosed in the shipped docs before it is a default, @@ -157,33 +179,53 @@ export async function createProtection(options = {}) { // Derived from enrolment rather than from a config flag: reporting is on for a site the platform // manages, and off everywhere else. `reportingState` holds the whole decision so every combination is // enumerable in a test instead of reachable only by constructing a guard. - const reporting = reportingState({ - siteUuid: options.siteUuid, - ruleOrigin: bundle.source?.origin, - hasCredential: Boolean(pulseAuth), - configOptOut: options.reportDetections === false, - }); - let detectionReporting = reporting.state; - if (reporting.state === 'unavailable-no-credential') { - const message = - 'Patchstack: this site is enrolled and running managed rules, but no API credential resolved, ' + - 'so no security event could be delivered. Reporting is off.'; - notify(onError, new Error(message), 'onError'); - console.warn(message); - } - if (reporting.reports) { - detections = createDetectionReporter({ - siteUuid: options.siteUuid, - baseUrl: options.pulseRulesUrl, - pulseAuth, - // The bundle the guard is actually running, so a hit can be attributed to the rules that produced - // it rather than to whatever is current when the report is read. Kept current across refreshes — - // see the refresh tick below. - rulesEtag: (await store.read())?.etag ?? null, - fetchImpl: options.fetchImpl, - flushMs: options.detectionFlushMs, - }); - } + let detectionReporting = 'not-enrolled'; + + /** + * Bring reporting into line with the rules now in force. + * + * Called at boot and after every refresh, because enrolment is not a boot-time fact: a guard that + * started on a failed fetch and fell back to its cached or bundled rules can receive platform rules on + * a later refresh, and one that was reporting can lose the credential or the enrolment. A state fixed + * at startup leaves the first case silent for the life of the process. + * + * Starts and stops the reporter accordingly. Stopping flushes what it holds — the events already + * collected were collected while reporting was on, and dropping them would lose evidence rather than + * decline to gather it. + */ + const applyReportingState = async (origin) => { + const next = stateFor(origin); + const changed = next.state !== detectionReporting; + detectionReporting = next.state; + + if (next.reports && !detections) { + detections = createDetectionReporter({ + siteUuid: options.siteUuid, + baseUrl: options.pulseRulesUrl, + pulseAuth, + // The bundle the guard is actually running, so a hit can be attributed to the rules that + // produced it rather than to whatever is current when the report is read. + rulesEtag: (await store.read())?.etag ?? null, + fetchImpl: options.fetchImpl, + flushMs: options.detectionFlushMs, + }); + } else if (!next.reports && detections) { + detections.stop(); + detections = undefined; + } + + if (changed && next.state === 'unavailable-no-credential') { + const message = + 'Patchstack: this site is enrolled and running managed rules, but no API credential resolved, ' + + 'so no security event could be delivered. Reporting is off.'; + notify(onError, new Error(message), 'onError'); + console.warn(message); + } + + return next; + }; + + await applyReportingState(bundle.source?.origin); // Mode is mutable so a Pulse refresh can flip dry-run ↔ block when SaaS enables production. // Precedence: PATCHSTACK_MODE env (local override) > API enforcement > options.mode > dry-run. let mode = resolveMode(options, bundle); @@ -701,9 +743,18 @@ export async function createProtection(options = {}) { notify(onError, err, 'onError'); // a failed report must not stop the rule refresh } } - const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth }); + const next = await resolveRules(options, store, { + timeoutMs: options.refreshTimeoutMs, + pulseAuth, + // Whatever the guard is in right now, so the platform's view follows the guard's rather than + // staying at the value the first fetch happened to carry. + detectionState: detectionReporting, + }); mode = resolveMode(options, next); applyBundle(next); + // Enrolment can change under a running guard in both directions, so this is recomputed from the + // origin the refresh actually resolved rather than left at its boot value. + await applyReportingState(next.source?.origin); // After the swap, and only after it: later detections belong to the bundle now running. A refresh // that fell back to the cached or bundled ruleset kept the previous rules, and `store.read()` then // still holds the previous identity — which is exactly the answer that stays true. @@ -742,9 +793,18 @@ export async function createProtection(options = {}) { protection.stopRefresh = protection.stop; // Which of the three states reporting is in: requested and running, requested but undeliverable, or // not requested. A boolean would collapse the middle one into "off", which is the reassuring reading. - protection.detectionReporting = detectionReporting; - // Delivery health, when there is a reporter: what was attempted, acknowledged, refused, and dropped. - if (detections) protection.detectionHealth = () => detections.health(); + // A getter, because the state follows refreshes: a property assigned once would report the boot value + // for the life of the process, including after reporting started or stopped. + Object.defineProperty(protection, 'detectionReporting', { + get: () => detectionReporting, + enumerable: true, + }); + // Delivery health while there is a reporter: what was attempted, acknowledged, refused, and dropped. + // Undefined when there is none, so "no reporter" and "a reporter with nothing to show" stay apart. + Object.defineProperty(protection, 'detectionHealth', { + get: () => (detections ? () => detections.health() : undefined), + enumerable: true, + }); return protection; } diff --git a/tests/protect/detections.test.ts b/tests/protect/detections.test.ts index 805c5440..46ad15fd 100644 --- a/tests/protect/detections.test.ts +++ b/tests/protect/detections.test.ts @@ -241,12 +241,21 @@ describe('declaring the capability', () => { reportDetections: true, }); - // Authenticated, so the claim carries weight and is made. - const claimed = seen.filter((h) => h['X-Patchstack-Detections'] === 'enabled'); + // Authenticated, so the claim carries weight and is made. The header carries the STATE, not a bit: + // "no events arrived" has several causes, and the platform can only tell them apart if the guard + // names which one it is in. + const claimed = seen.filter((h) => typeof h['X-Patchstack-Detections'] === 'string'); expect(claimed.length).toBeGreaterThan(0); for (const headers of claimed) { expect(headers.Authorization, 'the claim only travels on an authenticated request').toContain('Bearer'); + expect( + ['on', 'no-managed-rules', 'unavailable-no-credential'], + 'the header value is a reporting state', + ).toContain(headers['X-Patchstack-Detections']); } + // The first fetch of a site with no cached bundle honestly reports that it holds no managed rules + // yet; the state that follows the resolution is asserted separately below. + expect(p.detectionReporting).toBe('on'); p.stopRefresh?.(); }); diff --git a/tests/protect/reporting-runtime.test.ts b/tests/protect/reporting-runtime.test.ts new file mode 100644 index 00000000..a08546fa --- /dev/null +++ b/tests/protect/reporting-runtime.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +/** + * Reporting at the runtime seam: is it on by default for a managed site, does each opt-out reach it, does + * the platform learn the state, and does it follow a refresh. + * + * The state calculator is covered exhaustively elsewhere. What these cover is the wiring — a correct + * calculator that the runtime never consults, or consults once at boot, produces exactly the failure the + * state exists to prevent: a managed site that silently never reports, or an unmanaged one that does. + */ +const AUTH = 'the-secret-40-chars-long-ish-value-here-987'; +const RULES = { + firewall: [{ id: 'r1', title: 't', rule_v2: [{ parameter: 'get.q', match: { type: 'contains', value: 'boom' } }] }], + whitelists: [], + enforcement: 'dry-run', +}; + +const drain = async () => { await new Promise((r) => setTimeout(r, 5)); }; + +/** A fetch stub that serves rules, and records the capability header of every rules request. */ +function stubFetch(opts: { rulesOk?: boolean; etag?: string } = {}) { + const capabilityHeaders: Array = []; + const posted: string[] = []; + let rulesOk = opts.rulesOk ?? true; + + const impl = vi.fn(async (url: string, init?: RequestInit) => { + const target = String(url); + // The credential is exchanged for a short-lived token before the rules fetch. Without answering this, + // the rules request carries no Authorization — and the capability header only travels on an + // authenticated request, so every capability assertion would fail for the wrong reason. + if (target.includes('token')) { + return new Response(JSON.stringify({ access_token: 'jwt-abc', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (target.includes('/detections/')) { + posted.push(target); + + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + capabilityHeaders.push((init?.headers as Record)?.['X-Patchstack-Detections']); + if (!rulesOk) throw new Error('rules unreachable'); + + return new Response(JSON.stringify(RULES), { + status: 200, + headers: { 'Content-Type': 'application/json', ETag: opts.etag ?? '"v1"' }, + }); + }); + + vi.stubGlobal('fetch', impl); + + return { capabilityHeaders, posted, setRulesOk: (v: boolean) => { rulesOk = v; } }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + // In afterEach, not at the end of a test body: an assertion that fails would otherwise leak a + // stubbed variable into every test after it. + vi.unstubAllEnvs(); +}); + +describe('reporting is on by default for a managed site', () => { + it('needs no config flag', async () => { + // The behaviour that changed. Nothing here asks for reporting: an enrolled site running rules the + // platform delivered, with a credential, reports. + const { posted } = stubFetch(); + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + detectionFlushMs: 1, + }); + + expect(p.detectionReporting).toBe('on'); + + await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); + p.stop(); + await drain(); + await drain(); + + expect(posted.length, 'an event reached the endpoint').toBeGreaterThan(0); + }); + + it('sends nothing for a local install running its own rules', async () => { + // A bare install: no site identity, so nothing to report against and no endpoint to report to. + const p: any = await createProtection({ rules: RULES, mode: 'dry-run', detectionFlushMs: 1 }); + + expect(p.detectionReporting).toBe('not-enrolled'); + expect(p.detectionHealth).toBeUndefined(); + p.stop(); + }); + + it('sends nothing for a site identity whose rules are not the platform’s', async () => { + // Enrolled-looking, but the rules in force are the caller's own, so a detection could not be + // attributed to a managed rule document. + const { posted, setRulesOk } = stubFetch(); + setRulesOk(false); + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + rules: RULES, + detectionFlushMs: 1, + onError: () => {}, + }); + + expect(p.detectionReporting).toBe('no-managed-rules'); + + await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); + p.stop(); + await drain(); + + expect(posted).toEqual([]); + }); +}); + +describe('each opt-out reaches the runtime', () => { + it.each([ + ['PATCHSTACK_REPORT_DETECTIONS', 'disabled-by-config'], + ['PATCHSTACK_TELEMETRY', 'disabled-by-telemetry-opt-out'], + ])('%s=0 switches reporting off, and says which switch did it', async (name, expected) => { + const { posted } = stubFetch(); + vi.stubEnv(name, '0'); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + detectionFlushMs: 1, + }); + + expect(p.detectionReporting).toBe(expected); + + await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); + p.stop(); + await drain(); + + expect(posted, 'an opt-out means no events leave the process').toEqual([]); + }); + + it('honours reportDetections: false as an opt-out', async () => { + const { posted } = stubFetch(); + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + reportDetections: false, + detectionFlushMs: 1, + }); + + expect(p.detectionReporting).toBe('disabled-by-config'); + await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); + p.stop(); + await drain(); + + expect(posted).toEqual([]); + }); +}); + +describe('the platform learns the state', () => { + it('carries the state on the rules request, not a bit', async () => { + const { capabilityHeaders } = stubFetch(); + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + }); + + const sent = capabilityHeaders.filter((v): v is string => typeof v === 'string'); + expect(sent.length).toBeGreaterThan(0); + // Never the legacy bit: a boolean cannot say which of the reasons applies. + expect(sent).not.toContain('enabled'); + p.stop(); + }); + + it('carries the opt-out state, rather than saying nothing', async () => { + // Silence would leave the platform unable to tell an opted-out site from one that never installed. + const { capabilityHeaders } = stubFetch(); + vi.stubEnv('PATCHSTACK_REPORT_DETECTIONS', '0'); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + }); + + expect(capabilityHeaders).toContain('disabled-by-config'); + p.stop(); + }); +}); + +describe('reporting follows a refresh', () => { + it('stops when an opt-out appears under a running guard', async () => { + // The mirror of recovery, and the direction that matters more: a guard that keeps reporting after + // reporting is switched off is collecting retained evidence nobody asked it for. The state is read + // afresh on each refresh rather than fixed at boot, so an operator who sets the variable and waits + // for the next refresh gets what they asked for without a restart. + // + // Losing MANAGED status mid-process is not the case tested here: once a fetch has succeeded, the + // guard holds the platform's rules in its memory tier, so they remain managed and `cache` is the + // correct answer. The opt-out is the transition that is actually reachable. + const { posted } = stubFetch(); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + detectionFlushMs: 1, + }); + + expect(p.detectionReporting).toBe('on'); + expect(p.detectionHealth).toBeTypeOf('function'); + + vi.stubEnv('PATCHSTACK_REPORT_DETECTIONS', '0'); + await p.refresh(); + + expect(p.detectionReporting, 'the state follows the opt-out').toBe('disabled-by-config'); + expect(p.detectionHealth, 'and the health surface goes with it').toBeUndefined(); + + const before = posted.length; + await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); + p.stop(); + await drain(); + await drain(); + + expect(posted.length, 'no event is sent after reporting stops').toBe(before); + }); + + it('starts once a refresh receives platform rules', async () => { + // The recovery path. A guard that started on its own bundle because the first fetch failed must begin + // reporting when the platform becomes reachable — not stay silent for the life of the process. + const { posted, setRulesOk } = stubFetch(); + setRulesOk(false); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + rules: RULES, + detectionFlushMs: 1, + onError: () => {}, + }); + + expect(p.detectionReporting).toBe('no-managed-rules'); + expect(p.detectionHealth).toBeUndefined(); + + setRulesOk(true); + const status = await p.refresh(); + + expect(status).toMatchObject({ ok: true, origin: 'api' }); + expect(p.detectionReporting, 'the state follows the refresh').toBe('on'); + expect(p.detectionHealth, 'and so does the health surface').toBeTypeOf('function'); + + await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); + p.stop(); + await drain(); + await drain(); + + expect(posted.length, 'events flow after recovery').toBeGreaterThan(0); + }); +}); From 6d2d5b8046d2d33462b9ae664449246391e24aff Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 31 Aug 2026 18:01:00 +0200 Subject: [PATCH 03/33] Acknowledge the settled reporting state, and disclose the new default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state carried on the rules fetch is decided before that fetch determines where the rules came from, so a site booting with an empty cache declares that it holds no managed rules and then receives them on the same request. The corrected state is now sent on an authenticated request of its own, because the alternative was waiting for the next refresh — and a guard with refreshing switched off has none. It carries no events; its only content is the state, and it is only made when resolution settled somewhere other than the fetch declared. A refresh derives the state it sends from the origin in force, re-reading the environment, rather than resending the last reported value. An opt-out appearing under a running guard now travels on the next request rather than the one after it. Reporting on by default for enrolled sites is a change in what an installed app does on the network, so the shipped documentation says so: `AGENT-INSTALL.md` and the option documentation now describe the default, the three ways to switch it off, and that `reportDetections` is an opt-out which cannot enable reporting for a site that is not enrolled. Two states cannot travel over this header and are no longer claimed to. `not-enrolled` makes no site-addressed request, and `unavailable-no-credential` cannot produce an authenticated one — the declaration is withheld from unauthenticated requests because a claim about a site carries no weight without a verified token. Both stay useful locally; server-side absence needs modelling on its own terms. The comments no longer say enrolment or the credential can be lost mid-process. The credential is resolved once at boot, and managed rules stay in the memory tier, so within one process the reachable changes are a rules source that starts being the platform's and an opt-out appearing in the environment. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 25 +++++++--- src/protect/detections.js | 41 +++++++++++++++- src/protect/protect.d.ts | 23 +++++---- src/protect/reporting-state.js | 16 +++++-- src/protect/runtime.js | 49 +++++++++++++------ tests/protect/detections.test.ts | 9 +++- tests/protect/reporting-runtime.test.ts | 64 ++++++++++++++++++++++++- 7 files changed, 187 insertions(+), 40 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 94be61d9..dce28b7e 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -144,11 +144,22 @@ would have stopped while it is still in dry-run. Two separate paths, with differ token; the `apiKey` itself is not sent to the log endpoint. Disable with `PATCHSTACK_TELEMETRY=off`, or `reportFirewallLog: false` in `createProtection`. - **Every rule that matched** goes to `monitor/pulse/detections/` — including matches that - blocked, which are reported on both paths. This is **off unless you pass `reportDetections: true`** to - `createProtection`; the scaffolded guard does not pass it. It also requires a provisioned site UUID, a - resolvable credential, and is disabled by `PATCHSTACK_TELEMETRY=off`. It exists because a rule carrying - `dry-run` blocks nothing, so without it nothing distinguishes a rule that is protecting from one that is - quietly wrong. + blocked, which are reported on both paths. It exists because a rule carrying `dry-run` blocks nothing, so + without it nothing distinguishes a rule that is protecting from one that is quietly wrong. + + **This is on by default for a site enrolled with Patchstack that is running Patchstack-delivered rules**, + and off otherwise. Specifically, it requires all of: a provisioned site UUID, rules that came from + Patchstack rather than from a local bundle, and a resolvable credential. A local install, or a guard + running its own `rules`, sends nothing. + + Switch it off with **`PATCHSTACK_REPORT_DETECTIONS=0`**, or `reportDetections: false` in + `createProtection`, or `PATCHSTACK_TELEMETRY=off` which covers all telemetry. `reportDetections` is an + opt-out only — passing `true` cannot switch reporting on for a site that is not enrolled. + + `protection.detectionReporting` names the current state, so a guard that is not reporting says which + reason applies: `on`, `disabled-by-config`, `disabled-by-telemetry-opt-out`, `not-enrolled`, + `no-managed-rules`, or `unavailable-no-credential`. The state is also sent on the rules request the guard + already makes, so Patchstack can tell "nothing matched" apart from "reporting is off". What a detection report contains, per matched rule: the rule id, the request path **with any query string removed**, the parameter names that rule reads (from the rule's own definition), which phase matched, @@ -168,8 +179,8 @@ and not the value of any header, cookie or query-string parameter — including named above. Reports are batched, capped in memory, and dropped rather than retried if Patchstack cannot be reached — a reporting failure never delays or fails a request. -The endpoint needs a credential, so `reportDetections: true` with none resolved starts nothing: the guard -warns once at boot and `protection.detectionReporting` reads `unavailable-no-credential` instead of `on`. +The endpoint needs a credential, so an enrolled site with none resolved starts nothing: the guard warns +once at boot and `protection.detectionReporting` reads `unavailable-no-credential` instead of `on`. When reporting is on, `protection.detectionHealth()` returns local counts — detections attempted, acknowledged, refused or unreachable, dropped for queue pressure — and the time of the last acknowledgement. Those counts stay in your process; nothing extra is sent to report them. diff --git a/src/protect/detections.js b/src/protect/detections.js index cb6f73ab..534644e9 100644 --- a/src/protect/detections.js +++ b/src/protect/detections.js @@ -112,7 +112,7 @@ export function createDetectionReporter(opts) { // Nothing to report against. A no-op rather than a throw: reporting is never worth failing a boot. // It answers the whole interface, so a caller never has to know which kind it holds. return { - record() {}, flush() {}, stop() {}, setRulesEtag() {}, dropped: () => 0, + record() {}, flush() {}, stop() {}, setRulesEtag() {}, announce() {}, dropped: () => 0, health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null }), }; } @@ -240,6 +240,45 @@ export function createDetectionReporter(opts) { if (!timer) timer = setTimeout(flush, flushMs); }, flush, + /** + * Tell the platform which reporting state this guard settled on. + * + * The state also travels on the rules fetch, but that request is made BEFORE the fetch decides + * whether the rules are the platform's — so a site booting with an empty cache declares that it holds + * no managed rules, then receives them. Without this, the corrected state would wait for the next + * refresh, and a guard with refreshing switched off has none. + * + * Carries no events: an empty batch whose only content is the state. Fire-and-forget and fail-open for + * the same reason as a flush — a report is never worth disturbing the app over — and counted as a + * delivery attempt so a path that refuses everything stays visible. + * + * @param {string} state + */ + announce(state) { + if (stopped || typeof fetchImpl !== 'function' || typeof state !== 'string') return; + + void (async () => { + try { + const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': '@patchstack/connect', + ...(await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)), + }, + body: JSON.stringify({ detections: [], dropped: 0, reporting_state: state }), + }); + if (res && res.ok) { + lastDeliveredAt = new Date().toISOString(); + } else { + failed += 1; + } + } catch { + failed += 1; + } + })(); + }, stop() { stopped = true; flush(); diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 6d901adb..086779e1 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -118,20 +118,23 @@ export interface CreateProtectionOptions { */ reportFirewallLog?: boolean; /** - * Report EVERY rule that fired — including one in `dry-run` that did not block — to the Pulse - * detections endpoint. Off unless explicitly `true`. + * Opt OUT of reporting every rule that fired — including one in `dry-run` that did not block — to the + * Pulse detections endpoint. * - * Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a - * rule that is protecting look identical from the outside. + * Reporting is ON by default for a site enrolled with Patchstack that is running Patchstack-delivered + * rules and has a resolvable credential; it is off for a local install and for a guard running its own + * `rules`. This option can only switch it OFF: passing `true` cannot enable reporting for a site that is + * not enrolled, because whether a site is managed is Patchstack's answer and not a caller's to assert. + * `PATCHSTACK_REPORT_DETECTIONS=0` does the same thing from the environment. + * + * Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a rule + * that is protecting look identical from the outside. * * What it sends, per detection: the rule id, the request PATH with the query string removed, the - * parameters the rule reads, the phase, whether it was enforced, the rule-bundle ETag, and a - * timestamp. It does NOT send the matched value, the request body, headers, or query-string values — - * this is a counting channel, not a copy of your traffic. + * parameters the rule reads, the phase, whether it was enforced, the rule-bundle ETag, and a timestamp. + * It does NOT send the matched value, the request body, headers, or query-string values. * - * Off by default because switching it on adds an outbound request to every guard with a site UUID. - * Needs a resolvable API credential: the endpoint requires a verified, site-bound token, so with no - * credential no reporter is created and `detectionReporting` reads `unavailable-no-credential`. + * `detectionReporting` names the state, including the reason when reporting is off. */ reportDetections?: boolean; /** How long to buffer detections before posting a batch. Default 5000ms. */ diff --git a/src/protect/reporting-state.js b/src/protect/reporting-state.js index cf04b7fb..0e7fc913 100644 --- a/src/protect/reporting-state.js +++ b/src/protect/reporting-state.js @@ -8,9 +8,19 @@ * * The state is a single value with a reason built into it, because "no events arrived" has several * causes that look identical from the platform: nothing matched, reporting was switched off, the site - * was never enrolled, or delivery is broken. A dashboard that cannot separate them has to describe all - * of them every time. The state is declared on the rules fetch the guard already makes, so the platform - * learns it without an extra request and without waiting for a rule to fire. + * was never enrolled, or delivery is broken. + * + * Most of these travel: the state is declared on the rules fetch the guard already makes, so the platform + * learns it without an extra request and without waiting for a rule to fire. Two do not, and cannot: + * + * `not-enrolled` makes no site-addressed request at all, so there is nothing to carry it + * `unavailable-no-credential` cannot produce an authenticated request, and the declaration is withheld + * from an unauthenticated one because a claim about a site carries no + * weight without a verified token + * + * Both remain useful locally — `protect --check` reports them, and they are why a guard is silent. But the + * platform cannot infer them from this header, so server-side absence has to be modelled on its own terms + * (last seen, and how long ago) rather than treated as a state the guard reported. */ /** diff --git a/src/protect/runtime.js b/src/protect/runtime.js index f7acfdfa..e310b1e0 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -160,18 +160,20 @@ export async function createProtection(options = {}) { configOptOut: options.reportDetections === false, }); + // Read once and reused below, so the state reported on the fetch and the state compared against the + // settled one are the same value rather than two reads of a store the fetch has since written to. + const preFetchState = stateFor(await cachedOrigin()).state; const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth, - detectionState: stateFor(await cachedOrigin()).state, + detectionState: preFetchState, }); - // OPT-IN, deliberately. Two reasons, and the first is not about privacy: switching it on adds an - // outbound POST to every guard that has a site UUID, which is a change in what an installed app does - // on the network — the kind of thing that must be disclosed in the shipped docs before it is a default, - // not after. The second is that the default belongs to whoever owns that disclosure, so the capability - // lands here and the flip is a separate, deliberate change. + // ON by default for an enrolled site running Patchstack-delivered rules, and off otherwise — a local + // install and a guard running its own bundle send nothing. That default is a change in what an + // installed app does on the network, so it is disclosed in `AGENT-INSTALL.md` and in the option + // documentation rather than being inferred from behaviour. // - // And it needs a credential. The detections endpoint is site-addressed and site-bound-token-only, so a + // It needs a credential. The detections endpoint is site-addressed and site-bound-token-only, so a // reporter built without one queues events, posts them, and is refused — spending an outbound request // per batch to accomplish nothing, while `reportDetections: true` in the config says reporting is on. // Refusing to build it is the honest outcome; `protection.detectionReporting` says which it is. @@ -180,20 +182,31 @@ export async function createProtection(options = {}) { // manages, and off everywhere else. `reportingState` holds the whole decision so every combination is // enumerable in a test instead of reachable only by constructing a guard. let detectionReporting = 'not-enrolled'; + /** + * The origin of the rules in force. + * + * Held separately from the reported state so a request can carry a state derived FRESH from it. Sending + * the previously reported state would mean an opt-out that appeared under a running guard was not + * reported on the next request — only on the one after it. + */ + let currentOrigin; /** * Bring reporting into line with the rules now in force. * - * Called at boot and after every refresh, because enrolment is not a boot-time fact: a guard that + * Called at boot and after every refresh, because the inputs are not all boot-time facts: a guard that * started on a failed fetch and fell back to its cached or bundled rules can receive platform rules on - * a later refresh, and one that was reporting can lose the credential or the enrolment. A state fixed + * a later refresh, and an opt-out can appear in the environment under a running process. A state fixed * at startup leaves the first case silent for the life of the process. * + * The credential is resolved once at boot, so losing it mid-process does not change the state. + * * Starts and stops the reporter accordingly. Stopping flushes what it holds — the events already * collected were collected while reporting was on, and dropping them would lose evidence rather than * decline to gather it. */ const applyReportingState = async (origin) => { + currentOrigin = origin; const next = stateFor(origin); const changed = next.state !== detectionReporting; detectionReporting = next.state; @@ -225,7 +238,12 @@ export async function createProtection(options = {}) { return next; }; - await applyReportingState(bundle.source?.origin); + const settled = await applyReportingState(bundle.source?.origin); + // The fetch above carried the state as it stood before the fetch decided where the rules came from. If + // resolution settled somewhere else, say so now on an authenticated request of its own: waiting for the + // next refresh would leave the platform holding the pre-resolution answer, and a guard with refreshing + // switched off has no next refresh. + if (settled.state !== preFetchState && detections) detections.announce(settled.state); // Mode is mutable so a Pulse refresh can flip dry-run ↔ block when SaaS enables production. // Precedence: PATCHSTACK_MODE env (local override) > API enforcement > options.mode > dry-run. let mode = resolveMode(options, bundle); @@ -746,14 +764,15 @@ export async function createProtection(options = {}) { const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth, - // Whatever the guard is in right now, so the platform's view follows the guard's rather than - // staying at the value the first fetch happened to carry. - detectionState: detectionReporting, + // Derived from the origin in force, re-reading the environment, rather than resent from the last + // reported value: an opt-out that appeared since the previous request has to travel on THIS one. + detectionState: stateFor(currentOrigin).state, }); mode = resolveMode(options, next); applyBundle(next); - // Enrolment can change under a running guard in both directions, so this is recomputed from the - // origin the refresh actually resolved rather than left at its boot value. + // Recomputed from the origin this refresh resolved. Within one process the reachable changes are a + // rules source that starts or stops being the platform's, and an opt-out appearing in the + // environment; the credential is resolved once at boot, so losing it mid-process is not modelled. await applyReportingState(next.source?.origin); // After the swap, and only after it: later detections belong to the bundle now running. A refresh // that fell back to the cached or bundled ruleset kept the previous rules, and `store.read()` then diff --git a/tests/protect/detections.test.ts b/tests/protect/detections.test.ts index 46ad15fd..4a8c5677 100644 --- a/tests/protect/detections.test.ts +++ b/tests/protect/detections.test.ts @@ -564,13 +564,18 @@ describe('the reporter can always be reached', () => { await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); await drain(); - expect(posts.length, 'still buffered — nothing has asked it to flush').toBe(0); + // Counted in EVENTS, not requests: a state announcement is a request carrying no events, and it is + // made once at boot. What this asserts is that no detection has left the buffer yet. + const events = () => posts.flatMap((body: any) => body.detections ?? []); + expect(events().length, 'still buffered — nothing has asked it to flush').toBe(0); p.stop(); await drain(); await drain(); - expect(posts.length).toBe(1); + // Exactly the one buffered event, delivered by the stop. `sent`/`delivered` count events, so the + // state announcement — which carries none — does not move them. + expect(events().length).toBe(1); expect(p.detectionHealth()).toMatchObject({ sent: 1, delivered: 1, failed: 0, dropped: 0 }); expect(p.detectionHealth().lastDeliveredAt).not.toBeNull(); }); diff --git a/tests/protect/reporting-runtime.test.ts b/tests/protect/reporting-runtime.test.ts index a08546fa..9ea13f6e 100644 --- a/tests/protect/reporting-runtime.test.ts +++ b/tests/protect/reporting-runtime.test.ts @@ -22,6 +22,7 @@ const drain = async () => { await new Promise((r) => setTimeout(r, 5)); }; function stubFetch(opts: { rulesOk?: boolean; etag?: string } = {}) { const capabilityHeaders: Array = []; const posted: string[] = []; + const bodies: any[] = []; let rulesOk = opts.rulesOk ?? true; const impl = vi.fn(async (url: string, init?: RequestInit) => { @@ -37,6 +38,7 @@ function stubFetch(opts: { rulesOk?: boolean; etag?: string } = {}) { } if (target.includes('/detections/')) { posted.push(target); + bodies.push(JSON.parse(String(init?.body ?? '{}'))); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } @@ -51,7 +53,7 @@ function stubFetch(opts: { rulesOk?: boolean; etag?: string } = {}) { vi.stubGlobal('fetch', impl); - return { capabilityHeaders, posted, setRulesOk: (v: boolean) => { rulesOk = v; } }; + return { capabilityHeaders, posted, bodies, setRulesOk: (v: boolean) => { rulesOk = v; } }; } afterEach(() => { @@ -191,6 +193,57 @@ describe('the platform learns the state', () => { }); }); +describe('the settled state reaches the platform', () => { + it('is acknowledged when resolution settles somewhere other than the fetch declared', async () => { + // A site booting with an empty cache declares that it holds no managed rules, then receives them on + // that same request. Without an acknowledgement the platform keeps the pre-resolution answer — and a + // guard with refreshing switched off never sends another rules request. + const { capabilityHeaders, bodies } = stubFetch(); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + detectionFlushMs: 1, + }); + await drain(); + await drain(); + + // What the fetch carried, and what it settled on: different, which is why the acknowledgement exists. + expect(capabilityHeaders).toContain('no-managed-rules'); + expect(p.detectionReporting).toBe('on'); + + const announcements = bodies.filter((b) => typeof b.reporting_state === 'string'); + expect(announcements.map((b) => b.reporting_state)).toContain('on'); + // It carries no events — its only content is the state. + for (const body of announcements) expect(body.detections).toEqual([]); + + p.stop(); + }); + + it('is not acknowledged when the fetch already declared the settled state', async () => { + // A site whose store already holds a platform bundle declares `on` before the fetch and settles on + // `on`, so there is nothing to correct and no extra request to make. + let cached: unknown = { bundle: { firewall: [], whitelists: [], whitelist_keys: {} }, etag: '"v0"' }; + const { bodies } = stubFetch(); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + detectionFlushMs: 1, + ruleCache: { read: () => cached, write: (e: unknown) => { cached = e; } }, + }); + await drain(); + await drain(); + + expect(p.detectionReporting).toBe('on'); + expect(bodies.filter((b) => typeof b.reporting_state === 'string')).toEqual([]); + + p.stop(); + }); +}); + describe('reporting follows a refresh', () => { it('stops when an opt-out appears under a running guard', async () => { // The mirror of recovery, and the direction that matters more: a guard that keeps reporting after @@ -201,7 +254,7 @@ describe('reporting follows a refresh', () => { // Losing MANAGED status mid-process is not the case tested here: once a fetch has succeeded, the // guard holds the platform's rules in its memory tier, so they remain managed and `cache` is the // correct answer. The opt-out is the transition that is actually reachable. - const { posted } = stubFetch(); + const { posted, capabilityHeaders } = stubFetch(); const p: any = await createProtection({ siteUuid: 'site-1', @@ -213,11 +266,18 @@ describe('reporting follows a refresh', () => { expect(p.detectionReporting).toBe('on'); expect(p.detectionHealth).toBeTypeOf('function'); + const beforeRefresh = capabilityHeaders.length; vi.stubEnv('PATCHSTACK_REPORT_DETECTIONS', '0'); await p.refresh(); expect(p.detectionReporting, 'the state follows the opt-out').toBe('disabled-by-config'); expect(p.detectionHealth, 'and the health surface goes with it').toBeUndefined(); + // And the request made BY that refresh says so. Carrying the previously reported state would mean the + // platform learns of the opt-out only on the refresh after this one — or never, if there is none. + expect( + capabilityHeaders.slice(beforeRefresh), + 'the refresh request carries the state as of that request', + ).toContain('disabled-by-config'); const before = posted.length; await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); From c227e04545fa3f9c6f8b642b1cb1ce39defe16b2 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 31 Aug 2026 18:12:37 +0200 Subject: [PATCH 04/33] Count capability acknowledgements apart from event delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event counters are measured in events. A state announcement carries none, so letting it advance `lastDeliveredAt` or `failed` produced readings that describe no real delivery — `sent: 0` with `failed: 1` — and made a capability acknowledgement indistinguishable from a delivered detection. `detectionHealth()` now reports a separate `capability` block: announced, acknowledged, failed, and when one was last acknowledged. Zero there alongside delivered events is a normal state, and so is the reverse. The declared type carries the shape. The shipped documentation describes the state-only POST, because it is outbound behaviour: what it contains, that it carries no detections, that it is sent once per process and only when resolution settled somewhere other than the rules request declared, and that a guard with cached rules never sends it. It also says which two states never travel and why they are local diagnostics. The refresh comment is narrowed to the reachable direction: a rules source can start being the platform's within one process but cannot stop, because an accepted bundle stays in the memory tier and a later failed fetch still resolves to cache. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 25 +++++++++++--- src/protect/detections.js | 41 +++++++++++++++++++--- src/protect/protect.d.ts | 9 +++++ src/protect/runtime.js | 5 +-- tests/protect/reporting-runtime.test.ts | 46 +++++++++++++++++++++++-- 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index dce28b7e..9e189d4b 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -156,10 +156,23 @@ would have stopped while it is still in dry-run. Two separate paths, with differ `createProtection`, or `PATCHSTACK_TELEMETRY=off` which covers all telemetry. `reportDetections` is an opt-out only — passing `true` cannot switch reporting on for a site that is not enrolled. - `protection.detectionReporting` names the current state, so a guard that is not reporting says which - reason applies: `on`, `disabled-by-config`, `disabled-by-telemetry-opt-out`, `not-enrolled`, - `no-managed-rules`, or `unavailable-no-credential`. The state is also sent on the rules request the guard - already makes, so Patchstack can tell "nothing matched" apart from "reporting is off". + `protection.detectionReporting` names the current state locally, so a guard that is not reporting says + which reason applies: `on`, `disabled-by-config`, `disabled-by-telemetry-opt-out`, `not-enrolled`, + `no-managed-rules`, or `unavailable-no-credential`. + + **How the state reaches Patchstack, and what that costs on the network.** The state travels as a header + on the rules request the guard already makes — no extra request for it. Two of the six never travel: + `not-enrolled` makes no site-addressed request at all, and `unavailable-no-credential` cannot produce an + authenticated one, and the header is withheld from unauthenticated requests. Those two are local + diagnostics only. + + There is one case that does add a request. The header is set before the rules request finishes, so a + guard booting with no cached rules declares `no-managed-rules` and then receives managed rules on that + same response. When that happens it sends **one immediate POST to the detections endpoint containing the + corrected state and no detections at all** — an empty `detections` array plus `reporting_state`. It is + sent once per process, only when the state changed, and never when the guard already had cached rules. + Without it, a guard with rule refreshing switched off would leave Patchstack holding the pre-resolution + answer for the life of the process. What a detection report contains, per matched rule: the rule id, the request path **with any query string removed**, the parameter names that rule reads (from the rule's own definition), which phase matched, @@ -183,7 +196,9 @@ The endpoint needs a credential, so an enrolled site with none resolved starts n once at boot and `protection.detectionReporting` reads `unavailable-no-credential` instead of `on`. When reporting is on, `protection.detectionHealth()` returns local counts — detections attempted, acknowledged, refused or unreachable, dropped for queue pressure — and the time of the last -acknowledgement. Those counts stay in your process; nothing extra is sent to report them. +acknowledgement. Counts for the state POST described above are kept separately under `capability`, since it +carries no detections and would otherwise read as one. Those counts stay in your process; nothing extra is +sent to report them. `protection.stop()` stops everything the guard has running in the background — the rule-refresh loop, the block-log reporter, the detection reporter — and flushes what is buffered. `protection.stopRefresh()` is diff --git a/src/protect/detections.js b/src/protect/detections.js index 534644e9..dca91a5e 100644 --- a/src/protect/detections.js +++ b/src/protect/detections.js @@ -113,7 +113,10 @@ export function createDetectionReporter(opts) { // It answers the whole interface, so a caller never has to know which kind it holds. return { record() {}, flush() {}, stop() {}, setRulesEtag() {}, announce() {}, dropped: () => 0, - health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null }), + health: () => ({ + sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null, + capability: { announced: 0, acknowledged: 0, failed: 0, lastAcknowledgedAt: null }, + }), }; } @@ -141,6 +144,18 @@ export function createDetectionReporter(opts) { /** @type {string | null} */ let lastDeliveredAt = null; + // Capability accounting, kept apart from the event counters above. + // + // The event counters are measured in EVENTS. A state announcement carries none, so letting it advance + // `lastDeliveredAt` or `failed` produces readings that cannot describe any real delivery — `sent: 0` + // with `failed: 1` — and makes a capability acknowledgement indistinguishable from a delivered + // detection. They answer different questions and are counted separately. + let capabilityAnnounced = 0; + let capabilityAcknowledged = 0; + let capabilityFailed = 0; + /** @type {string | null} */ + let lastCapabilityAckAt = null; + /** @type {Array>} */ let queue = []; /** @type {ReturnType | null} */ @@ -256,6 +271,7 @@ export function createDetectionReporter(opts) { */ announce(state) { if (stopped || typeof fetchImpl !== 'function' || typeof state !== 'string') return; + capabilityAnnounced += 1; void (async () => { try { @@ -270,12 +286,13 @@ export function createDetectionReporter(opts) { body: JSON.stringify({ detections: [], dropped: 0, reporting_state: state }), }); if (res && res.ok) { - lastDeliveredAt = new Date().toISOString(); + capabilityAcknowledged += 1; + lastCapabilityAckAt = new Date().toISOString(); } else { - failed += 1; + capabilityFailed += 1; } } catch { - failed += 1; + capabilityFailed += 1; } })(); }, @@ -300,6 +317,20 @@ export function createDetectionReporter(opts) { * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped * for queue pressure — plus when a batch was last acknowledged. No request data of any kind. */ - health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt }), + health: () => ({ + sent, + delivered, + failed, + dropped: droppedTotal + dropped, + lastDeliveredAt, + // Separate, because a capability announcement delivers no events. Reading zero here alongside a + // non-zero `delivered` is a normal state, and so is the reverse. + capability: { + announced: capabilityAnnounced, + acknowledged: capabilityAcknowledged, + failed: capabilityFailed, + lastAcknowledgedAt: lastCapabilityAckAt, + }, + }), }; } diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 086779e1..23cc4769 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -73,11 +73,20 @@ export interface Protection { /** Present when detection reporting is on — delivery counts (in events) and the last acknowledgement. * Carries no request data. */ detectionHealth?: () => { + /** Events attempted, acknowledged, refused or unreachable, and dropped for queue pressure. */ sent: number; delivered: number; failed: number; dropped: number; lastDeliveredAt: string | null; + /** Capability announcements, counted separately: these carry no events, so they never move the + * counters above. Zero here alongside delivered events is normal, and so is the reverse. */ + capability: { + announced: number; + acknowledged: number; + failed: number; + lastAcknowledgedAt: string | null; + }; }; } diff --git a/src/protect/runtime.js b/src/protect/runtime.js index e310b1e0..6b33f5d3 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -771,8 +771,9 @@ export async function createProtection(options = {}) { mode = resolveMode(options, next); applyBundle(next); // Recomputed from the origin this refresh resolved. Within one process the reachable changes are a - // rules source that starts or stops being the platform's, and an opt-out appearing in the - // environment; the credential is resolved once at boot, so losing it mid-process is not modelled. + // rules source that STARTS being the platform's, and an opt-out appearing in the environment. It + // cannot stop being the platform's: once a bundle has been accepted the memory tier holds it, so a + // later failed fetch still resolves to `cache`. The credential is resolved once at boot. await applyReportingState(next.source?.origin); // After the swap, and only after it: later detections belong to the bundle now running. A refresh // that fell back to the cached or bundled ruleset kept the previous rules, and `store.read()` then diff --git a/tests/protect/reporting-runtime.test.ts b/tests/protect/reporting-runtime.test.ts index 9ea13f6e..f8de30a8 100644 --- a/tests/protect/reporting-runtime.test.ts +++ b/tests/protect/reporting-runtime.test.ts @@ -19,7 +19,7 @@ const RULES = { const drain = async () => { await new Promise((r) => setTimeout(r, 5)); }; /** A fetch stub that serves rules, and records the capability header of every rules request. */ -function stubFetch(opts: { rulesOk?: boolean; etag?: string } = {}) { +function stubFetch(opts: { rulesOk?: boolean; etag?: string; refuseDetections?: boolean } = {}) { const capabilityHeaders: Array = []; const posted: string[] = []; const bodies: any[] = []; @@ -40,7 +40,10 @@ function stubFetch(opts: { rulesOk?: boolean; etag?: string } = {}) { posted.push(target); bodies.push(JSON.parse(String(init?.body ?? '{}'))); - return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + return new Response('{}', { + status: opts.refuseDetections ? 503 : 200, + headers: { 'Content-Type': 'application/json' }, + }); } capabilityHeaders.push((init?.headers as Record)?.['X-Patchstack-Detections']); if (!rulesOk) throw new Error('rules unreachable'); @@ -218,6 +221,45 @@ describe('the settled state reaches the platform', () => { // It carries no events — its only content is the state. for (const body of announcements) expect(body.detections).toEqual([]); + // And it is accounted for separately. The event counters are measured in events, so an announcement + // moving them would produce readings that describe no real delivery — `sent: 0` with `failed: 1` — and + // would make an acknowledgement look like a delivered detection. + const health = p.detectionHealth(); + expect(health.capability, 'the announcement is counted as a capability, not an event').toMatchObject({ + announced: 1, + acknowledged: 1, + failed: 0, + }); + expect(health.capability.lastAcknowledgedAt).not.toBeNull(); + expect( + { sent: health.sent, delivered: health.delivered, failed: health.failed, lastDeliveredAt: health.lastDeliveredAt }, + 'no event has been delivered, so the event counters have not moved', + ).toEqual({ sent: 0, delivered: 0, failed: 0, lastDeliveredAt: null }); + + p.stop(); + }); + + it('counts a refused announcement against capability, not against events', async () => { + // The failure direction of the same separation: a refused announcement must not appear as a refused + // detection, which is what `failed` counts. + const { bodies } = stubFetch({ refuseDetections: true }); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: AUTH, + detectionFlushMs: 1, + }); + await drain(); + await drain(); + + expect(bodies.filter((b) => typeof b.reporting_state === 'string').length).toBe(1); + + const health = p.detectionHealth(); + expect(health.capability).toMatchObject({ announced: 1, acknowledged: 0, failed: 1 }); + expect(health.failed, 'no event was refused, because none was sent').toBe(0); + expect(health.sent).toBe(0); + p.stop(); }); From 5f3fe5badd21cea5b61c17c3652f24dd286111e7 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 31 Aug 2026 18:16:27 +0200 Subject: [PATCH 05/33] Acknowledge the settled state after a refresh, not only after boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every request that carries a reporting state declares it before resolution decides where the rules came from, so any of them can settle somewhere else. The boot path corrected that; the refresh path did not — so a guard that started on bundled or empty rules, recovered on a one-shot manual refresh, and never refreshed again left the platform holding the pre-resolution answer for the life of the process. Both paths now go through one helper that applies the settled state and acknowledges a mismatch, so they cannot diverge again. The refresh holds the state it declared in a binding and compares against exactly that, rather than recomputing it from the resolved origin — which would always agree with itself and never acknowledge anything. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/runtime.js | 35 ++++++++++++++++++------- tests/protect/reporting-runtime.test.ts | 10 ++++++- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 6b33f5d3..5b6e4ccb 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -238,12 +238,25 @@ export async function createProtection(options = {}) { return next; }; - const settled = await applyReportingState(bundle.source?.origin); - // The fetch above carried the state as it stood before the fetch decided where the rules came from. If - // resolution settled somewhere else, say so now on an authenticated request of its own: waiting for the - // next refresh would leave the platform holding the pre-resolution answer, and a guard with refreshing - // switched off has no next refresh. - if (settled.state !== preFetchState && detections) detections.announce(settled.state); + /** + * Apply the settled state, and correct the platform if the request that just went out declared another. + * + * Both the boot fetch and every refresh declare a state BEFORE resolution decides where the rules came + * from, so either can settle somewhere else. This is the single place that reconciles the two, so the + * two paths cannot drift apart: a guard whose only refresh is a one-shot manual call would otherwise + * leave the platform holding the pre-resolution answer for the life of the process. + * + * @param {'api'|'cache'|'bundled'|'empty'|undefined} origin + * @param {string} declared the state carried by the request that produced `origin` + */ + const applyAndAcknowledge = async (origin, declared) => { + const settled = await applyReportingState(origin); + if (settled.state !== declared && detections) detections.announce(settled.state); + + return settled; + }; + + await applyAndAcknowledge(bundle.source?.origin, preFetchState); // Mode is mutable so a Pulse refresh can flip dry-run ↔ block when SaaS enables production. // Precedence: PATCHSTACK_MODE env (local override) > API enforcement > options.mode > dry-run. let mode = resolveMode(options, bundle); @@ -761,12 +774,14 @@ export async function createProtection(options = {}) { notify(onError, err, 'onError'); // a failed report must not stop the rule refresh } } + // Derived from the origin in force, re-reading the environment, rather than resent from the last + // reported value: an opt-out that appeared since the previous request has to travel on THIS one. Held + // in a binding because the acknowledgement below compares against exactly what this request carried. + const declaredState = stateFor(currentOrigin).state; const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth, - // Derived from the origin in force, re-reading the environment, rather than resent from the last - // reported value: an opt-out that appeared since the previous request has to travel on THIS one. - detectionState: stateFor(currentOrigin).state, + detectionState: declaredState, }); mode = resolveMode(options, next); applyBundle(next); @@ -774,7 +789,7 @@ export async function createProtection(options = {}) { // rules source that STARTS being the platform's, and an opt-out appearing in the environment. It // cannot stop being the platform's: once a bundle has been accepted the memory tier holds it, so a // later failed fetch still resolves to `cache`. The credential is resolved once at boot. - await applyReportingState(next.source?.origin); + await applyAndAcknowledge(next.source?.origin, declaredState); // After the swap, and only after it: later detections belong to the bundle now running. A refresh // that fell back to the cached or bundled ruleset kept the previous rules, and `store.read()` then // still holds the previous identity — which is exactly the answer that stays true. diff --git a/tests/protect/reporting-runtime.test.ts b/tests/protect/reporting-runtime.test.ts index f8de30a8..6d45f5de 100644 --- a/tests/protect/reporting-runtime.test.ts +++ b/tests/protect/reporting-runtime.test.ts @@ -333,7 +333,7 @@ describe('reporting follows a refresh', () => { it('starts once a refresh receives platform rules', async () => { // The recovery path. A guard that started on its own bundle because the first fetch failed must begin // reporting when the platform becomes reachable — not stay silent for the life of the process. - const { posted, setRulesOk } = stubFetch(); + const { posted, bodies, setRulesOk } = stubFetch(); setRulesOk(false); const p: any = await createProtection({ @@ -350,10 +350,18 @@ describe('reporting follows a refresh', () => { setRulesOk(true); const status = await p.refresh(); + await drain(); expect(status).toMatchObject({ ok: true, origin: 'api' }); expect(p.detectionReporting, 'the state follows the refresh').toBe('on'); expect(p.detectionHealth, 'and so does the health surface').toBeTypeOf('function'); + // And the platform is told. The refresh request declared `no-managed-rules` and resolution settled on + // `on`, which is the same mismatch the boot path corrects — a guard whose only refresh is this + // one-shot call would otherwise leave the platform on the pre-resolution answer for good. + expect( + bodies.filter((b) => typeof b.reporting_state === 'string').map((b) => b.reporting_state), + 'the settled state is acknowledged after a recovering refresh', + ).toContain('on'); await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); p.stop(); From 1411e8bc2d39f6e2bf573ccf02ed728eab3f7813 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 31 Aug 2026 18:42:38 +0200 Subject: [PATCH 06/33] Resolve a client address from what the runtime observed, with its provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client address is only as trustworthy as whatever supplied it. A transport peer address is observed and cannot be set by the caller; a forwarded header is ordinary request input, meaningful only when the request is known to have arrived through a proxy that sets it. `resolveClientIp` reports the address together with where it came from — `runtime`, `trusted-proxy`, or `unavailable` — and `unavailable` is both the default and a real answer. A forwarded header is read only when two things hold together. The peer must be one the deployment declared, because every direct connection has a peer and its existence proves nothing. And the chain is walked from the application side inward, stepping over declared hops to the first untrusted address, because a proxy appends rather than replaces — taking the client-most entry trusts whatever the caller prepended. A policy has to say who is trusted: `peers` as CIDRs, `hops` as a count, or an `isTrusted` predicate. A configuration naming only a header declares nothing. Where `peers` or `isTrusted` is present that verdict gates the peer; a policy declaring only `hops` states the trust numerically, so the count is evaluated on its own. `hops` counts from the peer, matching the numeric form of Express's trust policy, and is pinned across the range because a number copied from an Express configuration has to land on the same address. Trust configuration fails closed. One unparseable member invalidates the whole policy rather than being dropped, and a prefix accepts exactly one optional slash. Addresses are parsed strictly and reported canonically, so a value the validator accepted is the same value wherever it is matched or stored: IPv4 in dotted decimal with leading zeros refused, IPv6 lowercased and compressed, IPv4-mapped IPv6 reduced to the IPv4 it carries, a zone identifier only on IPv6 and only when it names something. A chain entry that is not an address stops the walk. There are no provider shortcuts. A provider's name does not establish that the provider overwrote the header — several document that a client-supplied value survives unless the service is configured to replace it, and one that does overwrite it does so only for requests that actually traversed it. A shortcut worth having encodes a policy verifiable at run time, which needs an adapter that positively establishes the runtime. Not yet wired into the guards. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/client-ip.js | 392 +++++++++++++++++++++++++++++++ tests/protect/client-ip.test.ts | 393 ++++++++++++++++++++++++++++++++ 2 files changed, 785 insertions(+) create mode 100644 src/protect/client-ip.js create mode 100644 tests/protect/client-ip.test.ts diff --git a/src/protect/client-ip.js b/src/protect/client-ip.js new file mode 100644 index 00000000..5636a080 --- /dev/null +++ b/src/protect/client-ip.js @@ -0,0 +1,392 @@ +/** + * Resolve a client address, and say where it came from. + * + * A client address is only as trustworthy as the thing that supplied it. A transport peer address is + * observed by the runtime and cannot be set by the caller. A forwarded header is an ordinary request + * header: anyone can send one, and it means something only when the request is known to have arrived + * through a proxy that sets it and discards what the client sent. + * + * So provenance travels with the value, and nothing is trusted implicitly: + * + * `runtime` the transport peer address, observed rather than claimed + * `trusted-proxy` read from a forwarded chain, through peers a policy declares trustworthy + * `unavailable` no address this can stand behind + * + * `unavailable` is the default and a real answer. A runtime that cannot produce a verifiable address + * should say so rather than pass on a value the caller chose: an address recorded against a security + * event attributes that event, and one an attacker picked attributes it to whoever they name. + * + * ## What makes a header trustworthy + * + * Two things, together, and neither alone: + * + * 1. The PEER is a declared trusted proxy. A peer merely existing proves nothing — every direct + * connection has one — so a policy has to say which peers are the deployment's own front end. + * 2. The chain is walked from the APPLICATION side inward, skipping trusted hops, and the first + * untrusted address is the client. Taking the client-most entry instead trusts whatever the caller + * prepended, because a proxy appends rather than replaces. + * + * There are deliberately no provider shortcuts. A provider's name does not establish that the provider + * overwrote the header — several document that a client-supplied value survives unless the service is + * configured to replace it, and one that does overwrite it only does so for requests that actually + * traversed it. A shortcut worth having has to encode a policy that can be verified at run time, which + * needs a platform adapter that positively establishes the runtime; a header name on its own does not. + */ + +/** Provenance values. */ +export const IP_SOURCES = Object.freeze(['runtime', 'trusted-proxy', 'unavailable']); + +/** The header consulted when a policy does not name one. */ +const DEFAULT_FORWARDED_HEADER = 'x-forwarded-for'; + +/** + * Parse an IPv4 literal into its four octets, or null. + * + * Leading zeros are rejected: `010` is read as decimal 10 here and as octal 8 by some resolvers, and an + * address that means two things is not an identity. + */ +function parseIpv4(value) { + const parts = value.split('.'); + if (parts.length !== 4) return null; + + const octets = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + if (part.length > 1 && part.startsWith('0')) return null; + const n = Number(part); + if (n > 255) return null; + octets.push(n); + } + + return octets; +} + +/** + * Parse an IPv6 literal into its eight 16-bit groups, or null. + * + * Handles `::` compression and a trailing embedded IPv4 (`::ffff:203.0.113.1`), which is the form Node + * reports for an IPv4 client on a dual-stack socket. + */ +function parseIpv6(value) { + if (value === '' || value.includes(':::')) return null; + + let head = value; + let embedded = null; + const lastColon = head.lastIndexOf(':'); + const tail = lastColon === -1 ? '' : head.slice(lastColon + 1); + if (tail.includes('.')) { + embedded = parseIpv4(tail); + if (embedded === null) return null; + head = head.slice(0, lastColon + 1) + '0:0'; + } + + const halves = head.split('::'); + if (halves.length > 2) return null; + + const readGroups = (text) => { + if (text === '') return []; + const groups = []; + for (const g of text.split(':')) { + if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null; + groups.push(Number.parseInt(g, 16)); + } + + return groups; + }; + + const left = readGroups(halves[0]); + const right = halves.length === 2 ? readGroups(halves[1]) : []; + if (left === null || right === null) return null; + + let groups; + if (halves.length === 2) { + const fill = 8 - (left.length + right.length); + if (fill < 1) return null; + groups = [...left, ...new Array(fill).fill(0), ...right]; + } else { + groups = left; + } + if (groups.length !== 8) return null; + + if (embedded !== null) { + groups[6] = (embedded[0] << 8) | embedded[1]; + groups[7] = (embedded[2] << 8) | embedded[3]; + } + + return groups; +} + +/** + * An address as a comparable big integer, its width, and its canonical spelling — or null when the text + * is not an address. + * + * Strict about the syntax around the address, not only the address itself: + * + * - The bracketed form is not accepted. A bracket is not a valid character in an address, so the group + * parser rejects `[::1`, `::1]` and `[2001:db8::1]:8080` without needing a rule of its own — and + * stripping the delimiters instead would accept the unbalanced spellings. A proxy emitting the + * bracketed form is therefore not understood, and the walk falls back to the observed peer. + * - A zone identifier (`%eth0`) is permitted only on IPv6, and only when it names something. An IPv4 + * literal has no zone, so `1.2.3.4%eth0` is malformed rather than an address with decoration. + * + * The canonical spelling is what callers should store and match on, so a value the validator accepted is + * the same value everywhere: IPv4 in dotted decimal, IPv6 lowercased with the longest zero run + * compressed, and an IPv4-mapped IPv6 address reduced to the IPv4 it carries. + */ +function toNumeric(value) { + const text = String(value).trim(); + if (text === '') return null; + + const zoneAt = text.indexOf('%'); + const addr = zoneAt === -1 ? text : text.slice(0, zoneAt); + const zone = zoneAt === -1 ? null : text.slice(zoneAt + 1); + // A zone must name something, and only IPv6 has one. + if (zone !== null && (zone === '' || !addr.includes(':'))) return null; + + const v4 = parseIpv4(addr); + if (v4 !== null) { + return { + bits: 32, + value: v4.reduce((acc, octet) => (acc << 8n) | BigInt(octet), 0n), + canonical: v4.join('.'), + }; + } + + if (!addr.includes(':')) return null; + const v6 = parseIpv6(addr); + if (v6 === null) return null; + + // `::ffff:a.b.c.d` is the same host as `a.b.c.d`, so it canonicalises to the IPv4 form and compares + // against IPv4 policies. + const mapped = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 0xffff; + if (mapped) { + const octets = [v6[6] >> 8, v6[6] & 0xff, v6[7] >> 8, v6[7] & 0xff]; + + return { bits: 32, value: (BigInt(v6[6]) << 16n) | BigInt(v6[7]), canonical: octets.join('.') }; + } + + return { + bits: 128, + value: v6.reduce((acc, group) => (acc << 16n) | BigInt(group), 0n), + canonical: canonicalIpv6(v6), + }; +} + +/** Lowercase hex groups with the longest run of two or more zero groups compressed to `::`. */ +function canonicalIpv6(groups) { + let bestStart = -1; + let bestLength = 0; + let runStart = -1; + + for (let i = 0; i <= groups.length; i++) { + if (i < groups.length && groups[i] === 0) { + if (runStart === -1) runStart = i; + } else if (runStart !== -1) { + const length = i - runStart; + if (length > bestLength) { + bestLength = length; + bestStart = runStart; + } + runStart = -1; + } + } + + const hex = groups.map((g) => g.toString(16)); + if (bestLength < 2) return hex.join(':'); + + const head = hex.slice(0, bestStart).join(':'); + const tail = hex.slice(bestStart + bestLength).join(':'); + + return `${head}::${tail}`; +} + +/** + * The canonical spelling of an address, or null when the text is not one. + * + * Exported because every surface that stores or matches an address should use the same spelling — two + * records of one client that differ only in how the address was written are two records. + */ +export function canonicalIp(value) { + return toNumeric(value)?.canonical ?? null; +} + +/** + * Whether `value` is a syntactically valid IP address. + * + * Applied to everything before it is used or reported. A forwarded chain can carry `unknown`, a hostname + * or arbitrary text, and the resolved value is matched by rules and recorded against retained events — so + * a string that is not an address is not an answer. + */ +export function isIpAddress(value) { + return canonicalIp(value) !== null; +} + +/** + * Parse `1.2.3.0/24` or a bare address into a matcher, or null. + * + * Exactly one optional slash. `10.0.0.0/8/typo` is a typo, and reading it as `/8` would silently install + * a policy the operator did not write. + */ +function parseCidr(entry) { + if (typeof entry !== 'string') return null; + const parts = entry.trim().split('/'); + if (parts.length > 2) return null; + + const numeric = toNumeric(parts[0]); + if (numeric === null) return null; + + let length = numeric.bits; + if (parts.length === 2) { + if (!/^\d{1,3}$/.test(parts[1])) return null; + length = Number(parts[1]); + if (length > numeric.bits) return null; + } + + const shift = BigInt(numeric.bits - length); + + return { bits: numeric.bits, network: numeric.value >> shift, shift }; +} + +/** + * Read a trusted-proxy policy, or null when the configuration declares none. + * + * A policy needs at least one way to recognise the deployment's own front end — `peers`, `hops`, or + * `isTrusted`. A configuration carrying only a header name declares nothing: it says which header to + * read without saying when reading it is safe, and that is the case where request input silently becomes + * an identity. + */ +export function readTrustPolicy(trustedProxy) { + if (trustedProxy === null || typeof trustedProxy !== 'object' || Array.isArray(trustedProxy)) return null; + + const header = + typeof trustedProxy.header === 'string' && trustedProxy.header.trim() !== '' + ? trustedProxy.header.trim().toLowerCase() + : DEFAULT_FORWARDED_HEADER; + + // Trust configuration fails closed. One unparseable entry invalidates the whole policy rather than + // being dropped: a policy that silently lost a member is a policy nobody wrote, and the operator who + // typed it would have no way to tell from the behaviour which half took effect. + const cidrs = []; + if (Array.isArray(trustedProxy.peers)) { + for (const entry of trustedProxy.peers) { + const parsed = parseCidr(entry); + if (parsed === null) return null; + cidrs.push(parsed); + } + } else if (trustedProxy.peers !== undefined) { + return null; + } + + const hops = Number.isInteger(trustedProxy.hops) && trustedProxy.hops > 0 ? trustedProxy.hops : null; + const predicate = typeof trustedProxy.isTrusted === 'function' ? trustedProxy.isTrusted : null; + + if (cidrs.length === 0 && hops === null && predicate === null) return null; + + return { header, cidrs, hops, predicate }; +} + +/** Whether an address is one of the deployment's declared proxies. */ +function isTrustedPeer(policy, value) { + const numeric = toNumeric(value); + if (numeric === null) return false; + + for (const cidr of policy.cidrs) { + if (cidr.bits === numeric.bits && numeric.value >> cidr.shift === cidr.network) return true; + } + + if (policy.predicate !== null) { + try { + if (policy.predicate(String(value)) === true) return true; + } catch { + // A throwing predicate is not a grant of trust. + return false; + } + } + + return false; +} + +/** The forwarded chain, in wire order (client-most first), with only real addresses kept. */ +function chainFrom(headers, header) { + const raw = headers?.[header]; + const value = Array.isArray(raw) ? raw.join(',') : raw; + if (typeof value !== 'string' || value.trim() === '') return []; + + return value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''); +} + +/** + * Resolve the client address for a request. + * + * @param {{ + * peer?: unknown, + * headers?: Record, + * trustedProxy?: unknown, + * }} input + * @returns {{ ip: string | null, source: 'runtime' | 'trusted-proxy' | 'unavailable' }} + */ +export function resolveClientIp(input) { + const headers = input.headers ?? {}; + // Canonical from here on, so every surface stores and matches the same spelling. + const peer = canonicalIp(input.peer); + const policy = readTrustPolicy(input.trustedProxy); + + // No peer means no transport-level anchor. A forwarded header here is indistinguishable from one the + // caller wrote, whatever it is called, so there is nothing to report. + if (peer === null) return { ip: null, source: 'unavailable' }; + + if (policy === null) return { ip: peer, source: 'runtime' }; + + // Which part of the policy gates the peer. + // + // A policy declaring `peers` or `isTrusted` names the deployment's own front end, so that verdict + // governs: a connection from anywhere else did not arrive through it. A policy declaring only `hops` + // makes the statement numerically instead — the peer IS hop one — so the count is itself the trust and + // has to be evaluated on its own. Requiring a CIDR match there would make a hops-only policy accepted + // by configuration and inert in practice. + const gatedByAddress = policy.cidrs.length > 0 || policy.predicate !== null; + const peerTrusted = gatedByAddress ? isTrustedPeer(policy, peer) : policy.hops !== null; + if (!peerTrusted) return { ip: peer, source: 'runtime' }; + + const chain = chainFrom(headers, policy.header); + if (chain.length === 0) return { ip: peer, source: 'runtime' }; + + if (policy.hops !== null) { + // `hops` counts trusted proxies starting AT THE PEER, matching the numeric form of Express's trust + // policy. So `hops: 1` means the peer is the only proxy and the client is the application-most entry + // in the chain; `hops: 2` means the peer plus one charted hop; and so on. Counting from the chain + // instead would be off by one against every deployment that copied its number from an Express + // configuration. + const index = chain.length - policy.hops; + const candidate = index >= 0 ? chain[index] : undefined; + + const canonical = canonicalIp(candidate); + + return canonical === null ? { ip: peer, source: 'runtime' } : { ip: canonical, source: 'trusted-proxy' }; + } + + // Walk inward from the application side, stepping over hops the policy trusts. The first address that + // is not one of ours is the client. An entry that is not an address at all stops the walk: the chain + // cannot be reasoned about past something that is not a hop. + for (let i = chain.length - 1; i >= 0; i--) { + const canonical = canonicalIp(chain[i]); + if (canonical === null) return { ip: peer, source: 'runtime' }; + if (!isTrustedPeer(policy, canonical)) return { ip: canonical, source: 'trusted-proxy' }; + } + + // Every hop was one of ours, which leaves no client in the chain to name. + return { ip: peer, source: 'runtime' }; +} + +/** + * The event fields for a resolved address. + * + * `client_ip` is omitted entirely when there is none, rather than sent as null or an empty string: a + * field that is present but empty reads as a failed lookup of a real address. The provenance is always + * present, because "this could not be established" is the part a reader needs. + */ +export function clientIpFields(resolved) { + return resolved.ip === null + ? { client_ip_source: resolved.source } + : { client_ip: resolved.ip, client_ip_source: resolved.source }; +} diff --git a/tests/protect/client-ip.test.ts b/tests/protect/client-ip.test.ts new file mode 100644 index 00000000..929c5a59 --- /dev/null +++ b/tests/protect/client-ip.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect } from 'vitest'; +import { + IP_SOURCES, + canonicalIp, + clientIpFields, + isIpAddress, + readTrustPolicy, + resolveClientIp, +} from '../../src/protect/client-ip.js'; + +/** + * Where a client address came from, and when there is no answer. + * + * An address recorded against a security event attributes that event, so an address the caller chose + * attributes it to whoever they name. Two things together make a forwarded header usable — the peer is a + * declared proxy, and the chain is walked from the application side to the first untrusted hop — and + * neither is sufficient alone. + */ +const PEER = '203.0.113.10'; +const CLIENT = '198.51.100.99'; +const PROXY = '10.0.0.7'; +const POLICY = { peers: ['10.0.0.0/8'] }; + +describe('a peer existing is not a trust anchor', () => { + it.each([ + 'x-forwarded-for', + 'x-real-ip', + 'cf-connecting-ip', + 'true-client-ip', + 'fastly-client-ip', + 'forwarded', + ])('ignores %s with no policy at all', (header) => { + expect(resolveClientIp({ peer: PEER, headers: { [header]: CLIENT } })).toEqual({ + ip: PEER, + source: 'runtime', + }); + }); + + it('ignores the chain when the peer is not a declared proxy', () => { + // Every direct connection has a peer, so the peer's existence proves nothing. This request came + // straight from the internet, and its forwarded header is the caller's own invention. + expect( + resolveClientIp({ peer: PEER, headers: { 'x-forwarded-for': CLIENT }, trustedProxy: POLICY }), + ).toEqual({ ip: PEER, source: 'runtime' }); + }); + + it('reads the chain when the peer IS a declared proxy', () => { + expect( + resolveClientIp({ peer: PROXY, headers: { 'x-forwarded-for': CLIENT }, trustedProxy: POLICY }), + ).toEqual({ ip: CLIENT, source: 'trusted-proxy' }); + }); +}); + +describe('the chain is walked from the application side', () => { + it('ignores an address the caller prepended', () => { + // A proxy APPENDS, so the client-most entry is whatever the caller sent. Walking inward from the + // application and stopping at the first untrusted hop is what finds the real client. + const resolved = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': `9.9.9.9, ${CLIENT}, 10.0.0.3` }, + trustedProxy: POLICY, + }); + + expect(resolved).toEqual({ ip: CLIENT, source: 'trusted-proxy' }); + }); + + it('steps over several trusted hops', () => { + const resolved = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': `${CLIENT}, 10.1.1.1, 10.2.2.2, 10.3.3.3` }, + trustedProxy: POLICY, + }); + + expect(resolved).toEqual({ ip: CLIENT, source: 'trusted-proxy' }); + }); + + it('falls back to the peer when every hop is one of ours', () => { + // No client in the chain to name, so the only address this can stand behind is the observed peer. + const resolved = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': '10.1.1.1, 10.2.2.2' }, + trustedProxy: POLICY, + }); + + expect(resolved).toEqual({ ip: PROXY, source: 'runtime' }); + }); + + it('stops at an entry that is not an address', () => { + // A chain cannot be reasoned about past something that is not a hop, and `unknown` is a value real + // proxies emit. + const resolved = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': `${CLIENT}, unknown, 10.0.0.3` }, + trustedProxy: POLICY, + }); + + expect(resolved).toEqual({ ip: PROXY, source: 'runtime' }); + }); + + it.each([ + // `hops` counts trusted proxies starting at the PEER, which is how the numeric form of Express's + // trust policy counts. Pinned across the range because a deployment copying its number from an + // Express configuration must land on the same address, and an off-by-one here silently attributes + // events to a proxy or to a caller-supplied value. + [1, '10.0.0.3'], + [2, '172.16.0.1'], + [3, CLIENT], + ])('with hops=%i names %s as the client', (hops, expected) => { + const resolved = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': `${CLIENT}, 172.16.0.1, 10.0.0.3` }, + trustedProxy: { peers: ['10.0.0.0/8'], hops }, + }); + + expect(resolved).toEqual({ ip: expected, source: 'trusted-proxy' }); + }); + + it('falls back to the peer when the hop count runs past the chain', () => { + // A misconfigured count must not wrap around to the client-most entry, which is the one the caller + // controls. + const resolved = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': `${CLIENT}, 10.0.0.3` }, + trustedProxy: { peers: ['10.0.0.0/8'], hops: 9 }, + }); + + expect(resolved).toEqual({ ip: PROXY, source: 'runtime' }); + }); + + it('honours a predicate, and a throwing one grants nothing', () => { + const allow = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': CLIENT }, + trustedProxy: { isTrusted: (ip: string) => ip.startsWith('10.') }, + }); + const throwing = resolveClientIp({ + peer: PROXY, + headers: { 'x-forwarded-for': CLIENT }, + trustedProxy: { isTrusted: () => { throw new Error('boom'); } }, + }); + + expect(allow).toEqual({ ip: CLIENT, source: 'trusted-proxy' }); + expect(throwing).toEqual({ ip: PROXY, source: 'runtime' }); + }); +}); + +describe('a policy has to declare who is trusted', () => { + it.each([ + ['a header name alone', { header: 'x-forwarded-for' }], + ['nothing at all', {}], + ['a bare true', true], + ['a bare string', 'cloudflare'], + ['an array', ['10.0.0.0/8']], + ['null', null], + ['undefined', undefined], + ['unparseable peers', { peers: ['not-an-address'] }], + ['a zero hop count', { hops: 0 }], + ])('reads no policy from %s', (_label, trustedProxy) => { + // A configuration naming only a header says which header to read without saying when reading it is + // safe — which is exactly the case where request input silently becomes an identity. + expect(readTrustPolicy(trustedProxy)).toBeNull(); + }); + + it('reads a policy from peers, hops, or a predicate', () => { + expect(readTrustPolicy({ peers: ['10.0.0.0/8'] })).not.toBeNull(); + expect(readTrustPolicy({ hops: 1 })).not.toBeNull(); + expect(readTrustPolicy({ isTrusted: () => true })).not.toBeNull(); + }); + + it('defaults the header, and lets a policy name another', () => { + expect(readTrustPolicy({ peers: ['10.0.0.0/8'] })?.header).toBe('x-forwarded-for'); + expect(readTrustPolicy({ peers: ['10.0.0.0/8'], header: 'X-Real-IP' })?.header).toBe('x-real-ip'); + }); + + it('has no provider shortcuts', () => { + // A provider's name does not establish that the provider overwrote the header. Several document that + // a client-supplied value survives unless the service is configured to replace it. + for (const provider of ['cloudflare', 'fastly', 'akamai', 'vercel']) { + expect(readTrustPolicy({ provider }), provider).toBeNull(); + } + }); +}); + +describe('only real addresses are accepted', () => { + it.each(['203.0.113.1', '0.0.0.1', '255.255.255.255', '::1', '2001:db8::1', '::ffff:203.0.113.1'])( + 'accepts %s', + (value) => { + expect(isIpAddress(value)).toBe(true); + }, + ); + + it.each([ + 'unknown', + 'localhost', + 'example.com', + '203.0.113', + '203.0.113.256', + '010.1.1.1', + '1.2.3.4.5', + '', + ' ', + ':::1', + '2001:db8::1::2', + 'gggg::1', + '', + // Parses, but names nobody — an event carrying it would attribute itself to no one, in a field + // whose whole job is attribution. + '0.0.0.0', + '::', + '::ffff:0.0.0.0', + ])('refuses %s as an address, whatever its provenance claims', (ip) => { + // This function states the payload invariant, so it checks rather than assumes. A resolver bypass — + // a future adapter, or a record rebuilt elsewhere — must not be able to put a hostname or a + // nobody-address into retained evidence. + for (const source of ['runtime', 'trusted-proxy'] as const) { + expect(clientIpFields({ ip, source } as never)).toEqual({ client_ip_source: 'unavailable' }); + } + }); + + it.each([ + ['::FFFF:198.51.100.9', '198.51.100.9'], + ['2001:0DB8:0000:0000:0000:0000:0000:0001', '2001:db8::1'], + ])('emits %s in its canonical form %s', (ip, canonical) => { + // One address must not appear as two records depending on how a hop happened to spell it. + expect(clientIpFields({ ip, source: 'trusted-proxy' } as never)).toEqual({ + client_ip: canonical, + client_ip_source: 'trusted-proxy', + }); + }); + it('carry both when an address was established', () => { expect(clientIpFields({ ip: PEER, source: 'runtime' })).toEqual({ client_ip: PEER, From b1f957935868535d993091d529a9f8a1f0b4a4c3 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 10:07:23 +0200 Subject: [PATCH 13/33] Narrow the callback's view of a rule, and take raw evidence only from the request `onDetect` documents `rule` as `{ id, category }`, and that is now exactly what it receives. The rule the engine matches with IS the policy in force: enforcement lives in `rule_v2`, in `when`, and in the match and action objects inside them, so any view that still shares them lets a callback change what later requests through that guard are screened for. Projected rather than deep-cloned because this path runs on every detection, which an attacker can drive. On a 1 MiB rule, 1,000 detections cost about 1.2 s to clone and about 0.1 ms to project, and the clone existed only to hand back fields the contract does not promise. The internal reporters go on reading the real rule; only the callback's view is narrowed. Raw-body evidence is taken only from an own property, at the Express view and at the engine's normalising funnel. Evidence is what a request actually carried; a `_rawBody` reachable through a polluted prototype was carried by nothing, and accepting it would let a single pollution stand as verbatim bytes and fire every raw rule that matches it. Both layers are load-bearing: a shaped request still inherits from `Object.prototype`, so stripping the value at one layer does not keep it out of the other. The adapters that capture real bytes define `_rawBody` on the request object itself. Rule scope is on request in the wiring fixtures, so a view isolating `rule_v2` while sharing `when` is caught by a second request rather than passing. The shipped docs record the address behaviour for anyone upgrading: Express no longer consults `req.ip`, an app behind an undeclared proxy sees the proxy's address, and a Fetch runtime with no transport peer reports none. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 21 +++++++ src/protect/engine/normalizer.js | 5 +- src/protect/runtime.js | 33 ++++++----- tests/protect/client-ip-wiring.test.ts | 79 ++++++++++++++++++++------ 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 153f77da..a9f4d42a 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -189,6 +189,27 @@ empty, so a missing address cannot read as a failed lookup of a real one. A forw trusted implicitly: with no `trustedProxy` policy the address is whatever the transport observed, and in a runtime that exposes no transport peer there is no address to report at all. +### Behaviour change: how the client address is determined + +The guard resolves the client address itself, once per request, and shares that one answer with rule +matching, block logging and detection reports — so those cannot disagree about who a request came from. +Two consequences if you are upgrading: + +- **Express: `req.ip` is no longer consulted.** It reflects Express's own `trust proxy` setting, which the + guard cannot verify, so the guard reads the transport peer instead. **If your app runs behind a proxy or + load balancer, addresses will now show as the proxy's** until you declare your proxies with + `trustedProxy` (below). Rules matching on `server.ip` or `REMOTE_ADDR` see the same value. +- **Runtimes with no transport peer report no address.** A WHATWG `Request` exposes no peer, so a Fetch + guard (Workers, Deno, Bun, edge) has nothing to observe and no forwarded header is accepted in its + place: `client_ip_source` is `unavailable` and no address is sent. The Node and Express guards read the + socket peer and are unaffected. + +`trustedProxy` is the only way to make a forwarded header count. It takes the proxies you actually run — +`{ peers: ['10.0.0.0/8'] }`, or `{ hops: 1 }` to trust that many hops in from the peer, plus optional +`header` and `isTrusted` — and the chain is then read from your application inward, stopping at the first +hop you have not declared. There are no built-in provider presets: a header a provider sets is +indistinguishable from one a client sent unless you say which peers may set it. + The parameter names are **identifiers, and they name the request region they refer to** — `post.title`, `get.redirect_to`, `cookie.session`, `server.HTTP_AUTHORIZATION`. So a rule that inspects a cookie or an `Authorization` header sends that cookie's or header's **name**. They are read from the rule's own diff --git a/src/protect/engine/normalizer.js b/src/protect/engine/normalizer.js index 692daa8e..a66abb04 100644 --- a/src/protect/engine/normalizer.js +++ b/src/protect/engine/normalizer.js @@ -206,7 +206,10 @@ export function normalizeRequest(req, options = {}) { // it preserves literal keys like `__proto__` that JSON.stringify drops, which is // what makes prototype-pollution rules on `raw` robust. Fall back to a // reconstruction from the parsed body (the Express path, which has no raw text). - const rawBody = typeof req._rawBody === 'string' + // Own property only: a `_rawBody` reachable through a polluted prototype is not something this + // request carried, and accepting it here would let it stand as verbatim evidence on every path. + const ownRaw = Object.hasOwn(req ?? {}, '_rawBody') && typeof req._rawBody === 'string'; + const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(req.body ?? null); diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 22bca2f7..563a12d9 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -97,24 +97,18 @@ export async function createProtection(options = {}) { let detections = null; /** - * A rule a callback can hold without reaching the policy in force. + * The rule as a callback sees it: its identity, and nothing that carries policy. * - * The rule handed to `onDetect` is the object the engine is matching against, and its interesting parts - * are nested: `rule_v2`, `when`, and the match and action objects inside them. A shallow copy shares all - * of those, so a callback can rewrite a match value and change what every later request through this - * guard is screened for. + * `onDetect` documents `rule` as `{ id, category }`, and that is what this returns. The rule the engine + * matches with IS the policy in force, and enforcement lives in its nested parts — `rule_v2`, `when`, + * and the match and action objects inside them — so handing out the object, or any copy that still + * shares them, lets a callback change what every later request through this guard is screened for. * - * A deep clone breaks that link. Where a rule cannot be cloned — anything a structured clone refuses — - * the fields consumers read are projected instead, because passing the live object would be worse than - * passing less of it. + * Projected rather than cloned because this runs on every detection, which an attacker can drive. A + * deep clone would allocate the whole rule per detection to hand back fields the contract does not + * promise. The internal reporters keep reading the real rule; only the callback's view is narrowed. */ - const isolateRule = (rule) => { - try { - return structuredClone(rule); - } catch { - return { id: rule?.id, category: rule?.category }; - } - }; + const ruleIdentity = (rule) => ({ id: rule?.id, category: rule?.category }); const onDetect = (detection) => { // What the platform is told is the engine's account of the request, and a host callback cannot change @@ -136,7 +130,7 @@ export async function createProtection(options = {}) { }); } - notify(userOnDetect, { ...detection, ...(detection?.rule ? { rule: isolateRule(detection.rule) } : {}) }, 'onDetect'); + notify(userOnDetect, { ...detection, ...(detection?.rule ? { rule: ruleIdentity(detection.rule) } : {}) }, 'onDetect'); }; // One tiered store (memory → filesystem/pluggable) shared by the initial load and every refresh. @@ -516,7 +510,12 @@ export async function createProtection(options = {}) { // otherwise reconstructs raw by re-serialising the parsed body — which cannot carry what parsing // did not keep: a body that failed to parse at all, a duplicate key where only the last value // survives, or the exact bytes a signature was written against. - _rawBody: req?._rawBody, + // + // Own property only. Evidence is what this request actually carried: a value reachable through a + // polluted prototype is not, and materialising it here would turn it into evidence indistinguish- + // able from real bytes — firing every raw rule that matches it. The adapters that capture real + // bytes define `_rawBody` on the request object itself. + ...(Object.hasOwn(req ?? {}, '_rawBody') ? { _rawBody: req._rawBody } : {}), // The resolved address, and the resolution itself for the consumers downstream. ip: client.ip ?? '', _clientIp: client, diff --git a/tests/protect/client-ip-wiring.test.ts b/tests/protect/client-ip-wiring.test.ts index 2064ef34..6674a1ac 100644 --- a/tests/protect/client-ip-wiring.test.ts +++ b/tests/protect/client-ip-wiring.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { RuleEngine } from '../../src/protect/engine/engine.js'; import { createNodeMiddleware } from '../../src/protect/engine/node.js'; +import { normalizeRequest } from '../../src/protect/engine/normalizer.js'; import { createProtection } from '../../src/protect/runtime.js'; /** @@ -20,13 +21,17 @@ import { createProtection } from '../../src/protect/runtime.js'; * Nested objects are what a callback can reach, so a shared constant would let one test's mutation change * the policy a later test enforces — and these tests exist to prove exactly that cannot happen. */ -const rules = () => ({ +const rules = (scope?: { path?: string; method?: string }) => ({ firewall: [ { id: 'ip-rule', title: 'address under test', // The rule reads the address, so what the engine resolved is observable in the outcome. rule_v2: [{ parameter: 'server.ip', match: { type: 'contains', value: '198.51.100.' } }], + // Scope on request, because it is policy in a second nested object: a view that isolated `rule_v2` + // and shared `when` would still let a callback change which requests the rule applies to. Only the + // tests that send a matching request ask for it. + ...(scope ? { when: scope } : {}), }, ], whitelists: [], @@ -476,10 +481,9 @@ describe('the transported detection carries the address', () => { describe('the response phase reuses the request phase resolution', () => { it('gives a response rule the same address, without resolving again', async () => { const detections: any[] = []; - // Counting the PREDICATE would prove nothing here: a Fetch request has no transport peer, so - // `resolveClientIp` returns before consulting it and one resolution looks the same as ten. The policy - // object is read while the policy is parsed, which happens on every resolution — so a trap on it - // counts resolutions whether or not an address is ever established. + // Resolutions are counted by trapping reads of the policy object. Policy parsing happens on every + // resolution, including a path with no transport peer where no address is ever established, so the + // count holds wherever this runs. let policyReads = 0; const trustedProxy = new Proxy( { isTrusted: (ip: string) => ip.startsWith('10.') }, @@ -718,6 +722,46 @@ describe('the Express view carries evidence a reconstructed body cannot', () => expect(await throughExpress(p, req), 'the raw rule reads the verbatim body').toBe(403); p.stop(); }); + + it('refuses raw evidence that only the prototype chain supplies', async () => { + // Evidence is what THIS request carried. A `_rawBody` reachable through a polluted prototype was + // carried by nothing, and accepting it would let one pollution fire every raw rule that matches it — + // arriving as verbatim bytes, indistinguishable from a real body. + // + // The test above is the positive control: the same rule, the same payload, as an own property. + const p: any = await createProtection({ + rules: { + firewall: [ + { + id: 'raw-proto', + title: 'reads the verbatim body', + rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '__proto__' } }], + }, + ], + whitelists: [], + whitelist_keys: {}, + }, + mode: 'block', + }); + + (Object.prototype as any)._rawBody = 'not-json __proto__ {{payload}}'; + try { + const req = expressReq({ headers: { 'content-type': 'application/json' }, body: {} }); + + expect(Object.hasOwn(req, '_rawBody'), 'the request carries no raw body of its own').toBe(false); + expect((req as any)._rawBody, 'but the chain offers one').toContain('__proto__'); + expect(await throughExpress(p, req), 'the inherited value is not evidence').toBeNull(); + + // The engine's own funnel, which every runtime path goes through, holds the same line. + const normalized = normalizeRequest({ body: {}, query: {}, headers: {}, url: '/' } as never); + expect(normalized._rawBody, 'the reconstruction, not the inherited text').not.toContain( + '__proto__', + ); + } finally { + delete (Object.prototype as any)._rawBody; + } + p.stop(); + }); }); describe('a host callback cannot rewrite what the platform is told', () => { @@ -806,17 +850,13 @@ describe('a host callback cannot rewrite what the platform is told', () => { expect(logged[0].request_uri).toBe('/api'); }); - it('still reports a rule a structured clone refuses, without the live object', async () => { - // A rule is a JSON document, but `rules:` is a programmatic option, so a caller can pass one carrying - // something no clone accepts. That must neither throw inside the detection path nor fall back to - // handing out the live rule. + it('hands the callback the rule identity only, whatever the rule carries', async () => { + // The contract for `rule` is `{ id, category }`, and narrowing to it is what keeps policy out of a + // callback's reach. The rule here also carries something no structured clone accepts, because this + // path runs on every detection and must not depend on a rule being copyable. const seen: any[] = []; - const uncloneable = { - ...rules(), - firewall: rules().firewall.map((r) => ({ ...r, onSomething: () => {} })), - }; const p: any = await createProtection({ - rules: uncloneable, + rules: { ...rules(), firewall: rules().firewall.map((r) => ({ ...r, onSomething: () => {} })) }, mode: 'block', onDetect: (d: any) => { seen.push(d.rule); @@ -836,8 +876,8 @@ describe('a host callback cannot rewrite what the platform is told', () => { expect(await throughExpress(p, req()), 'the rule still blocks').toBe(403); expect(seen.length, 'the callback still ran').toBeGreaterThan(0); expect(seen[0].id, 'the rule is still identified').toBe('ip-rule'); - // The projection, not the live rule: nothing nested came through for a callback to reach. - expect(seen[0].rule_v2, 'no nested policy was handed out').toBeUndefined(); + // Exactly the promised fields, so nothing carrying policy went out. + expect(Object.keys(seen[0]).sort()).toEqual(['category', 'id']); expect(await throughExpress(p, req()), 'the policy survived').toBe(403); p.stop(); }); @@ -847,10 +887,12 @@ describe('a host callback cannot rewrite what the platform is told', () => { // shallow copy would still share them. This mutates a match value through the callback and then sends // a second request through the SAME guard: if the clone were shallow, the guard would now be // screening for something the callback chose. + const seen: any[] = []; const p: any = await createProtection({ - rules: rules(), + rules: rules({ path: '/api', method: 'GET' }), mode: 'block', onDetect: (d: any) => { + seen.push(d.rule); if (d.rule?.rule_v2?.[0]?.match) d.rule.rule_v2[0].match.value = 'never-matches-anything'; if (d.rule?.when) d.rule.when.method = 'OPTIONS'; if (d.rule) d.rule.id = 'rewritten'; @@ -869,6 +911,9 @@ describe('a host callback cannot rewrite what the platform is told', () => { expect(await throughExpress(p, req()), 'the rule matches on the first request').toBe(403); // Same guard, same request: the policy is unchanged, so it still matches. expect(await throughExpress(p, req()), 'the policy survived the callback').toBe(403); + // Neither nested policy object was ever within reach. + expect(seen[0].rule_v2, 'no match policy was handed out').toBeUndefined(); + expect(seen[0].when, 'no scope was handed out').toBeUndefined(); p.stop(); }); }); From 66c2665736cc87517ce7dd1c85f17aec696996e5 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 10:30:16 +0200 Subject: [PATCH 14/33] Take request evidence only from the request, and record the address change for Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field that only `Object.prototype` supplies was carried by nothing, so it is not evidence. Gating `_rawBody` alone did not achieve that: the raw view falls back to serialising the PARSED body, so a polluted `body` still became verbatim bytes by that route and fired `raw` and `post.` rules alike. Every field both boundaries read — body, query, headers, url, originalUrl — now comes through one gate, at the Express projection and at the engine's normalising funnel. Both are load-bearing: the projection turns whatever it reads into an own property, so a value laundered there is indistinguishable from real data afterwards. The gate is the SUPPLIER of a field, not ownership of it. Frameworks expose real request data through getters on their own prototypes: `headers` is a getter on `IncomingMessage.prototype`, and Express defines `query` on its request prototype. Requiring an own property would discard those and silently stop screening headers and query strings — a worse failure than the pollution it prevents. So the chain is walked to whichever object defines the key, and only `Object.prototype` is refused. Fields the adapters create themselves keep the stricter own-property rule, because every writer of those is ours. Each field is covered separately at the funnel, since the Express projection strips these before the funnel sees them and a shared assertion would leave the non-Express paths unproven. The upgrade note now covers the Node guard as well as Express. Both previously took the address from a forwarded header — `X-Forwarded-For`, `CF-Connecting-IP` or `X-Real-IP` on the Node path, `req.ip` on the Express path — so both now show the proxy's address until `trustedProxy` is declared, which moves attribution and any rule reading `server.ip`. On a runtime with no transport peer no policy shape yields an address, and earlier versions reported a client-supplied one there. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 20 ++-- src/protect/engine/normalizer.js | 45 ++++++- src/protect/runtime.js | 22 ++-- tests/protect/client-ip-wiring.test.ts | 160 +++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 23 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index a9f4d42a..6d515615 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -195,14 +195,18 @@ The guard resolves the client address itself, once per request, and shares that matching, block logging and detection reports — so those cannot disagree about who a request came from. Two consequences if you are upgrading: -- **Express: `req.ip` is no longer consulted.** It reflects Express's own `trust proxy` setting, which the - guard cannot verify, so the guard reads the transport peer instead. **If your app runs behind a proxy or - load balancer, addresses will now show as the proxy's** until you declare your proxies with - `trustedProxy` (below). Rules matching on `server.ip` or `REMOTE_ADDR` see the same value. -- **Runtimes with no transport peer report no address.** A WHATWG `Request` exposes no peer, so a Fetch - guard (Workers, Deno, Bun, edge) has nothing to observe and no forwarded header is accepted in its - place: `client_ip_source` is `unavailable` and no address is sent. The Node and Express guards read the - socket peer and are unaffected. +- **Express and Node: forwarded headers are no longer read implicitly.** Earlier versions took the + address from `X-Forwarded-For`, `CF-Connecting-IP` or `X-Real-IP` (the Node guard), or from `req.ip` + (the Express guard, where it reflects Express's own `trust proxy` setting). Neither source can be + verified by the guard, and any client can send those headers, so both guards now read the transport + peer. **If your app runs behind a proxy or load balancer, addresses will now show as the proxy's** + until you declare your proxies with `trustedProxy` (below) — which affects attribution in reports and + any rule matching on `server.ip` or `REMOTE_ADDR`. +- **Fetch runtimes report no address at all.** A WHATWG `Request` exposes no transport peer, so a Fetch + guard (Workers, Deno, Bun, edge) has nothing to observe, and no forwarded header is accepted in its + place under any `trustedProxy` policy: `client_ip_source` is `unavailable` and no address is sent. + Earlier versions reported the forwarded header here, so an address-scoped rule that appeared to work on + such a runtime was matching a client-supplied value. `trustedProxy` is the only way to make a forwarded header count. It takes the proxies you actually run — `{ peers: ['10.0.0.0/8'] }`, or `{ hops: 1 }` to trust that many hops in from the peer, plus optional diff --git a/src/protect/engine/normalizer.js b/src/protect/engine/normalizer.js index a66abb04..c7607834 100644 --- a/src/protect/engine/normalizer.js +++ b/src/protect/engine/normalizer.js @@ -201,6 +201,35 @@ function serializeForRawDetection(body, visited = new Set(), isRoot = true) { return '{' + parts.join(',') + '}'; } +/** + * A request field, unless `Object.prototype` is the only thing supplying it. + * + * Evidence is what a request actually carried. A prototype-pollution primitive writes to + * `Object.prototype`, so a field found there was carried by nothing — and materialising it would let one + * pollution stand as request data and fire every rule that matches it. + * + * The gate is the SUPPLIER, not ownership. Frameworks expose real request data through getters on their + * own prototypes: `headers` is a getter on `IncomingMessage.prototype`, and Express defines `query` on its + * request prototype. Requiring an own property would discard those and silently stop screening headers and + * query strings — a worse failure than the pollution it prevents. So the chain is walked to whichever + * object defines the key, and only `Object.prototype` is refused. + * + * Fields our own adapters create (`_rawBody`) are held to the stricter own-property rule instead: we + * control every writer, so there is no getter to accommodate. + */ +export function requestField(req, key) { + if (req === null || typeof req !== 'object') return undefined; + let holder = req; + while (holder !== null && holder !== undefined) { + if (Object.hasOwn(holder, key)) { + // Read through `req` so a legitimate accessor still runs with the right receiver. + return holder === Object.prototype ? undefined : req[key]; + } + holder = Object.getPrototypeOf(holder); + } + return undefined; +} + export function normalizeRequest(req, options = {}) { // Prefer a caller-provided verbatim body string (set by the fetch/node adapters): // it preserves literal keys like `__proto__` that JSON.stringify drops, which is @@ -209,16 +238,20 @@ export function normalizeRequest(req, options = {}) { // Own property only: a `_rawBody` reachable through a polluted prototype is not something this // request carried, and accepting it here would let it stand as verbatim evidence on every path. const ownRaw = Object.hasOwn(req ?? {}, '_rawBody') && typeof req._rawBody === 'string'; + // Every field below comes through the same gate, because the reconstruction fallback reads the parsed + // body: gating `_rawBody` alone would leave a polluted `body` serialised into raw evidence anyway. + const body = requestField(req, 'body'); + const url = requestField(req, 'url'); const rawBody = ownRaw ? req._rawBody - : serializeForRawDetection(req.body ?? null); + : serializeForRawDetection(body ?? null); return { - query: normalizeObject(req.query || {}, options), - body: normalizeObject(req.body || {}, options), - headers: normalizeObject(req.headers || {}, options), - url: normalize(req.url || '', options), - originalUrl: normalize(req.originalUrl || req.url || '', options), + query: normalizeObject(requestField(req, 'query') || {}, options), + body: normalizeObject(body || {}, options), + headers: normalizeObject(requestField(req, 'headers') || {}, options), + url: normalize(url || '', options), + originalUrl: normalize(requestField(req, 'originalUrl') || url || '', options), _rawBody: rawBody }; } diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 563a12d9..3b500f37 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -18,6 +18,7 @@ import { resolveClientIp } from './client-ip.js'; import { RuleEngine } from './engine/index.js'; import { matchValue, walkLeaves, safeRegExp, jwtClaimSpans } from './engine/engine.js'; +import { requestField } from './engine/normalizer.js'; import { PulseRuleClient } from './engine/pulse-client.js'; import { fromFetchRequest } from './engine/fetch.js'; import { fromNodeRequest } from './engine/node.js'; @@ -497,15 +498,18 @@ export async function createProtection(options = {}) { shaped: { // Own copies of everything a rule can address. Listed rather than spread, so a field the engine // gains has to be added here deliberately instead of appearing to work by accident. - method: req?.method, - url: req?.url, - originalUrl: req?.originalUrl, - headers: req?.headers, - query: req?.query, - body: req?.body, - files: req?.files, - cookies: req?.cookies, - socket: req?.socket, + // Through the same gate as the engine's own normalisation: this projection turns whatever it reads + // into an OWN property, so reading a polluted prototype here would launder it into evidence that + // no later own-property check could tell from the real thing. + method: requestField(req, 'method'), + url: requestField(req, 'url'), + originalUrl: requestField(req, 'originalUrl'), + headers: requestField(req, 'headers'), + query: requestField(req, 'query'), + body: requestField(req, 'body'), + files: requestField(req, 'files'), + cookies: requestField(req, 'cookies'), + socket: requestField(req, 'socket'), // The verbatim body, when a caller kept one. A `raw` rule reads it directly, and the engine // otherwise reconstructs raw by re-serialising the parsed body — which cannot carry what parsing // did not keep: a body that failed to parse at all, a duplicate key where only the last value diff --git a/tests/protect/client-ip-wiring.test.ts b/tests/protect/client-ip-wiring.test.ts index 6674a1ac..c08b1220 100644 --- a/tests/protect/client-ip-wiring.test.ts +++ b/tests/protect/client-ip-wiring.test.ts @@ -764,6 +764,166 @@ describe('the Express view carries evidence a reconstructed body cannot', () => }); }); +describe('request evidence comes from the request, not from a polluted prototype', () => { + const bodyRules = { + firewall: [ + { + id: 'raw-marker', + title: 'reads the verbatim body', + rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: 'inherited-evidence' } }], + }, + { + id: 'post-marker', + title: 'reads a named field', + rule_v2: [{ parameter: 'post.marker', match: { type: 'contains', value: 'inherited-evidence' } }], + }, + { + id: 'get-marker', + title: 'reads a query parameter', + rule_v2: [{ parameter: 'get.marker', match: { type: 'contains', value: 'inherited-evidence' } }], + }, + ], + whitelists: [], + whitelist_keys: {}, + }; + + it('fires on a body the request actually carried', async () => { + // The positive control for the two tests below: the same rules, the same payload, carried for real. + const p: any = await createProtection({ rules: bodyRules, mode: 'block' }); + + expect( + await throughExpress(p, expressReq({ body: { marker: 'inherited-evidence' } })), + 'an own body is evidence', + ).toBe(403); + expect( + await throughExpress(p, expressReq({ query: { marker: 'inherited-evidence' } })), + 'an own query is evidence', + ).toBe(403); + p.stop(); + }); + + it('refuses a body only the prototype chain supplies', async () => { + // Gating `_rawBody` alone is not enough: the raw view falls back to serialising the PARSED body, so a + // polluted `body` becomes verbatim evidence by that route and fires `raw` and `post.` rules alike. + const p: any = await createProtection({ rules: bodyRules, mode: 'block' }); + + (Object.prototype as any).body = { marker: 'inherited-evidence' }; + try { + const req = expressReq(); + delete (req as any).body; + + expect(Object.hasOwn(req, 'body'), 'the request carries no body of its own').toBe(false); + expect((req as any).body, 'but the chain offers one').toEqual({ marker: 'inherited-evidence' }); + expect(await throughExpress(p, req), 'neither a raw nor a post rule sees it').toBeNull(); + + // At the engine's own funnel too, where the serialisation happens. + const normalized = normalizeRequest({ query: {}, headers: {}, url: '/' } as never); + expect(normalized._rawBody, 'nothing was serialised into raw evidence').not.toContain( + 'inherited-evidence', + ); + expect(JSON.stringify(normalized.body), 'and nothing became POST data').not.toContain( + 'inherited-evidence', + ); + } finally { + delete (Object.prototype as any).body; + } + p.stop(); + }); + + it('refuses a query string only the prototype chain supplies', async () => { + const p: any = await createProtection({ rules: bodyRules, mode: 'block' }); + + (Object.prototype as any).query = { marker: 'inherited-evidence' }; + try { + const req = expressReq(); + delete (req as any).query; + + expect((req as any).query, 'the chain offers a query').toEqual({ marker: 'inherited-evidence' }); + expect(await throughExpress(p, req), 'a get rule does not see it').toBeNull(); + } finally { + delete (Object.prototype as any).query; + } + p.stop(); + }); + + it('ignores every polluted field at the engine funnel, not just the body', async () => { + // Each field needs its own check here. The Express projection strips these before the funnel sees + // them, so a gate removed at the funnel would still look covered by the tests above while every + // non-Express path went through ungated. + const MARKER = 'inherited-evidence'; + for (const field of ['body', 'query', 'headers', 'url', 'originalUrl', '_rawBody'] as const) { + const polluted = field === 'url' || field === 'originalUrl' || field === '_rawBody' + ? `/x?p=${MARKER}` + : { marker: MARKER }; + (Object.prototype as any)[field] = polluted; + try { + const normalized = normalizeRequest({} as never); + + expect(JSON.stringify(normalized), `${field} did not become evidence`).not.toContain(MARKER); + } finally { + delete (Object.prototype as any)[field]; + } + } + + // The positive control: the same fields, carried by the request, all arrive. + const normalized = normalizeRequest({ + body: { marker: MARKER }, + query: { marker: MARKER }, + headers: { 'x-marker': MARKER }, + url: `/x?p=${MARKER}`, + } as never); + + for (const key of ['body', 'query', 'headers', 'url'] as const) { + expect(JSON.stringify(normalized[key]), `an own ${key} is evidence`).toContain(MARKER); + } + }); + + it('still screens fields a framework supplies through its own prototype', async () => { + // The gate is the SUPPLIER, not ownership, and this is why. `headers` is a getter on + // `IncomingMessage.prototype`, and Express defines `query` the same way, so requiring an own property + // would leave real requests unscreened on exactly the sources rules read most. + const p: any = await createProtection({ + rules: { + firewall: [ + { + id: 'ua-rule', + title: 'reads a request header', + rule_v2: [ + { parameter: 'server.HTTP_USER_AGENT', match: { type: 'contains', value: 'scanner-payload' } }, + ], + }, + ], + whitelists: [], + whitelist_keys: {}, + }, + mode: 'block', + }); + + // A request shaped the way Node and Express really shape one. + const framework = { + get headers() { + return { 'user-agent': 'scanner-payload', 'content-type': 'application/json' }; + }, + }; + const req: any = Object.create(framework); + Object.assign(req, { + method: 'POST', + url: '/upload', + originalUrl: '/upload', + query: {}, + body: {}, + cookies: {}, + files: {}, + socket: { remoteAddress: '203.0.113.5' }, + readableEnded: true, + }); + + expect(Object.hasOwn(req, 'headers'), 'the headers are inherited, as they really are').toBe(false); + expect(await throughExpress(p, req), 'a header rule still screens them').toBe(403); + p.stop(); + }); +}); + describe('a host callback cannot rewrite what the platform is told', () => { /** * One blocked request through a guard whose `onDetect` rewrites everything it can reach, returning what From 27d54211179fc6ab894b7d581bbbbe55303f50c4 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 10:38:16 +0200 Subject: [PATCH 15/33] Take an inherited request field only from a framework accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Object.prototype` is not the only writable prototype, so refusing that one alone accepted anything installed closer to the request: a value parked on an intermediate prototype arrived as body, query and raw-body evidence the request never carried. An own property is evidence. Everything inherited is refused, with one narrow exception — a getter on a prototype, for `headers` and `query` alone. Those are the only fields a supported framework supplies that way: `headers` is a getter on `IncomingMessage.prototype` and Express defines `query` on its request prototype, while body parsers, cookie parsers and upload middleware all assign own properties and the Node and Fetch adapters build their shape as a literal. Requiring an own property everywhere would stop screening headers and query strings on real requests, which is a worse failure than the pollution it prevents. The exception is narrow in both directions. An inherited DATA property is refused however it arrives, because that is not how a framework delivers request data and is exactly how a write to a prototype presents. An accessor is refused for any other field, since no supported framework supplies one. And `Object.prototype` is refused even for an accessor: pollution can define a getter there, which would otherwise come through the one door left open. Each direction has its own test — the two framework getters separately, so a failure on one cannot hide the other, and refusals for an intermediate data property, an intermediate accessor on a field outside the exception, and an accessor on `Object.prototype` for a field inside it. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/engine/normalizer.js | 51 +++++--- tests/protect/client-ip-wiring.test.ts | 161 +++++++++++++++++++++---- 2 files changed, 170 insertions(+), 42 deletions(-) diff --git a/src/protect/engine/normalizer.js b/src/protect/engine/normalizer.js index c7607834..762867c5 100644 --- a/src/protect/engine/normalizer.js +++ b/src/protect/engine/normalizer.js @@ -201,33 +201,44 @@ function serializeForRawDetection(body, visited = new Set(), isRoot = true) { return '{' + parts.join(',') + '}'; } +// The only fields a supported framework supplies through an inherited accessor: `headers` is a getter on +// `IncomingMessage.prototype`, and Express defines `query` on its request prototype. Every other field +// arrives as an own property — body parsers, cookie parsers and upload middleware all assign one, and the +// Node and Fetch adapters build their request shape as a literal. +const INHERITED_ACCESSORS = new Set(['headers', 'query']); + /** - * A request field, unless `Object.prototype` is the only thing supplying it. + * A request field, taken only from the request itself. * - * Evidence is what a request actually carried. A prototype-pollution primitive writes to - * `Object.prototype`, so a field found there was carried by nothing — and materialising it would let one - * pollution stand as request data and fire every rule that matches it. + * Evidence is what a request actually carried. A value reachable only through a prototype was carried by + * nothing, and materialising it would let one write stand as request data and fire every rule that + * matches it — arriving indistinguishable from a real body, query or header. * - * The gate is the SUPPLIER, not ownership. Frameworks expose real request data through getters on their - * own prototypes: `headers` is a getter on `IncomingMessage.prototype`, and Express defines `query` on its - * request prototype. Requiring an own property would discard those and silently stop screening headers and - * query strings — a worse failure than the pollution it prevents. So the chain is walked to whichever - * object defines the key, and only `Object.prototype` is refused. + * An own property is evidence. Everything inherited is refused, with one exception: a getter on a + * prototype, for the two fields above. That exception exists because requiring an own property would + * discard how Node and Express really expose headers and the query string, and silently stop screening + * the sources rules read most — a worse failure than the pollution it prevents. * - * Fields our own adapters create (`_rawBody`) are held to the stricter own-property rule instead: we - * control every writer, so there is no getter to accommodate. + * The exception is deliberately narrow in both directions. An inherited DATA property is refused however + * it arrives, because a framework does not install request data that way and a write to a prototype does; + * and `Object.prototype` is refused even for an accessor, because that is where a pollution primitive + * lands. */ export function requestField(req, key) { if (req === null || typeof req !== 'object') return undefined; - let holder = req; - while (holder !== null && holder !== undefined) { - if (Object.hasOwn(holder, key)) { - // Read through `req` so a legitimate accessor still runs with the right receiver. - return holder === Object.prototype ? undefined : req[key]; - } - holder = Object.getPrototypeOf(holder); - } - return undefined; + if (Object.hasOwn(req, key)) return req[key]; + if (!INHERITED_ACCESSORS.has(key)) return undefined; + + let holder = Object.getPrototypeOf(req); + while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder); + if (holder === null || holder === Object.prototype) return undefined; + + // An accessor, not a value parked on a prototype the request happens to inherit from. + const descriptor = Object.getOwnPropertyDescriptor(holder, key); + if (typeof descriptor?.get !== 'function') return undefined; + + // Read through `req` so the accessor runs with the receiver it expects. + return req[key]; } export function normalizeRequest(req, options = {}) { diff --git a/tests/protect/client-ip-wiring.test.ts b/tests/protect/client-ip-wiring.test.ts index c08b1220..3a2edf62 100644 --- a/tests/protect/client-ip-wiring.test.ts +++ b/tests/protect/client-ip-wiring.test.ts @@ -878,10 +878,26 @@ describe('request evidence comes from the request, not from a polluted prototype } }); - it('still screens fields a framework supplies through its own prototype', async () => { - // The gate is the SUPPLIER, not ownership, and this is why. `headers` is a getter on - // `IncomingMessage.prototype`, and Express defines `query` the same way, so requiring an own property - // would leave real requests unscreened on exactly the sources rules read most. + /** A request whose named fields come from a prototype, the way `req` really reaches an Express guard. */ + const inheriting = (proto: object, own: Record = {}) => { + const req: any = Object.create(proto); + Object.assign(req, { + method: 'POST', + url: '/upload', + originalUrl: '/upload', + cookies: {}, + files: {}, + socket: { remoteAddress: '203.0.113.5' }, + readableEnded: true, + ...own, + }); + + return req; + }; + + it('still screens headers a framework supplies through an inherited getter', async () => { + // `headers` is a getter on `IncomingMessage.prototype`, so requiring an own property here would leave + // real requests unscreened on the source rules read most. const p: any = await createProtection({ rules: { firewall: [ @@ -889,7 +905,10 @@ describe('request evidence comes from the request, not from a polluted prototype id: 'ua-rule', title: 'reads a request header', rule_v2: [ - { parameter: 'server.HTTP_USER_AGENT', match: { type: 'contains', value: 'scanner-payload' } }, + { + parameter: 'server.HTTP_USER_AGENT', + match: { type: 'contains', value: 'scanner-payload' }, + }, ], }, ], @@ -899,29 +918,127 @@ describe('request evidence comes from the request, not from a polluted prototype mode: 'block', }); - // A request shaped the way Node and Express really shape one. - const framework = { - get headers() { - return { 'user-agent': 'scanner-payload', 'content-type': 'application/json' }; + const req = inheriting( + { + get headers() { + return { 'user-agent': 'scanner-payload', 'content-type': 'application/json' }; + }, }, - }; - const req: any = Object.create(framework); - Object.assign(req, { - method: 'POST', - url: '/upload', - originalUrl: '/upload', - query: {}, - body: {}, - cookies: {}, - files: {}, - socket: { remoteAddress: '203.0.113.5' }, - readableEnded: true, - }); + { query: {}, body: {} }, + ); expect(Object.hasOwn(req, 'headers'), 'the headers are inherited, as they really are').toBe(false); expect(await throughExpress(p, req), 'a header rule still screens them').toBe(403); p.stop(); }); + + it('still screens a query string Express supplies through an inherited getter', async () => { + // Express defines `query` on its request prototype, so this needs its own test: a failure on the + // header case above would otherwise hide a query string that stopped being screened. + const p: any = await createProtection({ rules: bodyRules, mode: 'block' }); + + const req = inheriting( + { + get query() { + return { marker: 'inherited-evidence' }; + }, + }, + { headers: { 'content-type': 'application/json' }, body: {} }, + ); + + expect(Object.hasOwn(req, 'query'), 'the query is inherited, as Express supplies it').toBe(false); + expect(await throughExpress(p, req), 'a get rule still screens it').toBe(403); + p.stop(); + }); + + it('refuses a value parked on an intermediate prototype, accessor or not', async () => { + // The inverse control. `Object.prototype` is not the only writable prototype, so refusing only that + // one would accept anything installed closer to the request — which is a value the request did not + // carry, arriving as evidence. A framework supplies request data as an own property or a getter; a + // data property on a prototype is neither. + const p: any = await createProtection({ rules: bodyRules, mode: 'block' }); + + const req = inheriting({ + body: { marker: 'inherited-evidence' }, + query: { marker: 'inherited-evidence' }, + headers: { 'content-type': 'application/json', 'x-marker': 'inherited-evidence' }, + }); + + expect((req as any).body, 'the chain does offer a body').toEqual({ marker: 'inherited-evidence' }); + expect(await throughExpress(p, req), 'no rule sees any of it').toBeNull(); + + // And at the funnel, for each field: an inherited data property is refused even where an inherited + // getter would be honoured. + const normalized = normalizeRequest( + Object.create({ + body: { marker: 'inherited-evidence' }, + query: { marker: 'inherited-evidence' }, + headers: { 'x-marker': 'inherited-evidence' }, + url: '/x?p=inherited-evidence', + }) as never, + ); + + expect(JSON.stringify(normalized), 'nothing inherited became evidence').not.toContain( + 'inherited-evidence', + ); + p.stop(); + }); + + it('refuses an accessor installed on Object.prototype, even for an allowed field', async () => { + // The inherited-accessor exception must not extend to `Object.prototype`. Pollution can define a + // GETTER there, not only a value, and that would otherwise arrive through the one door left open — + // for `headers` and `query`, the two fields the exception exists for. + const p: any = await createProtection({ rules: bodyRules, mode: 'block' }); + + for (const field of ['query', 'headers'] as const) { + Object.defineProperty(Object.prototype, field, { + get() { + return field === 'headers' + ? { 'content-type': 'application/json', 'x-marker': 'inherited-evidence' } + : { marker: 'inherited-evidence' }; + }, + configurable: true, + }); + try { + const req: any = inheriting({}, { body: {} }); + delete req[field]; + + expect(Object.hasOwn(req, field), `the request has no own ${field}`).toBe(false); + expect((req as any)[field], 'the polluted accessor does supply one').toBeTruthy(); + expect(await throughExpress(p, req), `a rule reading ${field} does not see it`).toBeNull(); + + const normalized = normalizeRequest({} as never); + + expect(JSON.stringify(normalized), `${field} did not become evidence`).not.toContain( + 'inherited-evidence', + ); + } finally { + delete (Object.prototype as any)[field]; + } + } + p.stop(); + }); + + it('refuses an inherited getter for a field no framework supplies that way', async () => { + // The exception is per field, not general: a getter installed for `body` is not how any supported + // framework delivers a parsed body, so it is refused like any other inherited value. + const p: any = await createProtection({ rules: bodyRules, mode: 'block' }); + + const req = inheriting( + { + get body() { + return { marker: 'inherited-evidence' }; + }, + }, + { headers: { 'content-type': 'application/json' }, query: {} }, + ); + + expect((req as any).body, 'the getter does supply one').toEqual({ + marker: 'inherited-evidence', + }); + expect(await throughExpress(p, req), 'a raw or post rule does not see it').toBeNull(); + p.stop(); + }); }); describe('a host callback cannot rewrite what the platform is told', () => { From 063e800a1b9fbb595301796058c9fafb8ba17261 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 10:53:09 +0200 Subject: [PATCH 16/33] Retry a security event that is worth retrying, once, under one key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A report is evidence, so losing a batch to a restart or a rate limit is worth another attempt. Delivery now retries up to four attempts per batch with exponential backoff and jitter, honouring `Retry-After` when the endpoint sets one. Three bounds make that safe rather than merely hopeful: Only a transient failure is retried. Unreachable, rate-limited or a server error gets another attempt; a batch refused on its merits does not, because retrying it spends the app's time to be told the same thing. Every attempt of one batch carries the same `Idempotency-Key`. An acknowledgement can be lost after the server has committed a batch, so without a stable key a redelivery would be counted twice and inflate exactly the numbers these reports are read for. A different batch gets a different key, or the server would discard it. One send is in flight at a time, and attempts are bounded. Memory stays at the queue plus one batch, a slow endpoint applies back pressure to the queue rather than to open sockets, and a batch that exhausts its attempts is dropped and counted where an unbounded retry would be an app quietly spending itself on an endpoint that will not take it. Nothing is scheduled after `stop()`, and retry timers do not hold a process open. A send now happens only when one is due — the interval elapsed, the batch bound reached, or a state waiting. Sending whenever the previous request finished would have applied the flush interval only to the first batch of a guard's life. Reporting states supersede rather than accumulate. They travel on the same transport, so they inherit the retry and the key, and declarations made in the same turn coalesce into one: what the platform needs is the state now, not how it got there. A state rides with queued events in a single request where there are any. Every field of an event is bounded, and an event that had to be shortened says which fields were shortened and how many parameters the rule really reads. The per-batch byte bound is set where a full batch of worst-case events can actually reach it — a bound above anything the field caps allow is a comment, not a bound — and what does not fit stays queued in order rather than being dropped. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 15 + src/protect/detections.js | 454 +++++++++++++++--- src/protect/protect.d.ts | 3 + tests/protect/detection-delivery.test.ts | 405 ++++++++++++++++ .../detection-payload-contract.test.ts | 53 +- tests/protect/detections.test.ts | 4 +- tests/protect/reporting-runtime.test.ts | 3 +- 7 files changed, 853 insertions(+), 84 deletions(-) create mode 100644 tests/protect/detection-delivery.test.ts diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 6d515615..df76bef3 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -181,6 +181,21 @@ bundle carried one, the client address and where that address came from, and a t carries a count of reports dropped when traffic outran the flush, so a partial sample is not read as a complete one. +Every field is bounded in size, and an event that had to be shortened says so. +`truncated` lists **which fields were shortened**. +`parameters_total` records **how many parameters the rule reads**, when a rule reads more than the event +names. Both appear only when something really was shortened, so their absence is not a claim of its own — +and a shortened route or rule id is marked rather than passed off as complete, because a reader must not +use one as a key believing it names the whole thing. + +Delivery is retried, up to four attempts per batch, with exponential backoff and jitter, honouring a +`Retry-After` header when the endpoint sets one. Only failures worth retrying are retried — unreachable, +rate-limited, or a server error; a batch that was refused on its merits is not sent again. Every attempt +of one batch carries the same `Idempotency-Key` header, so a batch that was committed before its +acknowledgement was lost is not counted twice. One request is in flight at a time, so a slow endpoint +slows the queue rather than opening more sockets, and a batch that exhausts its attempts is dropped and +counted rather than retried forever. + The client address is reported with its **provenance**, because an address is only as trustworthy as whatever supplied it. `client_ip_source` is one of `runtime` (the address the transport observed), `trusted-proxy` (read from a forwarded header, through peers you declared via `trustedProxy`), or diff --git a/src/protect/detections.js b/src/protect/detections.js index 62dc0e44..fbbe8e82 100644 --- a/src/protect/detections.js +++ b/src/protect/detections.js @@ -34,6 +34,126 @@ const MAX_BATCH = 50; /** Bounded so a detection storm costs memory it cannot grow out of. Oldest go first. */ const MAX_QUEUE = 500; +/** + * Delivery bounds. + * + * A retry exists because the common failure is transient — a restart, a rate limit, a dropped + * connection — and losing evidence to a five-second outage is avoidable. It is bounded because the + * failure that is NOT transient must not turn into a loop: after `MAX_ATTEMPTS` the batch is dropped and + * counted, which is visible, where an unbounded retry would be an app quietly spending itself on a + * refusing endpoint. + * + * Only one send is ever in flight. That keeps memory bounded to the queue plus one batch, keeps the + * retry sequence unambiguous, and means a slow endpoint applies back pressure to the queue rather than + * to the number of open sockets. + */ +const MAX_ATTEMPTS = 4; +const RETRY_BASE_MS = 1000; +const RETRY_CAP_MS = 30_000; +/** A refusal that will refuse again is terminal; these are the ones worth trying later. */ +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); + +/** + * Size bounds, applied per event and per batch. + * + * Every field is an identifier rather than traffic, but an identifier can still be long: a route is + * whatever the application routes, and a broad rule can read many parameters. A capped field carries + * a `truncated` list naming what was shortened, so a reader can tell a shortened value from a complete + * one instead of drawing conclusions from a route that looks like a different route. + */ +const MAX_ROUTE_CHARS = 256; +const MAX_PARAMETERS = 25; +const MAX_PARAMETER_CHARS = 64; +/** + * Identifiers are capped too, and marked when capped. + * + * These come from the rule bundle rather than from traffic, so a long one is our own bug rather than an + * attack — but an event has to be bounded by every field it carries, not by most of them. Marked rather + * than silently shortened, because a shortened identifier no longer matches the rule it names and a + * reader must not use it as a key believing it does. + */ +const MAX_IDENTIFIER_CHARS = 256; +/** + * The body bound, set where a full batch can actually reach it. + * + * A bound above anything the other caps allow is not a bound, it is a comment: the count and the field + * caps together put a full batch of worst-case events over this, so the split is a path traffic reaches + * rather than a branch nothing can enter. It is also a modest request body, which is the point — an + * endpoint or proxy that refuses an oversized body would refuse every retry of it too. + */ +const MAX_BODY_BYTES = 64 * 1024; + +/** A per-reporter identity, so idempotency keys from two guards cannot collide. */ +function makeInstanceId() { + try { + const uuid = globalThis.crypto?.randomUUID?.(); + if (typeof uuid === 'string' && uuid !== '') return uuid; + } catch { + // A runtime without usable web crypto falls through to the counter below. + } + + return `${Date.now().toString(36)}-${Math.floor(Math.random() * 0xffffff).toString(36)}`; +} + +/** A field shortened to fit, and whether it had to be. */ +function capText(value, limit) { + const text = typeof value === 'string' ? value : ''; + + return text.length > limit ? { value: text.slice(0, limit), truncated: true } : { value: text, truncated: false }; +} + +/** + * How long to wait before attempting again. + * + * `Retry-After` is honoured when the endpoint sets one, because it is the endpoint saying what it can + * take — but capped, so a header cannot park a batch indefinitely. Otherwise exponential from + * `RETRY_BASE_MS` with jitter, so many guards retrying after one shared outage do not return in step. + */ +export function retryDelayMs(attempts, retryAfter, random = Math.random) { + const advertised = parseRetryAfter(retryAfter); + if (advertised !== null) return Math.min(advertised, RETRY_CAP_MS); + + const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempts - 1), RETRY_CAP_MS); + + // ±25%, then capped again: jitter applied to a capped value can exceed the cap, so the cap goes last. + return Math.min(RETRY_CAP_MS, Math.round(backoff * (0.75 + random() * 0.5))); +} + +function parseRetryAfter(value) { + if (typeof value !== 'string' || value.trim() === '') return null; + const seconds = Number(value.trim()); + if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : null; + const at = Date.parse(value); + if (Number.isNaN(at)) return null; + + return Math.max(0, at - Date.now()); +} + +/** + * As many events as fit the byte bound, and the rest. + * + * A backstop, not the primary bound: with every field capped, a full batch cannot reach `MAX_BODY_BYTES` + * today. It stays because the field caps and the batch count are separate numbers that can each change, + * and an endpoint refusing an oversized body would refuse every retry of it too. At least one event + * always goes, since a batch of none makes no progress. + */ +export function splitToFit(events, maxBytes) { + const batch = events.slice(); + const rest = []; + while (batch.length > 1 && JSON.stringify(batch).length > maxBytes) { + rest.unshift(batch.pop()); + } + + return [batch, rest]; +} + +/** A timer that cannot hold a process open: a pending retry must never be why a command does not exit. */ +function unattended(timer) { + if (timer && typeof timer.unref === 'function') timer.unref(); + + return timer; +} + /** * The parameters a rule reads, from its own definition. * @@ -115,7 +235,7 @@ export function createDetectionReporter(opts) { return { record() {}, flush() {}, stop() {}, setRulesEtag() {}, announce() {}, dropped: () => 0, health: () => ({ - sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null, + sent: 0, delivered: 0, failed: 0, dropped: 0, retried: 0, lastDeliveredAt: null, capability: { announced: 0, acknowledged: 0, failed: 0, lastAcknowledgedAt: null }, }), }; @@ -161,55 +281,210 @@ export function createDetectionReporter(opts) { let queue = []; /** @type {ReturnType | null} */ let timer = null; + /** @type {ReturnType | null} */ + let retryTimer = null; let stopped = false; let dropped = 0; + let retried = 0; + let flushRequested = false; - const flush = () => { - if (timer) { - clearTimeout(timer); - timer = null; + // One send at a time, and one batch's worth of state while it runs. + let sending = false; + /** + * @type {{ + * key: string, events: Array>, dropped: number, + * state: string | null, attempts: number, + * } | null} + */ + let inFlight = null; + + /** + * The newest reporting state not yet declared, and only the newest. + * + * States supersede rather than accumulate: what the platform needs is the state this guard is in now, + * so a queue of them would deliver a history nobody asked for and end by declaring the same thing + * anyway. A state arriving while a send runs replaces whatever was waiting, and travels with the next + * send — attached to a batch of events when there is one, alone when there is not. + * + * @type {string | null} + */ + let pendingState = null; + const instanceId = makeInstanceId(); + let sequence = 0; + + /** Events up to the batch and byte bounds, leaving the rest queued. */ + const takeBatch = () => { + const [batch, rest] = splitToFit(queue.splice(0, MAX_BATCH), MAX_BODY_BYTES); + if (rest.length > 0) queue.unshift(...rest); + + return batch; + }; + + const post = async (body, key) => + fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': '@patchstack/connect', + // The same key on every attempt of one batch. A retry exists because an acknowledgement can be + // lost after the server committed the batch, so without this a redelivery would be counted twice + // and inflate exactly the numbers the reports are read for. + 'Idempotency-Key': key, + // Same credential path as the rules fetch. The detections endpoint is site-addressed and requires + // a verified, site-bound token, so a batch sent without one is refused — which is why the runtime + // does not build a reporter when no credential resolves, rather than posting into a 401. + ...(await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)), + }, + body: JSON.stringify(body), + }); + + /** Give up on the batch in flight, counting what it carried. */ + const abandon = () => { + if (!inFlight) return; + failed += inFlight.events.length; + if (inFlight.state !== null) capabilityFailed += 1; + inFlight = null; + }; + + const settle = () => { + if (!inFlight) return; + if (inFlight.events.length > 0) { + delivered += inFlight.events.length; + lastDeliveredAt = new Date().toISOString(); + } + if (inFlight.state !== null) { + capabilityAcknowledged += 1; + lastCapabilityAckAt = new Date().toISOString(); + } + inFlight = null; + }; + + /** + * One attempt at the batch in flight, then either done, retried, or given up on. + * + * Fail-open throughout: no path here rejects, throws into the caller, or blocks a request. A delivery + * problem is counted and nothing more. + */ + const attempt = async () => { + if (!inFlight) return; + sending = true; + inFlight.attempts += 1; + if (inFlight.attempts > 1) retried += 1; + + const body = { + detections: inFlight.events, + // The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer + // computing a false-positive rate needs to know its denominator is short, and silence about that + // would make a truncated sample look like a complete one. + dropped: inFlight.dropped, + ...(inFlight.state !== null ? { reporting_state: inFlight.state } : {}), + }; + + let status = null; + let retryAfter = null; + try { + const res = await post(body, inFlight.key); + if (res && res.ok) { + settle(); + finish(); + + return; + } + status = typeof res?.status === 'number' ? res.status : 0; + retryAfter = res?.headers?.get?.('retry-after') ?? null; + } catch { + // Unreachable rather than refused: worth another attempt, since nothing says the endpoint is + // unwilling. + status = null; + } + + const worthRetrying = status === null || RETRYABLE_STATUS.has(status); + // Not after `stop()`: the guard is going away, and a timer that outlives it would keep a process + // alive to deliver a report nobody is waiting for. + if (worthRetrying && inFlight.attempts < MAX_ATTEMPTS && !stopped) { + const delay = retryDelayMs(inFlight.attempts, retryAfter); + sending = false; + retryTimer = unattended( + setTimeout(() => { + retryTimer = null; + void attempt(); + }, delay), + ); + + return; + } + + abandon(); + finish(); + }; + + /** Whatever accumulated while that send ran. */ + const finish = () => { + sending = false; + kick(); + }; + + const arm = () => { + if (!timer) { + timer = unattended( + setTimeout(() => { + timer = null; + flushRequested = true; + kick(); + }, flushMs), + ); + } + }; + + /** + * Whether there is reason to send NOW, as opposed to reason to send eventually. + * + * Without this the buffer would empty every time a send completed, because whatever accumulated during + * one request would immediately become the next — and the flush interval, which exists so a busy app + * makes one request instead of fifty, would apply only to the first batch of a guard's life. + */ + const due = () => pendingState !== null || queue.length >= MAX_BATCH || flushRequested; + + /** Start a send if one is due and nothing is already in flight; otherwise wait for the interval. */ + const kick = () => { + if (sending || inFlight || typeof fetchImpl !== 'function') return; + if (queue.length === 0 && pendingState === null) { + flushRequested = false; + + return; } - if (queue.length === 0 || typeof fetchImpl !== 'function') return; + if (!due()) { + arm(); - const batch = queue.splice(0, MAX_BATCH); - // The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer - // computing a false-positive rate needs to know its denominator is short, and silence about that - // would make a truncated sample look like a complete one. + return; + } + flushRequested = false; + + const events = takeBatch(); const droppedWith = dropped; dropped = 0; droppedTotal += droppedWith; - sent += batch.length; - - void (async () => { - try { - const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': '@patchstack/connect', - // Same credential path as the rules fetch. The detections endpoint is site-addressed and - // requires a verified, site-bound token, so a batch sent without one is refused — which is - // why the runtime does not build a reporter when no credential resolves, rather than - // posting into a 401. - ...(await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)), - }, - body: JSON.stringify({ detections: batch, dropped: droppedWith }), - }); - // Fail-open and no retry: a rejected or unreachable endpoint must not disturb the app, and a - // retry loop over a refusing endpoint is worse than the lost batch. The outcome is counted, so - // that a delivery path which refuses everything is distinguishable from an app where no rule - // fired — both are silence at the server otherwise. - if (res && res.ok) { - delivered += batch.length; - lastDeliveredAt = new Date().toISOString(); - } else { - failed += batch.length; - } - } catch { - failed += batch.length; - } - })(); + sent += events.length; + + const state = pendingState; + pendingState = null; + // Counted where the declaration is actually attached to a request, so coalesced states count once — + // the number describes declarations made, not calls received. + if (state !== null) capabilityAnnounced += 1; + + sequence += 1; + inFlight = { key: `${instanceId}-${sequence}`, events, dropped: droppedWith, state, attempts: 0 }; + void attempt(); + }; + + const flush = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + flushRequested = true; + kick(); }; return { @@ -231,20 +506,47 @@ export function createDetectionReporter(opts) { dropped++; } + // Capped, with a note of what was capped. Every field is an identifier rather than traffic, but a + // route is whatever the application routes and a broad rule can read many parameters — and a reader + // who cannot tell a shortened route from a complete one will read it as a different route. + const route = capText(routeOf(detection.path), MAX_ROUTE_CHARS); + const allParameters = ruleParameters(detection.rule); + const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS)); + const truncated = []; + if (route.truncated) truncated.push('route'); + if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) { + truncated.push('parameters'); + } + + const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS); + const revision = capText(revisionOf(detection.rule) ?? '', MAX_IDENTIFIER_CHARS); + const etag = capText(rulesEtag ?? '', MAX_IDENTIFIER_CHARS); + for (const [name, field] of [ + ['rule_id', id], + ['rule_revision', revision], + ['rules_etag', etag], + ]) { + if (field.truncated) truncated.push(name); + } + queue.push({ - rule_id: ruleId, - route: routeOf(detection.path), - parameters: ruleParameters(detection.rule), + rule_id: id.value, + route: route.value, + parameters: parameters.map((entry) => entry.value), + // Present only when something was shortened, so its absence is not a claim of its own. + ...(truncated.length > 0 + ? { truncated, parameters_total: allParameters.length } + : {}), phase: detection.phase ?? null, // The state this detection was handled under, which is the whole point: `false` is a rule that // saw traffic it would have stopped. enforced: detection.mode === 'block', - rules_etag: rulesEtag, + rules_etag: rulesEtag === null ? null : etag.value, // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for // one rule describe the document that rule has now. Passed through untouched, and null when the // bundle carried none. - rule_revision: revisionOf(detection.rule), + rule_revision: revisionOf(detection.rule) === null ? null : revision.value, // The client address and where it came from. `client_ip` is omitted entirely when there is none, // so a present-but-empty field cannot read as a failed lookup of a real address; the provenance is // always present, because "this could not be established" is the part a reader needs. @@ -252,12 +554,9 @@ export function createDetectionReporter(opts) { detected_at: new Date().toISOString(), }); - if (queue.length >= MAX_BATCH) { - flush(); - - return; - } - if (!timer) timer = setTimeout(flush, flushMs); + // `kick` decides whether this is due now or waits for the interval, so the batch bound and the + // interval cannot disagree about when a full queue goes out. + kick(); }, flush, /** @@ -268,41 +567,33 @@ export function createDetectionReporter(opts) { * no managed rules, then receives them. Without this, the corrected state would wait for the next * refresh, and a guard with refreshing switched off has none. * - * Carries no events: an empty batch whose only content is the state. Fire-and-forget and fail-open for - * the same reason as a flush — a report is never worth disturbing the app over — and counted as a - * delivery attempt so a path that refuses everything stays visible. + * Goes through the same transport as events, so it inherits the retry, the idempotency key and the + * single-flight bound. Only the newest state is kept: calling this twice before a send declares the + * second, because what the platform needs is the state now, not how it got there. A declaration that + * exhausts its retries is dropped rather than held — the state travels on every rules fetch too, so + * the next one corrects it. * * @param {string} state */ announce(state) { if (stopped || typeof fetchImpl !== 'function' || typeof state !== 'string') return; - capabilityAnnounced += 1; - - void (async () => { - try { - const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': '@patchstack/connect', - ...(await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)), - }, - body: JSON.stringify({ detections: [], dropped: 0, reporting_state: state }), - }); - if (res && res.ok) { - capabilityAcknowledged += 1; - lastCapabilityAckAt = new Date().toISOString(); - } else { - capabilityFailed += 1; - } - } catch { - capabilityFailed += 1; - } - })(); + pendingState = state; + // Sent on a microtask, so declarations made in the same turn coalesce into one: by the time this + // runs, `pendingState` holds the last of them. Sending on the call would commit the first state + // before the second could supersede it, and the platform would be told a state this guard had + // already left. A second microtask finds nothing pending and does nothing. + queueMicrotask(() => { + if (!stopped) kick(); + }); }, stop() { stopped = true; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + // One last send for whatever is queued. No retry follows it: `stopped` closes that path, so a + // failure here is counted and the guard goes away rather than keeping a timer alive behind it. flush(); }, /** @@ -327,6 +618,9 @@ export function createDetectionReporter(opts) { delivered, failed, dropped: droppedTotal + dropped, + // Attempts beyond the first, counted in ATTEMPTS rather than events: a path that only ever + // succeeds on a second try is working, and is worth telling apart from one that never retries. + retried, lastDeliveredAt, // Separate, because a capability announcement delivers no events. Reading zero here alongside a // non-zero `delivered` is a normal state, and so is the reverse. diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index b11df488..e8d53bae 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -78,6 +78,9 @@ export interface Protection { delivered: number; failed: number; dropped: number; + /** Attempts beyond the first. A path that only ever succeeds on a retry is working, and is worth + * telling apart from one that never has to retry. */ + retried: number; lastDeliveredAt: string | null; /** Capability announcements, counted separately: these carry no events, so they never move the * counters above. Zero here alongside delivered events is normal, and so is the reverse. */ diff --git a/tests/protect/detection-delivery.test.ts b/tests/protect/detection-delivery.test.ts new file mode 100644 index 00000000..eb9b50ec --- /dev/null +++ b/tests/protect/detection-delivery.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createDetectionReporter, retryDelayMs, splitToFit } from '../../src/protect/detections.js'; + +/** + * Delivery, not payload. + * + * A report is evidence, so losing a batch to a restart or a rate limit is worth one more attempt — and a + * retry is only safe if a redelivery cannot be counted twice, and only sane if it is bounded. These + * assert those three together: retried when it is worth it, identified so a duplicate is recognisable, + * and given up on before an app spends itself on an endpoint that will not take it. + */ +const RULE = { id: 'r1', rule_v2: [{ parameter: 'post.title', match: { type: 'contains', value: 'x' } }] }; +const reporterFor = (fetchImpl: unknown, over: Record = {}) => + createDetectionReporter({ + siteUuid: 'site-1', + baseUrl: 'https://x.test/monitor/pulse', + fetchImpl: fetchImpl as typeof fetch, + ...over, + }); + +const one = (r: any, path = '/a') => r.record({ rule: RULE, phase: 'request', mode: 'block', path }); +const settle = async () => { await new Promise((r) => setTimeout(r, 0)); }; +/** Let every scheduled retry run, without waiting out the real backoff. */ +const runRetries = async () => { + for (let i = 0; i < MAX_ATTEMPTS + 1; i++) { + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS); + await Promise.resolve(); + } +}; +const MAX_ATTEMPTS = 4; +const RETRY_CAP_MS = 30_000; +const keysOf = (impl: any) => + impl.mock.calls.map((c: any[]) => (c[1]?.headers ?? {})['Idempotency-Key']).filter(Boolean); + +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); + +describe('a batch worth retrying is retried, and only so far', () => { + it('retries a transient refusal and delivers the same batch', async () => { + vi.useFakeTimers(); + let attempts = 0; + const impl = vi.fn(async () => { + attempts++; + + return new Response('{}', { status: attempts < 3 ? 503 : 202 }); + }); + const r = reporterFor(impl); + + one(r); + r.flush(); + await runRetries(); + + expect(attempts, 'it kept trying until the endpoint took it').toBe(3); + // One event, delivered once — the retries are attempts at the same batch, not more events. + expect(r.health()).toMatchObject({ sent: 1, delivered: 1, failed: 0, retried: 2 }); + r.stop(); + }); + + it('sends every attempt of one batch under the same idempotency key', async () => { + vi.useFakeTimers(); + let attempts = 0; + const impl = vi.fn(async () => { + attempts++; + + return new Response('{}', { status: attempts < 3 ? 503 : 202 }); + }); + const r = reporterFor(impl); + + one(r); + r.flush(); + await runRetries(); + + const keys = keysOf(impl); + expect(keys.length, 'every attempt carried a key').toBe(3); + expect(new Set(keys).size, 'and it was the same key each time').toBe(1); + + // A different batch is a different key, or the server would discard it as a duplicate. + one(r, '/b'); + r.flush(); + await runRetries(); + + const all = keysOf(impl); + expect(new Set(all).size, 'the second batch is distinguishable').toBe(2); + r.stop(); + }); + + it('gives up after a bounded number of attempts and counts the loss', async () => { + vi.useFakeTimers(); + const impl = vi.fn(async () => new Response('{}', { status: 503 })); + const r = reporterFor(impl); + + one(r); + r.flush(); + await runRetries(); + + expect(impl.mock.calls.length, 'bounded, not a loop').toBe(MAX_ATTEMPTS); + expect(r.health()).toMatchObject({ sent: 1, delivered: 0, failed: 1 }); + + // And nothing is still scheduled: an exhausted batch leaves no timer behind. + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS * 4); + expect(impl.mock.calls.length, 'no attempt after the bound').toBe(MAX_ATTEMPTS); + r.stop(); + }); + + it('does not retry a refusal that will refuse again', async () => { + vi.useFakeTimers(); + const impl = vi.fn(async () => new Response('{}', { status: 400 })); + const r = reporterFor(impl); + + one(r); + r.flush(); + await runRetries(); + + // A rejected batch is rejected on its merits. Retrying it spends the app's time to be told the same + // thing, where a transient failure has some chance of a different answer. + expect(impl.mock.calls.length, 'one attempt only').toBe(1); + expect(r.health()).toMatchObject({ sent: 1, delivered: 0, failed: 1, retried: 0 }); + r.stop(); + }); + + it('retries when the endpoint could not be reached at all', async () => { + vi.useFakeTimers(); + let attempts = 0; + const impl = vi.fn(async () => { + attempts++; + if (attempts < 2) throw new Error('connection reset'); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl); + + one(r); + r.flush(); + await runRetries(); + + // Unreachable is not refused: nothing has said the endpoint is unwilling. + expect(r.health()).toMatchObject({ delivered: 1, failed: 0, retried: 1 }); + r.stop(); + }); + + it('schedules nothing new once stopped', async () => { + vi.useFakeTimers(); + const impl = vi.fn(async () => new Response('{}', { status: 503 })); + const r = reporterFor(impl); + + one(r); + r.stop(); + // Under fake timers a real `setTimeout` never fires, so the send is drained by advancing them. + await vi.advanceTimersByTimeAsync(1); + const afterStop = impl.mock.calls.length; + + expect(afterStop, 'stopping still sends what was buffered').toBe(1); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS * 4); + // A guard being torn down must not leave a timer holding a process open for a report nobody awaits. + expect(impl.mock.calls.length, 'and does not retry behind the guard').toBe(afterStop); + }); +}); + +describe('the backoff', () => { + it('grows, stays bounded, and is jittered', () => { + // Jitter is what keeps many guards from returning in step after one shared outage. + expect(retryDelayMs(1, null, () => 0.5)).toBe(1000); + expect(retryDelayMs(2, null, () => 0.5)).toBe(2000); + expect(retryDelayMs(3, null, () => 0.5)).toBe(4000); + expect(retryDelayMs(99, null, () => 0.5)).toBe(30_000); + + // Within ±25%, so jitter can neither collapse the delay to nothing nor exceed the cap. + expect(retryDelayMs(1, null, () => 0)).toBe(750); + expect(retryDelayMs(1, null, () => 0.999)).toBeLessThanOrEqual(1250); + expect(retryDelayMs(99, null, () => 0.999)).toBeLessThanOrEqual(30_000); + }); + + it('honours Retry-After, but not past the cap', () => { + // The endpoint saying what it can take beats a guess — capped, so a header cannot park a batch. + expect(retryDelayMs(1, '5')).toBe(5000); + expect(retryDelayMs(1, '99999')).toBe(30_000); + expect(retryDelayMs(1, 'not-a-date'), 'an unusable value falls back to the backoff').toBeGreaterThan(0); + expect(retryDelayMs(1, '-1'), 'and so does a negative one').toBeGreaterThan(0); + }); +}); + +describe('a reporting state supersedes rather than accumulates', () => { + it('declares only the newest state when several arrive before a send', async () => { + const bodies: any[] = []; + const impl = vi.fn(async (_u: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? '{}'))); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl); + + r.announce('no-managed-rules'); + r.announce('on'); + await settle(); + + const declared = bodies.filter((b) => typeof b.reporting_state === 'string'); + expect(declared.length, 'one declaration, not two').toBe(1); + expect(declared[0].reporting_state, 'and it is the current state').toBe('on'); + expect(r.health().capability).toMatchObject({ announced: 1, acknowledged: 1 }); + // A declaration carries no events, so it must not move the event counters. + expect(r.health()).toMatchObject({ sent: 0, delivered: 0 }); + r.stop(); + }); + + it('carries a state and the queued events in one request', async () => { + const bodies: any[] = []; + const impl = vi.fn(async (_u: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? '{}'))); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl); + + one(r); + r.announce('on'); + await settle(); + + expect(bodies.length, 'one request, not one each').toBe(1); + expect(bodies[0].reporting_state).toBe('on'); + expect(bodies[0].detections.length).toBe(1); + r.stop(); + }); + + it('declares a state that arrives while a send is in flight, once that send finishes', async () => { + const bodies: any[] = []; + let release: (() => void) | null = null; + const impl = vi.fn(async (_u: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? '{}'))); + if (bodies.length === 1) await new Promise((r) => { release = r; }); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl); + + r.announce('no-managed-rules'); + await settle(); + expect(bodies.length, 'the first declaration is in flight').toBe(1); + + // Two more while it is held. Only the newest should follow. + r.announce('disabled-by-config'); + r.announce('on'); + release?.(); + await settle(); + await settle(); + + expect(bodies.length, 'one follow-up, not two').toBe(2); + expect(bodies[1].reporting_state).toBe('on'); + r.stop(); + }); +}); + +describe('an event is bounded in size, and says when it was', () => { + const capture = () => { + const bodies: any[] = []; + const impl = vi.fn(async (_u: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? '{}'))); + + return new Response('{}', { status: 202 }); + }); + + return { bodies, impl }; + }; + + it('shortens a long route and says so', async () => { + const { bodies, impl } = capture(); + const r = reporterFor(impl); + + r.record({ rule: RULE, phase: 'request', mode: 'block', path: `/${'a'.repeat(500)}` }); + r.flush(); + await settle(); + + const event = bodies[0].detections[0]; + expect(event.route.length, 'capped').toBe(256); + // Without this a reader would take a shortened route for a different route. + expect(event.truncated).toContain('route'); + }); + + it('caps how many parameters an event names, and reports the real count', async () => { + const { bodies, impl } = capture(); + const r = reporterFor(impl); + const broad = { + id: 'broad', + rule_v2: Array.from({ length: 40 }, (_, i) => ({ + parameter: `post.field_${i}`, + match: { type: 'contains', value: 'x' }, + })), + }; + + r.record({ rule: broad, phase: 'request', mode: 'block', path: '/a' }); + r.flush(); + await settle(); + + const event = bodies[0].detections[0]; + expect(event.parameters.length).toBe(25); + expect(event.truncated).toContain('parameters'); + expect(event.parameters_total, 'so a reader knows what was left out').toBe(40); + }); + + it('says nothing about truncation when nothing was truncated', async () => { + const { bodies, impl } = capture(); + const r = reporterFor(impl); + + one(r); + r.flush(); + await settle(); + + const event = bodies[0].detections[0]; + // Absence is not a claim: the field appears only when something really was shortened. + expect(Object.hasOwn(event, 'truncated')).toBe(false); + expect(Object.hasOwn(event, 'parameters_total')).toBe(false); + }); + + it('marks a capped identifier instead of passing a shortened one off as whole', async () => { + const { bodies, impl } = capture(); + const r = reporterFor(impl, { rulesEtag: `"${'e'.repeat(400)}"` }); + + r.record({ + rule: { ...RULE, id: 'r'.repeat(400), rule_revision: 'v'.repeat(400) }, + phase: 'request', + mode: 'block', + path: '/a', + }); + r.flush(); + await settle(); + + const event = bodies[0].detections[0]; + expect(event.rule_id.length).toBe(256); + expect(event.rules_etag.length).toBe(256); + // A shortened identifier no longer names the rule it came from, so saying so is the whole point: a + // reader must not use it as a key believing it is complete. + expect(event.truncated).toContain('rule_id'); + expect(event.truncated).toContain('rules_etag'); + r.stop(); + }); +}); + +describe('the byte bound on a batch', () => { + // Distinguishable, or `toEqual` on the remainder cannot tell a reordering from the right order. + const event = (bytes: number, id: number) => ({ rule_id: `r${id}`, route: `/${id}`.padEnd(bytes, 'a') }); + + it('keeps at least one event even when that one exceeds the bound', () => { + // A batch of none makes no progress and would retry forever against the same bound. + const [batch, rest] = splitToFit([event(5000, 1)], 1000); + + expect(batch.length).toBe(1); + expect(rest.length).toBe(0); + }); + + it('sends what fits and returns the rest in order', () => { + const events = [event(400, 1), event(400, 2), event(400, 3), event(400, 4)]; + const [batch, rest] = splitToFit(events, 1000); + + expect(batch.length, 'as many as fit').toBeLessThan(events.length); + expect(JSON.stringify(batch).length).toBeLessThanOrEqual(1000); + expect(batch.length + rest.length, 'nothing is lost in the split').toBe(events.length); + // Order matters: the remainder goes back to the front of the queue, so it must still be in sequence. + expect(rest).toEqual(events.slice(batch.length)); + }); + + it('delivers the remainder in a later batch rather than losing it', async () => { + // The split is only safe if what did not fit comes back. A remainder that is returned and then + // dropped looks identical to a batch that fit, and the events are simply gone. + const bodies: any[] = []; + const impl = vi.fn(async (_u: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? '{}'))); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl, { rulesEtag: `"${'e'.repeat(254)}"` }); + // Worst-case events: a full batch of these exceeds the body bound, so the split is reached. + const wide = { + id: 'r'.repeat(256), + rule_revision: 'v'.repeat(256), + rule_v2: Array.from({ length: 25 }, (_, i) => ({ + parameter: `post.${'f'.repeat(58)}${i}`, + match: { type: 'contains', value: 'x' }, + })), + }; + for (let i = 0; i < 50; i++) { + r.record({ rule: wide, phase: 'request', mode: 'block', path: `/${i}`.padEnd(256, 'a') }); + } + + // Drain every batch the queue produces. + for (let i = 0; i < 6; i++) { + r.flush(); + await settle(); + } + + expect(bodies.length, 'it took more than one request').toBeGreaterThan(1); + for (const body of bodies) { + expect(JSON.stringify(body.detections).length, 'each body is under the bound').toBeLessThanOrEqual( + 64 * 1024, + ); + } + const total = bodies.reduce((n, b) => n + b.detections.length, 0); + expect(total, 'every event arrived').toBe(50); + expect(r.health()).toMatchObject({ delivered: 50, failed: 0, dropped: 0 }); + r.stop(); + }); + + it('sends everything when it all fits', () => { + const events = [event(10, 1), event(10, 2)]; + + expect(splitToFit(events, 1000)).toEqual([events, []]); + }); +}); diff --git a/tests/protect/detection-payload-contract.test.ts b/tests/protect/detection-payload-contract.test.ts index 54b30fc8..2cbc870b 100644 --- a/tests/protect/detection-payload-contract.test.ts +++ b/tests/protect/detection-payload-contract.test.ts @@ -39,6 +39,8 @@ const FIELD_DISCLOSURE: Record = { detected_at: /timestamp/i, client_ip: /client address/i, client_ip_source: /where that address came from/i, + truncated: /which fields were shortened/i, + parameters_total: /how many parameters the rule reads/i, }; /** @@ -48,7 +50,7 @@ const FIELD_DISCLOSURE: Record = { * present-but-empty field reads as a failed lookup of a real address. So the completeness check below * requires every OTHER documented field, and this one only when there was an address to report. */ -const CONDITIONAL_FIELDS = new Set(['client_ip']); +const CONDITIONAL_FIELDS = new Set(['client_ip', 'truncated', 'parameters_total']); /** Envelope keys, described separately because they are per-batch rather than per-detection. */ const ENVELOPE_DISCLOSURE: Record = { @@ -118,10 +120,57 @@ async function capturePayload(): Promise<{ raw: string; body: Record }; } +/** + * The same reporter, on a detection large enough to be shortened. + * + * The fields that only appear when something was truncated would otherwise never be emitted here, and + * their disclosure entries would sit in the table above describing a payload this test never produces. + */ +async function captureTruncatedPayload(): Promise> { + let raw = ''; + const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { + raw = String(init.body); + + return new Response('{}', { status: 202 }); + }); + const reporter = createDetectionReporter({ + siteUuid: 'site-contract', + baseUrl: 'https://api.test/monitor/pulse', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + reporter.record({ + rule: { + id: 'PS-CVE-2026-0002', + rule_v2: Array.from({ length: 40 }, (_, i) => ({ + parameter: `post.field_${i}`, + match: { type: 'contains', value: 'x' }, + })), + }, + phase: 'request', + mode: 'block', + path: `/${'a'.repeat(400)}`, + } as never); + reporter.flush(); + await vi.waitFor(() => expect(raw).not.toBe('')); + + return (JSON.parse(raw).detections as Array>)[0]; +} + describe('the detection payload matches what AGENT-INSTALL.md says about it', () => { it('describes every field it emits', async () => { const { body } = await capturePayload(); - const detection = (body.detections as Array>)[0]; + const truncatedDetection = await captureTruncatedPayload(); + const detection = { + ...(body.detections as Array>)[0], + ...truncatedDetection, + }; + + // The conditional fields are only conditional; they still have to be produced somewhere. + expect(Object.keys(truncatedDetection), 'a shortened payload names what it shortened').toContain( + 'truncated', + ); + expect(Object.keys(truncatedDetection)).toContain('parameters_total'); for (const key of Object.keys(detection)) { const pattern = FIELD_DISCLOSURE[key]; diff --git a/tests/protect/detections.test.ts b/tests/protect/detections.test.ts index d37c8e5b..4b740674 100644 --- a/tests/protect/detections.test.ts +++ b/tests/protect/detections.test.ts @@ -599,7 +599,9 @@ describe('delivery health', () => { // The capability declaration says a guard intends to report. Only an acknowledgement says anything // arrived, and without counting the refusals a delivery path that rejects everything reads the same // as an app where no rule fired. - let status = 500; + // A terminal refusal, so the outcome is settled on the first attempt: a retryable status would be + // retried, and this test is about which counter moves, not about when. + let status = 400; const fetchImpl = vi.fn(async () => new Response('{}', { status })); const reporter = createDetectionReporter({ siteUuid: 'site-1', diff --git a/tests/protect/reporting-runtime.test.ts b/tests/protect/reporting-runtime.test.ts index 6d45f5de..a9dfe2b6 100644 --- a/tests/protect/reporting-runtime.test.ts +++ b/tests/protect/reporting-runtime.test.ts @@ -41,7 +41,8 @@ function stubFetch(opts: { rulesOk?: boolean; etag?: string; refuseDetections?: bodies.push(JSON.parse(String(init?.body ?? '{}'))); return new Response('{}', { - status: opts.refuseDetections ? 503 : 200, + // Terminal, so a refusal is counted on the first attempt. Retry behaviour has its own tests. + status: opts.refuseDetections ? 400 : 200, headers: { 'Content-Type': 'application/json' }, }); } From c2d692df8a6a6b815922fad534b61291f9a11616 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 11:11:05 +0200 Subject: [PATCH 17/33] Leave nothing outstanding when reporting stops, and bound a request in bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop()` now answers everything that can be outstanding. A batch waiting on a retry timer holds the only send slot, so clearing that timer alone left it neither delivered nor counted: it gets one final attempt, with no retry behind it. A request in flight is aborted rather than waited on. Events still queued are drained a batch at a time, each attempted once, and whatever cannot be sent — including everything queued in a runtime with no usable `fetch` — is counted as dropped. Every recorded event now ends up delivered, refused or dropped rather than sitting in a queue that appears in no number. The body bound is measured on the request that is actually sent. It was measured on the events alone, using string length: the first leaves out the envelope and the drop count, and the second counts a multi-byte character as one byte, so a body over the bound on the wire passed the check. It is now the serialised request, in bytes. A retry of a state-only declaration no longer moves the event counter. Retries are counted against whatever the request carried — events, a declaration, or both — which is the same separation the delivered and acknowledged counts already keep. Any 5xx is retried, not four chosen statuses. A server error is the endpoint saying the fault is its own, and the documented contract says such a failure is retried, so abandoning most of the range on the first attempt was a difference nothing in the output would reveal. Each attempt is abandoned after ten seconds. With one send in flight, a request that never settles would hold that slot for the life of the process: the queue would fill, later events would be dropped for pressure, and the health counters would show one attempt that never failed. A parameter total is reported only when parameters were left out, rather than whenever any field was shortened, and a detection with no route keeps `null` instead of an empty string — an empty route reads as a known path that happens to be blank. The shipped documentation no longer says reports are dropped rather than retried, and no longer promises that a redelivery is not counted twice: Connect sends a stable key per batch, and what that key guarantees depends on the endpoint honouring it. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 13 +- src/protect/detections.js | 189 ++++++++++--- src/protect/protect.d.ts | 2 + tests/protect/detection-delivery.test.ts | 334 ++++++++++++++++++++++- 4 files changed, 493 insertions(+), 45 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index df76bef3..91531eea 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -191,10 +191,13 @@ use one as a key believing it names the whole thing. Delivery is retried, up to four attempts per batch, with exponential backoff and jitter, honouring a `Retry-After` header when the endpoint sets one. Only failures worth retrying are retried — unreachable, rate-limited, or a server error; a batch that was refused on its merits is not sent again. Every attempt -of one batch carries the same `Idempotency-Key` header, so a batch that was committed before its -acknowledgement was lost is not counted twice. One request is in flight at a time, so a slow endpoint -slows the queue rather than opening more sockets, and a batch that exhausts its attempts is dropped and -counted rather than retried forever. +of one batch carries the same `Idempotency-Key` header, and a different batch carries a different one, so +a redelivery is identifiable as the same batch rather than a new one — an acknowledgement can be lost +after the server has already taken a batch. One request is in flight at a time, so a slow endpoint slows +the queue rather than opening more sockets; each attempt is abandoned after 10 seconds, so a request that +never settles cannot hold that slot; and a batch that exhausts its attempts is dropped and counted rather +than retried forever. Stopping a guard makes one last attempt at whatever is outstanding and counts +anything it could not send. The client address is reported with its **provenance**, because an address is only as trustworthy as whatever supplied it. `client_ip_source` is one of `runtime` (the address the transport observed), @@ -237,7 +240,7 @@ contained. What it does not contain: **no values of any kind.** Not the value that matched, not the request body, and not the value of any header, cookie or query-string parameter — including those of the parameters -named above. Reports are batched, capped in memory, and dropped rather than retried if Patchstack cannot +named above. Reports are batched, capped in memory, retried a bounded number of times, and dropped if Patchstack cannot be reached — a reporting failure never delays or fails a request. The endpoint needs a credential, so an enrolled site with none resolved starts nothing: the guard warns diff --git a/src/protect/detections.js b/src/protect/detections.js index fbbe8e82..e3f36e24 100644 --- a/src/protect/detections.js +++ b/src/protect/detections.js @@ -50,8 +50,28 @@ const MAX_QUEUE = 500; const MAX_ATTEMPTS = 4; const RETRY_BASE_MS = 1000; const RETRY_CAP_MS = 30_000; -/** A refusal that will refuse again is terminal; these are the ones worth trying later. */ -const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); +/** + * A refusal that will refuse again is terminal; these are the ones worth trying later. + * + * Any 5xx counts, not a chosen few. A server error is the endpoint saying the fault is its own, and + * picking a subset would leave the rest abandoned on the first attempt while the documented contract + * says a server error is retried — a difference nothing in the output would reveal. + */ +const RETRYABLE_EXPLICIT = new Set([408, 425, 429]); +export function worthRetrying(status) { + if (status === null) return true; // Unreachable: nothing has said the endpoint is unwilling. + + return RETRYABLE_EXPLICIT.has(status) || (status >= 500 && status <= 599); +} + +/** + * How long one attempt may take before it is abandoned and retried. + * + * Only one send is in flight, so a request that never settles would hold that slot for the life of the + * process: the queue would fill, every later event would be dropped for pressure, and the health + * counters would show a single attempt that never failed. A hung connection has to look like a failure. + */ +const ATTEMPT_TIMEOUT_MS = 10_000; /** * Size bounds, applied per event and per batch. @@ -129,18 +149,29 @@ function parseRetryAfter(value) { return Math.max(0, at - Date.now()); } +const encoder = new TextEncoder(); + +/** The size of a string on the wire. `length` counts UTF-16 code units, which is not that. */ +export function byteLength(text) { + return encoder.encode(text).length; +} + /** * As many events as fit the byte bound, and the rest. * - * A backstop, not the primary bound: with every field capped, a full batch cannot reach `MAX_BODY_BYTES` - * today. It stays because the field caps and the batch count are separate numbers that can each change, - * and an endpoint refusing an oversized body would refuse every retry of it too. At least one event - * always goes, since a batch of none makes no progress. + * The bound is on the REQUEST, so `wrap` builds the body that will actually be sent — envelope, drop + * count and reporting state included — and it is measured in bytes rather than characters. Measuring the + * events alone, or measuring `length`, both understate the request: one leaves out the envelope, the + * other counts a multi-byte character as one. Either would let a body past the bound on the wire while + * the check reported it as fitting. + * + * At least one event always goes, since a batch of none makes no progress and would meet the same bound + * on every retry. */ -export function splitToFit(events, maxBytes) { +export function splitToFit(events, maxBytes, wrap = (batch) => ({ detections: batch })) { const batch = events.slice(); const rest = []; - while (batch.length > 1 && JSON.stringify(batch).length > maxBytes) { + while (batch.length > 1 && byteLength(JSON.stringify(wrap(batch))) > maxBytes) { rest.unshift(batch.pop()); } @@ -236,7 +267,7 @@ export function createDetectionReporter(opts) { record() {}, flush() {}, stop() {}, setRulesEtag() {}, announce() {}, dropped: () => 0, health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, retried: 0, lastDeliveredAt: null, - capability: { announced: 0, acknowledged: 0, failed: 0, lastAcknowledgedAt: null }, + capability: { announced: 0, acknowledged: 0, failed: 0, retried: 0, lastAcknowledgedAt: null }, }), }; } @@ -286,7 +317,9 @@ export function createDetectionReporter(opts) { let stopped = false; let dropped = 0; let retried = 0; + let capabilityRetried = 0; let flushRequested = false; + let draining = false; // One send at a time, and one batch's worth of state while it runs. let sending = false; @@ -313,16 +346,29 @@ export function createDetectionReporter(opts) { let sequence = 0; /** Events up to the batch and byte bounds, leaving the rest queued. */ - const takeBatch = () => { - const [batch, rest] = splitToFit(queue.splice(0, MAX_BATCH), MAX_BODY_BYTES); + /** The exact request body for a batch: what is measured is what is sent. */ + const bodyFor = (events, droppedWith, state) => ({ + detections: events, + // The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer + // computing a false-positive rate needs to know its denominator is short, and silence about that + // would make a truncated sample look like a complete one. + dropped: droppedWith, + ...(state !== null ? { reporting_state: state } : {}), + }); + + const takeBatch = (droppedWith, state) => { + const [batch, rest] = splitToFit(queue.splice(0, MAX_BATCH), MAX_BODY_BYTES, (events) => + bodyFor(events, droppedWith, state), + ); if (rest.length > 0) queue.unshift(...rest); return batch; }; - const post = async (body, key) => + const post = async (body, key, signal) => fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, { method: 'POST', + ...(signal ? { signal } : {}), headers: { 'Content-Type': 'application/json', Accept: 'application/json', @@ -370,23 +416,30 @@ export function createDetectionReporter(opts) { if (!inFlight) return; sending = true; inFlight.attempts += 1; - if (inFlight.attempts > 1) retried += 1; - - const body = { - detections: inFlight.events, - // The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer - // computing a false-positive rate needs to know its denominator is short, and silence about that - // would make a truncated sample look like a complete one. - dropped: inFlight.dropped, - ...(inFlight.state !== null ? { reporting_state: inFlight.state } : {}), - }; + if (inFlight.attempts > 1) { + // Counted against whatever the request carries. A state-only request carries no events, so letting + // it advance the event counter would report retries of deliveries that never happened — the same + // conflation the delivered/acknowledged split exists to prevent. A request carrying both counts on + // both, because both were retried. + if (inFlight.events.length > 0) retried += 1; + if (inFlight.state !== null) capabilityRetried += 1; + } + + const body = bodyFor(inFlight.events, inFlight.dropped, inFlight.state); let status = null; let retryAfter = null; + // A hung request must look like a failure rather than holding the only send slot forever. + const controller = typeof AbortController === 'function' ? new AbortController() : null; + inFlight.controller = controller; + const timeout = controller + ? unattended(setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS)) + : null; try { - const res = await post(body, inFlight.key); + const res = await post(body, inFlight.key, controller?.signal); if (res && res.ok) { settle(); + if (timeout) clearTimeout(timeout); finish(); return; @@ -394,15 +447,18 @@ export function createDetectionReporter(opts) { status = typeof res?.status === 'number' ? res.status : 0; retryAfter = res?.headers?.get?.('retry-after') ?? null; } catch { - // Unreachable rather than refused: worth another attempt, since nothing says the endpoint is - // unwilling. + // Unreachable, timed out, or aborted: worth another attempt, since nothing says the endpoint is + // unwilling. An abort from `stop()` is not retried, because `stopped` closes that path below. status = null; + } finally { + if (timeout) clearTimeout(timeout); + if (inFlight) inFlight.controller = null; } - const worthRetrying = status === null || RETRYABLE_STATUS.has(status); + const retryable = worthRetrying(status); // Not after `stop()`: the guard is going away, and a timer that outlives it would keep a process // alive to deliver a report nobody is waiting for. - if (worthRetrying && inFlight.attempts < MAX_ATTEMPTS && !stopped) { + if (retryable && inFlight.attempts < MAX_ATTEMPTS && !stopped) { const delay = retryDelayMs(inFlight.attempts, retryAfter); sending = false; retryTimer = unattended( @@ -444,13 +500,25 @@ export function createDetectionReporter(opts) { * one request would immediately become the next — and the flush interval, which exists so a busy app * makes one request instead of fifty, would apply only to the first batch of a guard's life. */ - const due = () => pendingState !== null || queue.length >= MAX_BATCH || flushRequested; + // While draining there is no "eventually": everything left goes now, or is accounted for. + const due = () => draining || pendingState !== null || queue.length >= MAX_BATCH || flushRequested; /** Start a send if one is due and nothing is already in flight; otherwise wait for the interval. */ const kick = () => { - if (sending || inFlight || typeof fetchImpl !== 'function') return; + if (sending || inFlight) return; + if (typeof fetchImpl !== 'function') { + // Nothing can be sent, so a drain makes no progress and the queue is accounted for here. + if (draining) drained(); + + return; + } + // After `stop()` the only sends are the drain's own. `record` and `announce` also refuse once stopped, + // so this is the second of two independent refusals rather than the only one — deliberately, because + // the property it protects is that a torn-down guard opens no connections and arms no timers. + if (stopped && !draining) return; if (queue.length === 0 && pendingState === null) { flushRequested = false; + if (draining) drained(); return; } @@ -461,14 +529,15 @@ export function createDetectionReporter(opts) { } flushRequested = false; - const events = takeBatch(); const droppedWith = dropped; dropped = 0; droppedTotal += droppedWith; - sent += events.length; const state = pendingState; pendingState = null; + + const events = takeBatch(droppedWith, state); + sent += events.length; // Counted where the declaration is actually attached to a request, so coalesced states count once — // the number describes declarations made, not calls received. if (state !== null) capabilityAnnounced += 1; @@ -478,6 +547,15 @@ export function createDetectionReporter(opts) { void attempt(); }; + /** Nothing left to send: whatever never left is counted rather than forgotten. */ + const drained = () => { + draining = false; + if (queue.length > 0) { + droppedTotal += queue.length; + queue = []; + } + }; + const flush = () => { if (timer) { clearTimeout(timer); @@ -509,7 +587,10 @@ export function createDetectionReporter(opts) { // Capped, with a note of what was capped. Every field is an identifier rather than traffic, but a // route is whatever the application routes and a broad rule can read many parameters — and a reader // who cannot tell a shortened route from a complete one will read it as a different route. - const route = capText(routeOf(detection.path), MAX_ROUTE_CHARS); + // `null` survives: there being no route is not the same as the route being empty, and a cap that + // turned one into the other would invent a known path where none was established. + const rawRoute = routeOf(detection.path); + const route = typeof rawRoute === 'string' ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false }; const allParameters = ruleParameters(detection.rule); const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS)); const truncated = []; @@ -534,9 +615,10 @@ export function createDetectionReporter(opts) { route: route.value, parameters: parameters.map((entry) => entry.value), // Present only when something was shortened, so its absence is not a claim of its own. - ...(truncated.length > 0 - ? { truncated, parameters_total: allParameters.length } - : {}), + ...(truncated.length > 0 ? { truncated } : {}), + // Only when parameters were actually left out. Reporting a total because some OTHER field was + // shortened states that parameters were omitted when none were. + ...(truncated.includes('parameters') ? { parameters_total: allParameters.length } : {}), phase: detection.phase ?? null, // The state this detection was handled under, which is the whole point: `false` is a rule that // saw traffic it would have stopped. @@ -586,15 +668,45 @@ export function createDetectionReporter(opts) { if (!stopped) kick(); }); }, + /** + * Stop reporting, leaving nothing stranded and nothing scheduled. + * + * Three things can be outstanding, and each needs an answer: + * + * - a batch waiting on a retry timer — it holds the only send slot, so clearing the timer alone would + * leave it neither delivered nor counted. It gets one final attempt, with no retry behind it. + * - a request in flight — it is aborted, and its completion drains what is left rather than starting + * open-ended work. + * - events still queued — drained a batch at a time, each attempted once. + * + * Whatever remains unsent when the drain runs out is counted as dropped, so every recorded event ends + * up delivered, refused or dropped, and none simply disappears. + */ stop() { + if (stopped) return; stopped = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + draining = true; + if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; + void attempt(); + + return; } - // One last send for whatever is queued. No retry follows it: `stopped` closes that path, so a - // failure here is counted and the guard goes away rather than keeping a timer alive behind it. - flush(); + if (sending) { + // Its completion continues the drain. Aborting turns a hung request into a failure now rather + // than a slot held until the process exits. + inFlight?.controller?.abort(); + + return; + } + flushRequested = true; + kick(); }, /** * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected @@ -628,6 +740,7 @@ export function createDetectionReporter(opts) { announced: capabilityAnnounced, acknowledged: capabilityAcknowledged, failed: capabilityFailed, + retried: capabilityRetried, lastAcknowledgedAt: lastCapabilityAckAt, }, }), diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index e8d53bae..8312fbda 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -88,6 +88,8 @@ export interface Protection { announced: number; acknowledged: number; failed: number; + /** Retries of a declaration, counted apart from event retries for the same reason as the rest. */ + retried: number; lastAcknowledgedAt: string | null; }; }; diff --git a/tests/protect/detection-delivery.test.ts b/tests/protect/detection-delivery.test.ts index eb9b50ec..ea34dc18 100644 --- a/tests/protect/detection-delivery.test.ts +++ b/tests/protect/detection-delivery.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createDetectionReporter, retryDelayMs, splitToFit } from '../../src/protect/detections.js'; +import { + byteLength, + createDetectionReporter, + retryDelayMs, + splitToFit, + worthRetrying, +} from '../../src/protect/detections.js'; /** * Delivery, not payload. @@ -32,7 +38,7 @@ const RETRY_CAP_MS = 30_000; const keysOf = (impl: any) => impl.mock.calls.map((c: any[]) => (c[1]?.headers ?? {})['Idempotency-Key']).filter(Boolean); -afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('a batch worth retrying is retried, and only so far', () => { it('retries a transient refusal and delivers the same batch', async () => { @@ -272,6 +278,22 @@ describe('an event is bounded in size, and says when it was', () => { expect(event.route.length, 'capped').toBe(256); // Without this a reader would take a shortened route for a different route. expect(event.truncated).toContain('route'); + // And nothing else is claimed: no parameter was left out, so there is no total to report. + expect(event.truncated).not.toContain('parameters'); + expect(Object.hasOwn(event, 'parameters_total')).toBe(false); + }); + + it('keeps a missing route missing rather than turning it into an empty one', async () => { + const { bodies, impl } = capture(); + const r = reporterFor(impl); + + r.record({ rule: RULE, phase: 'request', mode: 'block' } as never); + r.flush(); + await settle(); + + // There being no route is not the same as the route being empty: an empty string reads as a known + // path that happens to be blank. + expect(bodies[0].detections[0].route).toBeNull(); }); it('caps how many parameters an event names, and reports the real count', async () => { @@ -329,6 +351,8 @@ describe('an event is bounded in size, and says when it was', () => { // reader must not use it as a key believing it is complete. expect(event.truncated).toContain('rule_id'); expect(event.truncated).toContain('rules_etag'); + // The rule reads one parameter and the event names it, so a parameter total would be a false claim. + expect(Object.hasOwn(event, 'parameters_total'), 'no parameters were omitted').toBe(false); r.stop(); }); }); @@ -403,3 +427,309 @@ describe('the byte bound on a batch', () => { expect(splitToFit(events, 1000)).toEqual([events, []]); }); }); + +describe('stopping leaves nothing outstanding and nothing scheduled', () => { + const refusing = () => vi.fn(async () => new Response('{}', { status: 503 })); + + it('makes a final attempt at a batch that was waiting to retry, and counts it', async () => { + vi.useFakeTimers(); + const impl = refusing(); + const r = reporterFor(impl); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + expect(impl.mock.calls.length, 'the first attempt failed and a retry is pending').toBe(1); + + r.stop(); + await vi.advanceTimersByTimeAsync(1); + + // Clearing the retry timer alone would leave this batch holding the only slot: never delivered, + // never abandoned, and absent from every counter. + expect(impl.mock.calls.length, 'the waiting batch got one last attempt').toBe(2); + expect(r.health()).toMatchObject({ sent: 1, delivered: 0, failed: 1 }); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS * 4); + expect(impl.mock.calls.length, 'and nothing after it').toBe(2); + }); + + it('accounts for a waiting batch that finally succeeds on the way out', async () => { + vi.useFakeTimers(); + let attempts = 0; + const impl = vi.fn(async () => { + attempts++; + + return new Response('{}', { status: attempts === 1 ? 503 : 202 }); + }); + const r = reporterFor(impl); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + r.stop(); + await vi.advanceTimersByTimeAsync(1); + + expect(r.health()).toMatchObject({ sent: 1, delivered: 1, failed: 0 }); + }); + + it('drains what is still queued, a batch at a time', async () => { + vi.useFakeTimers(); + const bodies: any[] = []; + const impl = vi.fn(async (_u: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? '{}'))); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl); + + // More than one batch's worth, with nothing due, so it is all still queued. + for (let i = 0; i < 60; i++) one(r, `/p${i}`); + r.stop(); + await vi.advanceTimersByTimeAsync(1); + + const total = bodies.reduce((n, b) => n + b.detections.length, 0); + expect(bodies.length, 'more than one batch left').toBeGreaterThan(1); + expect(total, 'and all of it went').toBe(60); + expect(r.health()).toMatchObject({ delivered: 60, dropped: 0 }); + }); + + it('counts what it could not send rather than losing track of it', async () => { + vi.useFakeTimers(); + // A transport that is gone: nothing can be delivered, so the drain has to account for the queue. + const impl = vi.fn(async () => { throw new Error('gone'); }); + const r = reporterFor(impl); + + for (let i = 0; i < 60; i++) one(r, `/p${i}`); + r.stop(); + await vi.advanceTimersByTimeAsync(1); + + const h = r.health(); + // Every recorded event ends up somewhere: delivered, refused, or dropped. None simply disappears. + expect(h.delivered + h.failed + h.dropped, 'all 60 are accounted for').toBe(60); + expect(h.delivered).toBe(0); + }); + + it('does not start new work when a send already in flight completes after stop', async () => { + let release: (() => void) | null = null; + const impl = vi.fn(async () => { + if (impl.mock.calls.length === 1) await new Promise((r) => { release = r; }); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl); + + one(r); + r.flush(); + await settle(); + expect(impl.mock.calls.length, 'one send is in flight').toBe(1); + + // Queued behind it, then stopped while it is still running. + one(r, '/b'); + r.stop(); + release?.(); + await settle(); + await settle(); + + // The drain sends what was queued — deliberately, once each — rather than the completion quietly + // chaining fresh batches behind a stopped guard. + const total = impl.mock.calls.length; + await settle(); + expect(impl.mock.calls.length, 'and then it is finished').toBe(total); + expect(r.health().sent).toBe(2); + }); + + it('ignores a flush or a record that arrives after stopping', async () => { + const impl = vi.fn(async () => new Response('{}', { status: 202 })); + const r = reporterFor(impl); + + one(r); + r.stop(); + await settle(); + const afterStop = impl.mock.calls.length; + expect(afterStop, 'the drain sent what was buffered').toBe(1); + + // `flush` and `record` are public, so they can be called after a guard is torn down. Neither may + // start a new request or arm a new interval behind it. + one(r, '/late'); + r.flush(); + await settle(); + + expect(impl.mock.calls.length, 'nothing new was started').toBe(afterStop); + expect(r.health().sent, 'and the late event was never sent').toBe(1); + }); + + it('accounts for the queue when there is no transport to drain it through', async () => { + // A runtime with no usable `fetch` can still record. Those events go nowhere, so they have to be + // counted somewhere rather than sitting in a queue that appears in no number. + vi.stubGlobal('fetch', undefined); + const r = createDetectionReporter({ siteUuid: 'site-1', baseUrl: 'https://x.test/monitor/pulse' }); + + for (let i = 0; i < 7; i++) one(r, `/p${i}`); + r.stop(); + await settle(); + + const h = r.health(); + expect(h.dropped, 'every unsendable event is accounted for').toBe(7); + expect(h.sent).toBe(0); + expect(h.delivered + h.failed).toBe(0); + }); + + it('abandons an attempt that never settles instead of holding the only slot', async () => { + vi.useFakeTimers(); + const seen: Array = []; + const impl = vi.fn( + (_u: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + seen.push(init?.signal ?? undefined); + init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }), + ); + const r = reporterFor(impl); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + expect(seen[0], 'the attempt carries a signal').toBeDefined(); + + // Ten seconds is the attempt bound; without it this request holds the single send slot for the life + // of the process and every later event is dropped for pressure. + await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS); + expect(impl.mock.calls.length, 'it was abandoned and retried').toBeGreaterThan(1); + r.stop(); + }); + + it('aborts a request in flight when stopped', async () => { + let aborted = false; + const impl = vi.fn( + (_u: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { aborted = true; reject(new Error('aborted')); }); + }), + ); + const r = reporterFor(impl); + + one(r); + r.flush(); + await settle(); + r.stop(); + await settle(); + + expect(aborted, 'stopping does not wait on a request that may never answer').toBe(true); + }); +}); + +describe('what counts as worth retrying', () => { + it('retries any server error, not a chosen few', () => { + // The documented contract says a server error is retried. Picking a subset would abandon the rest on + // the first attempt while the documentation said otherwise. + for (const status of [500, 501, 502, 503, 504, 507, 508, 599]) { + expect(worthRetrying(status), `${status} is the endpoint's own fault`).toBe(true); + } + for (const status of [408, 425, 429]) expect(worthRetrying(status)).toBe(true); + expect(worthRetrying(null), 'unreachable says nothing about willingness').toBe(true); + }); + + it('does not retry a refusal on the merits', () => { + for (const status of [400, 401, 403, 404, 409, 413, 422, 200, 302]) { + expect(worthRetrying(status), `${status} would refuse again`).toBe(false); + } + }); +}); + +describe('the byte bound is measured in bytes, on the request that is sent', () => { + it('counts what goes on the wire, not UTF-16 code units', () => { + // A multi-byte character is one code unit and several bytes, so `length` understates the request. + const multi = '☂'.repeat(100); + expect(multi.length).toBe(100); + expect(byteLength(multi), 'three bytes each on the wire').toBe(300); + }); + + it('sizes the whole request, envelope included', () => { + const events = [{ rule_id: 'a' }, { rule_id: 'b' }]; + const wrap = (batch: unknown[]) => ({ detections: batch, dropped: 0, reporting_state: 'on' }); + const bare = byteLength(JSON.stringify({ detections: events })); + const full = byteLength(JSON.stringify(wrap(events))); + + // Measuring the events alone leaves the envelope out, so a body just under the bound goes over it. + expect(full).toBeGreaterThan(bare); + const [batch] = splitToFit(events, full - 1, wrap as never); + expect(batch.length, 'the envelope counted against the bound').toBe(1); + }); + + it('keeps a real request under the bound with multi-byte routes', async () => { + const bodies: string[] = []; + const impl = vi.fn(async (_u: string, init?: RequestInit) => { + bodies.push(String(init?.body ?? '')); + + return new Response('{}', { status: 202 }); + }); + const r = reporterFor(impl, { rulesEtag: `"${'e'.repeat(254)}"` }); + const wide = { + id: 'r'.repeat(256), + rule_v2: Array.from({ length: 25 }, (_, i) => ({ + parameter: `post.${'f'.repeat(58)}${i}`, + match: { type: 'contains', value: 'x' }, + })), + }; + // Three bytes per character, so a batch sized by characters would be three times the bound. + for (let i = 0; i < 50; i++) { + r.record({ rule: wide, phase: 'request', mode: 'block', path: `/${'☂'.repeat(120)}${i}` }); + } + for (let i = 0; i < 8; i++) { + r.flush(); + await settle(); + } + + expect(bodies.length).toBeGreaterThan(1); + for (const body of bodies) { + // The actual bytes of the actual request. + expect(byteLength(body), 'the request that was sent is under the bound').toBeLessThanOrEqual(64 * 1024); + } + r.stop(); + }); +}); + +describe('a capability retry is not an event retry', () => { + it('counts a retried declaration against capability only', async () => { + vi.useFakeTimers(); + let attempts = 0; + const impl = vi.fn(async () => { + attempts++; + + return new Response('{}', { status: attempts === 1 ? 503 : 202 }); + }); + const r = reporterFor(impl); + + r.announce('on'); + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS); + + const h = r.health(); + // A declaration carries no events, so a retry of it describes no delivery of any event. + expect(h.retried, 'no event was retried, because none was sent').toBe(0); + expect(h.capability).toMatchObject({ announced: 1, acknowledged: 1, retried: 1 }); + expect(h.sent).toBe(0); + r.stop(); + }); + + it('counts both when one request carried both', async () => { + vi.useFakeTimers(); + let attempts = 0; + const impl = vi.fn(async () => { + attempts++; + + return new Response('{}', { status: attempts === 1 ? 503 : 202 }); + }); + const r = reporterFor(impl); + + one(r); + r.announce('on'); + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS); + + const h = r.health(); + expect(h.retried, 'the events were retried').toBe(1); + expect(h.capability.retried, 'and so was the declaration').toBe(1); + r.stop(); + }); +}); From 794e23beeda41276d06e2a02bb2bd9450d6b0553 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 11:29:38 +0200 Subject: [PATCH 18/33] Authenticate a detection on its own, and let a shutdown wait for the drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reporter asked for a credential header without passing a timeout, and the exchange bounds itself with a timeout built from that value: absent, the construction throws, the exchange reports "no token", and the batch goes out with no `Authorization` for a site-addressed endpoint to refuse. Boot hid it, because the rules fetch happens first and primes the shared token cache — so the failure only appeared once that cache expired, or wherever the reporter ran without a rules fetch before it. It now passes a timeout, clamped to an attempt's own bound: the exchange is a separate request that an attempt's abort does not reach, so a longer bound would hold the single send slot past the point the attempt was meant to end. Detections also go through the shared Pulse path now, so a 401 discards the token and retries once with a fresh one. A credential can be rotated or revoked before the token's own expiry, which makes the server's refusal authoritative over our clock; the batch's headers travel through both sends, so the redelivery is still recognisable as the same batch. A batch still refused after that is a refusal on the merits and is counted, not retried. `stop()` returns a promise that settles when nothing is outstanding. The drain was already asynchronous, so a host shutting down had no way to wait for it and could interrupt the final attempt it had been promised. The wait is bounded by its own budget and remains best-effort — a runtime that terminates regardless still wins — and ignoring the promise behaves exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 4 +- src/protect/detections.js | 107 +++++++++--- src/protect/protect.d.ts | 12 +- src/protect/runtime.js | 8 +- tests/protect/detection-delivery.test.ts | 211 ++++++++++++++++++++++- 5 files changed, 314 insertions(+), 28 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 91531eea..ce51bf12 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -197,7 +197,9 @@ after the server has already taken a batch. One request is in flight at a time, the queue rather than opening more sockets; each attempt is abandoned after 10 seconds, so a request that never settles cannot hold that slot; and a batch that exhausts its attempts is dropped and counted rather than retried forever. Stopping a guard makes one last attempt at whatever is outstanding and counts -anything it could not send. +anything it could not send. `stop()` returns a promise that settles once that is finished, so a shutdown +handler can `await protection.stop()` instead of racing the last batch against process exit — bounded and +best-effort, since a runtime that terminates regardless still wins. The client address is reported with its **provenance**, because an address is only as trustworthy as whatever supplied it. `client_ip_source` is one of `runtime` (the address the transport observed), diff --git a/src/protect/detections.js b/src/protect/detections.js index e3f36e24..b5b5def1 100644 --- a/src/protect/detections.js +++ b/src/protect/detections.js @@ -1,4 +1,4 @@ -import { pulseAuthHeader } from '../pulse-token.js'; +import { pulseFetch } from '../pulse-token.js'; import { clientIpFields } from './client-ip.js'; import { isSafeOrigin } from './safe-origin.js'; @@ -73,6 +73,19 @@ export function worthRetrying(status) { */ const ATTEMPT_TIMEOUT_MS = 10_000; +/** Matches the rules path, so one app-wide setting governs both. */ +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * How long `stop()` will wait for the drain before resolving anyway. + * + * A shutdown that waits without a bound is a shutdown that can hang, and a host handling SIGTERM has its + * own deadline. So the promise resolves either when nothing is outstanding or when this elapses — never + * later. Waiting is the caller's option, not an obligation: ignoring the promise leaves the old + * behaviour exactly as it was. + */ +const STOP_BUDGET_MS = 5_000; + /** * Size bounds, applied per event and per batch. * @@ -264,7 +277,7 @@ export function createDetectionReporter(opts) { // Nothing to report against. A no-op rather than a throw: reporting is never worth failing a boot. // It answers the whole interface, so a caller never has to know which kind it holds. return { - record() {}, flush() {}, stop() {}, setRulesEtag() {}, announce() {}, dropped: () => 0, + record() {}, flush() {}, stop: () => Promise.resolve(), setRulesEtag() {}, announce() {}, dropped: () => 0, health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, retried: 0, lastDeliveredAt: null, capability: { announced: 0, acknowledged: 0, failed: 0, retried: 0, lastAcknowledgedAt: null }, @@ -320,6 +333,12 @@ export function createDetectionReporter(opts) { let capabilityRetried = 0; let flushRequested = false; let draining = false; + /** @type {(() => void) | null} */ + let drainResolve = null; + /** @type {Promise | null} */ + let drainPromise = null; + /** @type {ReturnType | null} */ + let budgetTimer = null; // One send at a time, and one batch's worth of state while it runs. let sending = false; @@ -365,25 +384,42 @@ export function createDetectionReporter(opts) { return batch; }; + /** + * The credential exchange's own bound, never longer than an attempt's. + * + * The exchange is a separate request, so the attempt's abort signal does not reach it: a token call + * bounded above the attempt would hold the single send slot past the point the attempt was supposed to + * end. It must also be a number — the exchange bounds itself with a timeout built from this value, and + * an absent one makes that construction throw, which the exchange reports as "no token" and every + * site-addressed endpoint then refuses. + */ + const configuredTimeout = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : DEFAULT_TIMEOUT_MS; + const tokenTimeoutMs = Math.min(configuredTimeout, ATTEMPT_TIMEOUT_MS); + const authConfig = { pulseAuth: opts.pulseAuth, endpoint: baseUrl, timeoutMs: tokenTimeoutMs }; + const post = async (body, key, signal) => - fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, { - method: 'POST', - ...(signal ? { signal } : {}), - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': '@patchstack/connect', - // The same key on every attempt of one batch. A retry exists because an acknowledgement can be - // lost after the server committed the batch, so without this a redelivery would be counted twice - // and inflate exactly the numbers the reports are read for. - 'Idempotency-Key': key, - // Same credential path as the rules fetch. The detections endpoint is site-addressed and requires - // a verified, site-bound token, so a batch sent without one is refused — which is why the runtime - // does not build a reporter when no credential resolves, rather than posting into a 401. - ...(await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)), + // Through the shared Pulse path, which attaches the token and — on a 401 — discards it and retries + // once with a fresh one. A cached token can stop being valid before it expires, and the server's + // refusal is authoritative over our own clock; the batch's own headers, this key included, are + // carried through both sends, so the redelivery is still recognisable as the same batch. + pulseFetch( + authConfig, + `${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, + { + method: 'POST', + ...(signal ? { signal } : {}), + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': '@patchstack/connect', + // The same key on every attempt of one batch: an acknowledgement can be lost after the server + // has already taken the batch, and a redelivery has to be identifiable as the same one. + 'Idempotency-Key': key, + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }); + fetchImpl, + ); /** Give up on the batch in flight, counting what it carried. */ const abandon = () => { @@ -547,13 +583,20 @@ export function createDetectionReporter(opts) { void attempt(); }; - /** Nothing left to send: whatever never left is counted rather than forgotten. */ + /** Nothing left to send: whatever never left is counted rather than forgotten, and the wait ends. */ const drained = () => { draining = false; if (queue.length > 0) { droppedTotal += queue.length; queue = []; } + if (budgetTimer) { + clearTimeout(budgetTimer); + budgetTimer = null; + } + const resolve = drainResolve; + drainResolve = null; + if (resolve) resolve(); }; const flush = () => { @@ -681,10 +724,26 @@ export function createDetectionReporter(opts) { * * Whatever remains unsent when the drain runs out is counted as dropped, so every recorded event ends * up delivered, refused or dropped, and none simply disappears. + * + * The drain is asynchronous, so this returns a promise that settles when nothing is outstanding — + * which a host shutting down can await instead of racing the last batch against process exit. It is + * best-effort and bounded: a runtime that terminates the process regardless, or a drain slower than + * `STOP_BUDGET_MS`, still ends the wait. Ignoring the promise behaves exactly as before. */ stop() { - if (stopped) return; + if (stopped) return drainPromise ?? Promise.resolve(); stopped = true; + drainPromise = new Promise((resolve) => { + drainResolve = resolve; + }); + budgetTimer = unattended( + setTimeout(() => { + budgetTimer = null; + const resolve = drainResolve; + drainResolve = null; + if (resolve) resolve(); + }, STOP_BUDGET_MS), + ); if (timer) { clearTimeout(timer); timer = null; @@ -696,17 +755,19 @@ export function createDetectionReporter(opts) { retryTimer = null; void attempt(); - return; + return drainPromise; } if (sending) { // Its completion continues the drain. Aborting turns a hung request into a failure now rather // than a slot held until the process exits. inFlight?.controller?.abort(); - return; + return drainPromise; } flushRequested = true; kick(); + + return drainPromise; }, /** * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 8312fbda..9d0d47f8 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -47,9 +47,17 @@ export interface Protection { refreshHandler?: () => (request: Request) => Promise; /** Stops everything with a timer or a buffer behind it: the refresh loop, the block log, the * detection reporter (flushing what it holds). Always present, and safe to call twice. */ - stop: () => void; + /** + * Stop everything holding a timer or a buffer. + * + * Resolves when the detection reporter has finished draining — outstanding batches delivered, or + * counted if they could not be — so a shutdown handler can await it instead of racing process exit. + * Best-effort and bounded: a runtime that terminates regardless still wins, and the wait ends after an + * internal budget either way. Ignoring the promise behaves as it always has. + */ + stop: () => Promise; /** Alias of `stop`, under the name callers already have. */ - stopRefresh: () => void; + stopRefresh: () => Promise; /** Whether this guard reports security events, and if not, why not. * * Reporting is on for a site enrolled in Patchstack-managed mitigation that is running managed rules diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 3b500f37..af337aac 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -246,6 +246,7 @@ export async function createProtection(options = {}) { rulesEtag: (await store.read())?.etag ?? null, fetchImpl: options.fetchImpl, flushMs: options.detectionFlushMs, + timeoutMs: options.timeoutMs, }); } else if (!next.reports && detections) { detections.stop(); @@ -971,10 +972,15 @@ export async function createProtection(options = {}) { // the block log, the detection reporter. Always present because a lifecycle method that exists only // for some configurations is one a caller cannot rely on — and each of these components can be the // only one installed, so any of them can be the one left running. + // + // Returns a promise that settles when the reporter has finished draining, so a host shutting down can + // await it rather than racing the last batch against process exit. Bounded and best-effort — a runtime + // that terminates regardless still wins — and ignoring the return behaves exactly as before. protection.stop = () => { loop?.stop(); firewallLog?.stop(); - detections?.stop(); + + return detections?.stop() ?? Promise.resolve(); }; // The name callers already have, kept as an alias for it. protection.stopRefresh = protection.stop; diff --git a/tests/protect/detection-delivery.test.ts b/tests/protect/detection-delivery.test.ts index ea34dc18..ec321bf4 100644 --- a/tests/protect/detection-delivery.test.ts +++ b/tests/protect/detection-delivery.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { clearPulseToken } from '../../src/pulse-token.js'; import { byteLength, createDetectionReporter, @@ -733,3 +734,211 @@ describe('a capability retry is not an event retry', () => { r.stop(); }); }); + +describe('a detection is posted authenticated, on a cold cache and after revocation', () => { + // `{secret}-{oauth id}`, the credential shape the exchange parses. + const AUTH = 'the-secret-40-chars-long-ish-value-here-987'; + + /** A transport that exchanges a credential for a token and records what each detection POST carried. */ + const stub = (opts: { tokens?: string[]; detectionStatus?: (n: number) => number } = {}) => { + const tokens = opts.tokens ?? ['jwt-first', 'jwt-second']; + let exchanges = 0; + const posts: Array<{ auth?: string; key?: string }> = []; + const impl = vi.fn(async (url: string, init?: RequestInit) => { + const target = String(url); + const headers = (init?.headers ?? {}) as Record; + if (target.endsWith('/token')) { + const token = tokens[Math.min(exchanges, tokens.length - 1)]; + exchanges++; + + return new Response(JSON.stringify({ access_token: token, expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + posts.push({ auth: headers.Authorization, key: headers['Idempotency-Key'] }); + + return new Response('{}', { status: opts.detectionStatus?.(posts.length) ?? 202 }); + }); + + return { impl, posts, exchanges: () => exchanges }; + }; + + beforeEach(() => { clearPulseToken(); }); + afterEach(() => { clearPulseToken(); }); + + it('exchanges a credential when nothing is cached', async () => { + // Boot happens to prime the shared token cache through the rules fetch, so a reporter that could not + // exchange one itself still looked authenticated — until the cache expired or it ran on its own. + const { impl, posts, exchanges } = stub(); + const r = reporterFor(impl, { pulseAuth: AUTH }); + + one(r); + r.flush(); + await settle(); + + expect(exchanges(), 'it obtained a token of its own').toBe(1); + expect(posts.length).toBe(1); + expect(posts[0].auth, 'and the detection went out authenticated').toBe('Bearer jwt-first'); + r.stop(); + }); + + it('exchanges again once the cached token has expired', async () => { + const { impl, posts } = stub({ tokens: ['jwt-short', 'jwt-fresh'] }); + const r = reporterFor(impl, { pulseAuth: AUTH }); + + one(r); + r.flush(); + await settle(); + expect(posts[0].auth).toBe('Bearer jwt-short'); + + // What a long-running guard reaches: the token it holds is past its life. + clearPulseToken(); + one(r, '/b'); + r.flush(); + await settle(); + + expect(posts[1].auth, 'a fresh token, not an unauthenticated request').toBe('Bearer jwt-fresh'); + r.stop(); + }); + + it('discards a revoked token, retries once, and keeps the same idempotency key', async () => { + // A credential can be rotated or revoked before the token's own expiry, so the server's 401 is + // authoritative over our clock. Without this a guard would present a dead token until local expiry + // and every event in between would be refused. + const { impl, posts, exchanges } = stub({ + tokens: ['jwt-revoked', 'jwt-reissued'], + detectionStatus: (n) => (n === 1 ? 401 : 202), + }); + const r = reporterFor(impl, { pulseAuth: AUTH }); + + one(r); + r.flush(); + await settle(); + + expect(posts.length, 'refused once, then sent again').toBe(2); + expect(posts[0].auth).toBe('Bearer jwt-revoked'); + expect(posts[1].auth, 'with a reissued token').toBe('Bearer jwt-reissued'); + expect(exchanges()).toBe(2); + // The redelivery must still be recognisable as the same batch. + expect(posts[1].key, 'the same key as the refused attempt').toBe(posts[0].key); + expect(r.health()).toMatchObject({ sent: 1, delivered: 1, failed: 0 }); + r.stop(); + }); + + it('counts a persistent refusal rather than retrying it forever', async () => { + vi.useFakeTimers(); + const { impl, posts } = stub({ detectionStatus: () => 401 }); + const r = reporterFor(impl, { pulseAuth: AUTH }); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS * 4); + + // The token path retries a 401 once with a fresh token; a still-refused batch is a refusal on the + // merits, so the outer retry does not repeat it. + expect(posts.length, 'two sends, not an endless series').toBe(2); + expect(r.health()).toMatchObject({ sent: 1, delivered: 0, failed: 1 }); + r.stop(); + }); + + it('bounds the credential exchange by the attempt, not by a longer app-wide setting', async () => { + // The exchange is a separate request, so an attempt's abort does not reach it. Bounded above the + // attempt it would hold the only send slot past the point the attempt was meant to end. + const seen: number[] = []; + const original = AbortSignal.timeout.bind(AbortSignal); + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => { + seen.push(ms); + + return original(ms); + }); + const { impl } = stub(); + const r = reporterFor(impl, { pulseAuth: AUTH, timeoutMs: 120_000 }); + + one(r); + r.flush(); + await settle(); + + expect(seen.length, 'the exchange bounded itself').toBeGreaterThan(0); + for (const ms of seen) { + expect(typeof ms, 'a number, or building the bound throws and the token is lost').toBe('number'); + expect(ms, 'never longer than one attempt').toBeLessThanOrEqual(10_000); + } + r.stop(); + }); +}); + +describe('stopping can be awaited', () => { + it('settles only once the outstanding batch has been delivered', async () => { + let release: ((r: Response) => void) | null = null; + const impl = vi.fn( + () => new Promise((resolve) => { release = resolve; }), + ); + const r = reporterFor(impl); + + one(r); + r.flush(); + await settle(); + + let settled = false; + const done = r.stop().then(() => { settled = true; }); + await settle(); + + // A shutdown handler that did not await this would race the last batch against process exit. + expect(settled, 'not while the request is still open').toBe(false); + release?.(new Response('{}', { status: 202 })); + await done; + + expect(settled).toBe(true); + expect(r.health()).toMatchObject({ delivered: 1 }); + }); + + it('settles when there was nothing outstanding', async () => { + const impl = vi.fn(async () => new Response('{}', { status: 202 })); + const r = reporterFor(impl); + + await expect(r.stop()).resolves.toBeUndefined(); + }); + + it('settles after the drain of a multi-batch queue', async () => { + const impl = vi.fn(async () => new Response('{}', { status: 202 })); + const r = reporterFor(impl); + + for (let i = 0; i < 60; i++) one(r, `/p${i}`); + await r.stop(); + + // Awaiting means the queue is finished with, not merely started on. + expect(r.health()).toMatchObject({ delivered: 60, dropped: 0 }); + }); + + it('gives up waiting rather than hanging on a request that never answers', async () => { + vi.useFakeTimers(); + const impl = vi.fn(() => new Promise(() => {})); + const r = reporterFor(impl); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + + let settled = false; + void r.stop().then(() => { settled = true; }); + // The budget, not the attempt: a host shutting down has its own deadline, and an unbounded wait here + // would become a hung shutdown. + await vi.advanceTimersByTimeAsync(5_000); + expect(settled, 'the wait is bounded').toBe(true); + }); + + it('returns the same settled wait when stopped twice', async () => { + const impl = vi.fn(async () => new Response('{}', { status: 202 })); + const r = reporterFor(impl); + + one(r); + const first = r.stop(); + const second = r.stop(); + await Promise.all([first, second]); + + // A second stop must not restart a drain or hand back a promise nothing will settle. + expect(impl.mock.calls.length).toBe(1); + }); +}); From 4dd329e3ddf1232d273b8feac5e48825b2bcff53 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 12:03:50 +0200 Subject: [PATCH 19/33] End the drain when the shutdown budget runs out, and wait for every buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The budget only resolved the promise. The request stayed open, the queue stayed unaccounted, and later batches were free to follow — so the promise was reporting a completion that had not happened, and the test covering it proved only that the wait ended. The budget now ENDS the drain: the attempt is abandoned and counted, the queue is accounted for, and a response landing afterwards moves nothing, because the numbers have already been reported as final. All three outcomes are covered, since an acknowledgement, a retryable refusal and a refusal on the merits each take a different path out of an attempt. Writing that test found a related defect. An attempt cleared `inFlight.controller` in its `finally`, but on the acknowledged path the next batch is already in flight by the time that runs — so a batch the drain started could have its own controller cleared by the batch before it, leaving nothing able to abort it. The controller is now cleared only by the attempt that created it. `stop()` waits for the block log as well as the detection reporter. Its flush started a token exchange and a post and returned nothing, so the promise could resolve with records still outstanding — and resolve immediately in a configuration where the reporter it waited for was never built. The block log now returns its send, drains a batch at a time, and never rejects, so a caller that ignores it is unaffected and one that awaits it is waiting for the attempt rather than asking whether it succeeded. The credential exchange's bound is the reporter's own, fixed at one attempt's length. It read an app-wide option that no public option feeds, so the value was always absent and always fell through — a setting presented to callers who could not set it. A redelivery after a refused token is counted and reported as itself. The credential path can send a batch twice, which left the attempt count at one and the retry count at zero for a batch that went out twice. It is not a backoff retry, so it has its own number rather than being folded into one whose meaning would then need a caveat. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 8 +- src/protect/detections.js | 87 +++++++++---- src/protect/firewall-log.js | 42 +++++-- src/protect/protect.d.ts | 17 ++- src/protect/runtime.js | 10 +- tests/protect/client-ip-wiring.test.ts | 58 +++++++++ tests/protect/detection-delivery.test.ts | 152 ++++++++++++++++++++++- 7 files changed, 321 insertions(+), 53 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index ce51bf12..9a735abb 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -197,9 +197,11 @@ after the server has already taken a batch. One request is in flight at a time, the queue rather than opening more sockets; each attempt is abandoned after 10 seconds, so a request that never settles cannot hold that slot; and a batch that exhausts its attempts is dropped and counted rather than retried forever. Stopping a guard makes one last attempt at whatever is outstanding and counts -anything it could not send. `stop()` returns a promise that settles once that is finished, so a shutdown -handler can `await protection.stop()` instead of racing the last batch against process exit — bounded and -best-effort, since a runtime that terminates regardless still wins. +anything it could not send. `stop()` returns a promise that settles once every buffer it reaches is +finished with, so a shutdown handler can `await protection.stop()` instead of racing the last batch +against process exit. It is bounded: if the wait runs out first, the drain is ended rather than left +running, so nothing is still outstanding when it resolves. Still best-effort, since a runtime that +terminates the process regardless still wins. The client address is reported with its **provenance**, because an address is only as trustworthy as whatever supplied it. `client_ip_source` is one of `runtime` (the address the transport observed), diff --git a/src/protect/detections.js b/src/protect/detections.js index b5b5def1..ee5fbabb 100644 --- a/src/protect/detections.js +++ b/src/protect/detections.js @@ -73,8 +73,6 @@ export function worthRetrying(status) { */ const ATTEMPT_TIMEOUT_MS = 10_000; -/** Matches the rules path, so one app-wide setting governs both. */ -const DEFAULT_TIMEOUT_MS = 30_000; /** * How long `stop()` will wait for the drain before resolving anyway. @@ -279,7 +277,7 @@ export function createDetectionReporter(opts) { return { record() {}, flush() {}, stop: () => Promise.resolve(), setRulesEtag() {}, announce() {}, dropped: () => 0, health: () => ({ - sent: 0, delivered: 0, failed: 0, dropped: 0, retried: 0, lastDeliveredAt: null, + sent: 0, delivered: 0, failed: 0, dropped: 0, retried: 0, reauthorized: 0, lastDeliveredAt: null, capability: { announced: 0, acknowledged: 0, failed: 0, retried: 0, lastAcknowledgedAt: null }, }), }; @@ -339,6 +337,9 @@ export function createDetectionReporter(opts) { let drainPromise = null; /** @type {ReturnType | null} */ let budgetTimer = null; + /** Bumped when a drain is terminated, so a late response cannot move a counter after the fact. */ + let epoch = 0; + let reauthorized = 0; // One send at a time, and one batch's worth of state while it runs. let sending = false; @@ -385,26 +386,28 @@ export function createDetectionReporter(opts) { }; /** - * The credential exchange's own bound, never longer than an attempt's. + * The credential exchange's own bound: this reporter's, not the application's. * - * The exchange is a separate request, so the attempt's abort signal does not reach it: a token call - * bounded above the attempt would hold the single send slot past the point the attempt was supposed to - * end. It must also be a number — the exchange bounds itself with a timeout built from this value, and - * an absent one makes that construction throw, which the exchange reports as "no token" and every + * It matches an attempt, because the exchange is a separate request that an attempt's abort signal does + * not reach — bounded any longer, a token call would hold the single send slot past the point the + * attempt was meant to end. It must also BE a number: the exchange builds its timeout from this value, + * and an absent one makes that construction throw, which the exchange reports as "no token" and every * site-addressed endpoint then refuses. + * + * Deliberately not a knob. Nothing in the public options feeds a value here, so reading one would be a + * setting a caller cannot set — always undefined, always falling through to a default. */ - const configuredTimeout = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : DEFAULT_TIMEOUT_MS; - const tokenTimeoutMs = Math.min(configuredTimeout, ATTEMPT_TIMEOUT_MS); - const authConfig = { pulseAuth: opts.pulseAuth, endpoint: baseUrl, timeoutMs: tokenTimeoutMs }; + const authConfig = { pulseAuth: opts.pulseAuth, endpoint: baseUrl, timeoutMs: ATTEMPT_TIMEOUT_MS }; + const detectionsUrl = `${baseUrl}/detections/${encodeURIComponent(siteUuid)}`; - const post = async (body, key, signal) => + const post = async (body, key, signal, transport) => // Through the shared Pulse path, which attaches the token and — on a 401 — discards it and retries // once with a fresh one. A cached token can stop being valid before it expires, and the server's // refusal is authoritative over our own clock; the batch's own headers, this key included, are // carried through both sends, so the redelivery is still recognisable as the same batch. pulseFetch( authConfig, - `${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, + detectionsUrl, { method: 'POST', ...(signal ? { signal } : {}), @@ -418,7 +421,7 @@ export function createDetectionReporter(opts) { }, body: JSON.stringify(body), }, - fetchImpl, + transport, ); /** Give up on the batch in flight, counting what it carried. */ @@ -450,6 +453,7 @@ export function createDetectionReporter(opts) { */ const attempt = async () => { if (!inFlight) return; + const mine = epoch; sending = true; inFlight.attempts += 1; if (inFlight.attempts > 1) { @@ -471,8 +475,18 @@ export function createDetectionReporter(opts) { const timeout = controller ? unattended(setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS)) : null; + // The credential path may send the same batch twice: once with a token the server refuses, once with + // a reissued one. That is a redelivery of this batch, so it is counted rather than invisible. + let sends = 0; + const counted = (url, init) => { + if (String(url) === detectionsUrl) sends += 1; + + return fetchImpl(url, init); + }; try { - const res = await post(body, inFlight.key, controller?.signal); + const res = await post(body, inFlight.key, controller?.signal, counted); + if (sends > 1) reauthorized += sends - 1; + if (mine !== epoch) return; // the drain was terminated while this was open if (res && res.ok) { settle(); if (timeout) clearTimeout(timeout); @@ -488,9 +502,14 @@ export function createDetectionReporter(opts) { status = null; } finally { if (timeout) clearTimeout(timeout); - if (inFlight) inFlight.controller = null; + // Only if it is still THIS attempt's. On the acknowledged path `settle()` and `finish()` run inside + // the block above, so by the time this executes `inFlight` can already be the NEXT batch — and + // clearing its controller would leave that batch with nothing to abort it by. + if (inFlight && inFlight.controller === controller) inFlight.controller = null; } + if (mine !== epoch) return; + const retryable = worthRetrying(status); // Not after `stop()`: the guard is going away, and a timer that outlives it would keep a process // alive to deliver a report nobody is waiting for. @@ -583,6 +602,27 @@ export function createDetectionReporter(opts) { void attempt(); }; + /** + * End the drain now, because the shutdown budget is spent. + * + * Resolving alone would have been the promise claiming a completion that had not happened: the request + * would still be open, the queue unaccounted, and later batches free to follow. So the attempt is + * abandoned and counted, the queue is accounted for, and `epoch` moves — which is what stops a response + * that lands afterwards from moving any counter, since by then the numbers have already been reported + * as final. + */ + const terminate = () => { + epoch += 1; + if (inFlight) { + inFlight.controller?.abort(); + failed += inFlight.events.length; + if (inFlight.state !== null) capabilityFailed += 1; + inFlight = null; + } + sending = false; + drained(); + }; + /** Nothing left to send: whatever never left is counted rather than forgotten, and the wait ends. */ const drained = () => { draining = false; @@ -736,14 +776,10 @@ export function createDetectionReporter(opts) { drainPromise = new Promise((resolve) => { drainResolve = resolve; }); - budgetTimer = unattended( - setTimeout(() => { - budgetTimer = null; - const resolve = drainResolve; - drainResolve = null; - if (resolve) resolve(); - }, STOP_BUDGET_MS), - ); + budgetTimer = unattended(setTimeout(() => { + budgetTimer = null; + terminate(); + }, STOP_BUDGET_MS)); if (timer) { clearTimeout(timer); timer = null; @@ -794,6 +830,9 @@ export function createDetectionReporter(opts) { // Attempts beyond the first, counted in ATTEMPTS rather than events: a path that only ever // succeeds on a second try is working, and is worth telling apart from one that never retries. retried, + // Redeliveries the credential path made after a refused token, which are not backoff retries and + // would otherwise appear nowhere: a rotated or revoked credential is worth seeing as itself. + reauthorized, lastDeliveredAt, // Separate, because a capability announcement delivers no events. Reading zero here alongside a // non-zero `delivered` is a normal state, and so is the reverse. diff --git a/src/protect/firewall-log.js b/src/protect/firewall-log.js index 7733a3be..221a96cc 100644 --- a/src/protect/firewall-log.js +++ b/src/protect/firewall-log.js @@ -72,7 +72,7 @@ export function resolveApiBase(pulseOrManifestUrl) { export function createFirewallLogReporter(opts) { const creds = parseApiKey(opts.apiKey); if (!creds) { - return { record() {}, flush() {}, stop() {} }; + return { record() {}, flush: () => Promise.resolve(), stop: () => Promise.resolve() }; } const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, ''); @@ -135,18 +135,22 @@ export function createFirewallLogReporter(opts) { clearTimeout(timer); timer = null; } - if (queue.length === 0 || typeof fetchImpl !== 'function') return; + if (queue.length === 0 || typeof fetchImpl !== 'function') return Promise.resolve(); const batch = queue.splice(0, MAX_BATCH); - void (async () => { - const token = await fetchAccessToken(); - if (!token) return; - - const body = new URLSearchParams(); - body.set('type', 'firewall'); - body.set('logs', JSON.stringify(batch)); + // The send is returned rather than discarded, so a shutdown can wait for it. It never rejects: a + // caller that ignores it must not produce an unhandled rejection, and one that awaits it is waiting + // for the attempt to finish, not asking whether it succeeded. + return (async () => { try { + const token = await fetchAccessToken(); + if (!token) return; + + const body = new URLSearchParams(); + body.set('type', 'firewall'); + body.set('logs', JSON.stringify(batch)); + const p = fetchImpl(`${apiBase}/api/logs/log`, { method: 'POST', headers: { @@ -158,9 +162,9 @@ export function createFirewallLogReporter(opts) { }, body, }); - if (p && typeof p.then === 'function') p.catch(() => {}); + if (p && typeof p.then === 'function') await p.catch(() => {}); } catch { - /* ignore */ + /* A delivery problem is never worth disturbing the app over. */ } })(); }; @@ -196,9 +200,23 @@ export function createFirewallLogReporter(opts) { if (!timer) timer = setTimeout(flush, flushMs); }, flush, + /** + * Stop, and hand back a wait for what was outstanding. + * + * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon + * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and + * spinning would be worse than leaving the records where they are. + */ stop() { stopped = true; - flush(); + + return (async () => { + while (queue.length > 0) { + const before = queue.length; + await flush(); + if (queue.length >= before) return; + } + })(); }, }; } diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 9d0d47f8..651b9afd 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -50,10 +50,12 @@ export interface Protection { /** * Stop everything holding a timer or a buffer. * - * Resolves when the detection reporter has finished draining — outstanding batches delivered, or - * counted if they could not be — so a shutdown handler can await it instead of racing process exit. - * Best-effort and bounded: a runtime that terminates regardless still wins, and the wait ends after an - * internal budget either way. Ignoring the promise behaves as it always has. + * Resolves once every buffer this reaches is finished with — the detection reporter and the block log, + * their outstanding batches delivered or else counted — so a shutdown handler can await it instead of + * racing process exit. Bounded: if an internal budget elapses first, the drain is ENDED rather than + * merely stopped being waited for, so nothing is left outstanding when this resolves. Still + * best-effort, because a runtime that terminates the process regardless still wins. Ignoring the + * promise behaves as it always has. */ stop: () => Promise; /** Alias of `stop`, under the name callers already have. */ @@ -86,9 +88,12 @@ export interface Protection { delivered: number; failed: number; dropped: number; - /** Attempts beyond the first. A path that only ever succeeds on a retry is working, and is worth - * telling apart from one that never has to retry. */ + /** Backoff attempts beyond the first. A path that only ever succeeds on a retry is working, and is + * worth telling apart from one that never has to retry. */ retried: number; + /** Redeliveries made after the endpoint refused a token, which are not backoff retries: a rotated or + * revoked credential is worth seeing as itself rather than as a delivery failure. */ + reauthorized: number; lastDeliveredAt: string | null; /** Capability announcements, counted separately: these carry no events, so they never move the * counters above. Zero here alongside delivered events is normal, and so is the reverse. */ diff --git a/src/protect/runtime.js b/src/protect/runtime.js index af337aac..7925ac6d 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -246,7 +246,6 @@ export async function createProtection(options = {}) { rulesEtag: (await store.read())?.etag ?? null, fetchImpl: options.fetchImpl, flushMs: options.detectionFlushMs, - timeoutMs: options.timeoutMs, }); } else if (!next.reports && detections) { detections.stop(); @@ -978,9 +977,14 @@ export async function createProtection(options = {}) { // that terminates regardless still wins — and ignoring the return behaves exactly as before. protection.stop = () => { loop?.stop(); - firewallLog?.stop(); + // Both reporters, because the promise says every buffer this reaches is finished with. Waiting only + // for one would resolve while the other still had records outstanding — and resolve immediately in a + // configuration where the one being waited for was never built. + const outstanding = [firewallLog?.stop(), detections?.stop()].filter( + (wait) => wait && typeof wait.then === 'function', + ); - return detections?.stop() ?? Promise.resolve(); + return Promise.all(outstanding).then(() => undefined); }; // The name callers already have, kept as an alias for it. protection.stopRefresh = protection.stop; diff --git a/tests/protect/client-ip-wiring.test.ts b/tests/protect/client-ip-wiring.test.ts index 3a2edf62..2f04d8c0 100644 --- a/tests/protect/client-ip-wiring.test.ts +++ b/tests/protect/client-ip-wiring.test.ts @@ -1194,3 +1194,61 @@ describe('a host callback cannot rewrite what the platform is told', () => { p.stop(); }); }); + +describe('stopping a guard waits for every buffer it reaches', () => { + it('waits for the block log, not only for the detection reporter', async () => { + // `stop()` promises that the buffers it reaches are finished with. Waiting for one reporter would + // resolve while the other still had records outstanding — and resolve at once in a configuration + // where the awaited one was never built. + let releaseLog: ((r: Response) => void) | null = null; + let logPosted = false; + const fetchImpl = vi.fn(async (url: string) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'jwt-abc', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (target.includes('/api/logs/log')) { + logPosted = true; + + return new Promise((resolve) => { releaseLog = resolve; }); + } + + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + vi.stubGlobal('fetch', fetchImpl); + + const p: any = await createProtection({ + rules: rules(), + mode: 'block', + // A block-log reporter and no detection reporter: no site uuid, so the detection path is a no-op + // and the block log is the only buffer with anything outstanding. + apiKey: 'secret0000000000000000000000000000000000-12345', + fetchImpl, + }); + + await throughExpress( + p, + expressReq({ + method: 'GET', + url: '/api', + originalUrl: '/api', + headers: {}, + socket: { remoteAddress: '198.51.100.7' }, + }), + ); + + let settled = false; + const done = p.stop().then(() => { settled = true; }); + await new Promise((r) => setTimeout(r, 20)); + + expect(logPosted, 'the block log is in flight').toBe(true); + expect(settled, 'and the wait has not finished with it').toBe(false); + + releaseLog?.(new Response('{}', { status: 200 })); + await done; + expect(settled).toBe(true); + }); +}); diff --git a/tests/protect/detection-delivery.test.ts b/tests/protect/detection-delivery.test.ts index ec321bf4..6ab2188b 100644 --- a/tests/protect/detection-delivery.test.ts +++ b/tests/protect/detection-delivery.test.ts @@ -912,21 +912,108 @@ describe('stopping can be awaited', () => { expect(r.health()).toMatchObject({ delivered: 60, dropped: 0 }); }); - it('gives up waiting rather than hanging on a request that never answers', async () => { + it('ends the drain when the budget runs out, rather than only ending the wait', async () => { vi.useFakeTimers(); - const impl = vi.fn(() => new Promise(() => {})); + let landLate: ((r: Response) => void) | null = null; + let aborted = false; + const impl = vi.fn( + (_u: string, init?: RequestInit) => + new Promise((resolve) => { + landLate = resolve; + init?.signal?.addEventListener('abort', () => { aborted = true; }); + }), + ); const r = reporterFor(impl); one(r); + one(r, '/b'); r.flush(); await vi.advanceTimersByTimeAsync(1); let settled = false; void r.stop().then(() => { settled = true; }); - // The budget, not the attempt: a host shutting down has its own deadline, and an unbounded wait here - // would become a hung shutdown. + // A host shutting down has its own deadline, so the wait is bounded. await vi.advanceTimersByTimeAsync(5_000); - expect(settled, 'the wait is bounded').toBe(true); + expect(settled, 'the wait ended').toBe(true); + + // And the promise means what it says. Resolving while the request was still open, the queue + // unaccounted and later batches free to follow would have been a claim of completion that had not + // happened. + const atBudget = r.health(); + expect(atBudget.delivered + atBudget.failed + atBudget.dropped, 'everything is accounted for').toBe(2); + // Not asserted here: `stop()` aborts whatever was open when it was called, so this request was + // already abandoned before the budget mattered. The test below covers the batch the budget is for. + expect(aborted, 'the request stop() found was abandoned').toBe(true); + const sendsAtBudget = impl.mock.calls.length; + + // A response landing after the drain ended must not move a number that has already been reported. + landLate?.(new Response('{}', { status: 202 })); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS * 4); + + expect(r.health(), 'the final numbers stayed final').toEqual(atBudget); + expect(impl.mock.calls.length, 'and nothing followed it').toBe(sendsAtBudget); + }); + + it('abandons a batch the drain itself started, when the budget runs out', async () => { + vi.useFakeTimers(); + // `stop()` aborts the request it finds. A LATER batch — one the drain starts on its own — is the one + // only the budget can end, so that is the request this hangs. + const aborts: boolean[] = []; + const impl = vi.fn((_u: string, init?: RequestInit) => { + if (impl.mock.calls.length === 1) return Promise.resolve(new Response('{}', { status: 202 })); + const index = aborts.length; + aborts.push(false); + + return new Promise(() => { + init?.signal?.addEventListener('abort', () => { aborts[index] = true; }); + }); + }); + const r = reporterFor(impl); + + // Two batches' worth: the first goes, the second hangs. + for (let i = 0; i < 60; i++) one(r, `/p${i}`); + const done = r.stop(); + await vi.advanceTimersByTimeAsync(1); + expect(impl.mock.calls.length, 'the drain moved on to a second batch').toBeGreaterThan(1); + + await vi.advanceTimersByTimeAsync(5_000); + await done; + + expect(aborts.some(Boolean), 'the hanging batch was let go of, not just left open').toBe(true); + const h = r.health(); + expect(h.delivered + h.failed + h.dropped, 'and all 60 are accounted for').toBe(60); + }); + + it.each([ + ['an acknowledgement', 202], + ['a refusal worth retrying', 503], + ['a refusal on the merits', 400], + ])('ignores %s that lands after the drain ended', async (_what, status) => { + vi.useFakeTimers(); + let landLate: ((r: Response) => void) | null = null; + const impl = vi.fn( + () => new Promise((resolve) => { landLate = resolve; }), + ); + const r = reporterFor(impl); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + // The budget only elapses once the timers move, so the wait is started and then advanced. + const done = r.stop(); + await vi.advanceTimersByTimeAsync(5_000); + await done; + + const atBudget = r.health(); + const sends = impl.mock.calls.length; + + // Each outcome takes a different path through the attempt, and none of them may reach a counter or + // schedule a retry once the numbers have been reported as final. + landLate?.(new Response('{}', { status })); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS * 4); + + expect(r.health()).toEqual(atBudget); + expect(impl.mock.calls.length).toBe(sends); }); it('returns the same settled wait when stopped twice', async () => { @@ -942,3 +1029,58 @@ describe('stopping can be awaited', () => { expect(impl.mock.calls.length).toBe(1); }); }); + +describe('a redelivery after a refused token appears in the numbers', () => { + const AUTH = 'the-secret-40-chars-long-ish-value-here-987'; + + beforeEach(() => { clearPulseToken(); }); + afterEach(() => { clearPulseToken(); }); + + it('counts an authenticated redelivery, apart from a backoff retry', async () => { + let posts = 0; + const impl = vi.fn(async (url: string) => { + if (String(url).endsWith('/token')) { + return new Response(JSON.stringify({ access_token: `jwt-${posts}`, expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + posts++; + + return new Response('{}', { status: posts === 1 ? 401 : 202 }); + }); + const r = reporterFor(impl, { pulseAuth: AUTH }); + + one(r); + r.flush(); + await settle(); + + const h = r.health(); + expect(posts, 'the batch was sent twice').toBe(2); + // The credential path sends the second one, so it is not a backoff retry — but it IS a redelivery of + // this batch, and a number that ignored it would say the batch went out once. + expect(h.reauthorized, 'the redelivery is visible').toBe(1); + expect(h.retried, 'and is not confused with a backoff retry').toBe(0); + expect(h).toMatchObject({ sent: 1, delivered: 1 }); + r.stop(); + }); + + it('reports no redelivery when the token was accepted', async () => { + const impl = vi.fn(async (url: string) => + String(url).endsWith('/token') + ? new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response('{}', { status: 202 }), + ); + const r = reporterFor(impl, { pulseAuth: AUTH }); + + one(r); + r.flush(); + await settle(); + + expect(r.health().reauthorized).toBe(0); + r.stop(); + }); +}); From 4c4f7f67e67a739d85bf5ebf16978145fdf7d830 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 12:32:23 +0200 Subject: [PATCH 20/33] Bound the block log's shutdown, and track the sends a queue no longer knows about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flush that has already taken its batch leaves an empty queue behind it, so a shutdown looking only at the queue saw nothing to wait for while a token exchange or a post was still open. What is outstanding is the set of sends, not the contents of the queue, so the sends are now tracked and waited for. Stopping twice hands back the same wait rather than a second drain, or a resolved promise while the first one is still running. That drain is bounded, like the detection reporter's and for the same reason: a hung transport would otherwise keep a shutdown pending for as long as the process lived, which is not the bounded shutdown the contract describes. The budget both aborts and detaches — both request phases carry the signal, and aborting alone would still leave the wait depending on a transport to honour it. What the two reporters promise is now stated separately, because it differs. Detection events are delivered or else counted, and their health accounts for every one. Block-log records only have their outstanding attempts completed: that path keeps no counters, so a record lost to a failed exchange or post is reported nowhere, and saying otherwise claimed an accounting that does not exist. A redelivery after a refused token is counted as the second send is made, under the attempt's own epoch, rather than after the credential path returns — otherwise a refresh completing after a shutdown had reported its numbers as final could still move one. Termination also clears the attempt's own timer, which outlives the shutdown budget when a transport ignores an abort, and accounts for a reporting state still waiting for a request it will now never get. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 3 +- src/protect/detections.js | 30 ++++- src/protect/firewall-log.js | 72 ++++++++++-- src/protect/protect.d.ts | 16 ++- tests/protect/detection-delivery.test.ts | 83 +++++++++++++ tests/protect/firewall-log.test.ts | 141 +++++++++++++++++++++++ 6 files changed, 325 insertions(+), 20 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 9a735abb..31886ef4 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -201,7 +201,8 @@ anything it could not send. `stop()` returns a promise that settles once every b finished with, so a shutdown handler can `await protection.stop()` instead of racing the last batch against process exit. It is bounded: if the wait runs out first, the drain is ended rather than left running, so nothing is still outstanding when it resolves. Still best-effort, since a runtime that -terminates the process regardless still wins. +terminates the process regardless still wins. Detection events are delivered or else counted; block-log +records only have their outstanding attempts completed, since that path keeps no counters. The client address is reported with its **provenance**, because an address is only as trustworthy as whatever supplied it. `client_ip_source` is one of `runtime` (the address the transport observed), diff --git a/src/protect/detections.js b/src/protect/detections.js index ee5fbabb..bf5a734e 100644 --- a/src/protect/detections.js +++ b/src/protect/detections.js @@ -475,17 +475,25 @@ export function createDetectionReporter(opts) { const timeout = controller ? unattended(setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS)) : null; + // Owned by the batch, so termination can cancel it. A transport that ignores an abort would otherwise + // leave this scheduled past the shutdown that already reported itself finished. + inFlight.timeout = timeout; // The credential path may send the same batch twice: once with a token the server refuses, once with // a reissued one. That is a redelivery of this batch, so it is counted rather than invisible. let sends = 0; const counted = (url, init) => { - if (String(url) === detectionsUrl) sends += 1; + if (String(url) === detectionsUrl) { + sends += 1; + // Counted as the second send is made, and only while this attempt still owns the numbers. After + // `terminate()` the health has been reported as final, and a refresh completing later must not + // move it. + if (sends > 1 && mine === epoch) reauthorized += 1; + } return fetchImpl(url, init); }; try { const res = await post(body, inFlight.key, controller?.signal, counted); - if (sends > 1) reauthorized += sends - 1; if (mine !== epoch) return; // the drain was terminated while this was open if (res && res.ok) { settle(); @@ -505,7 +513,10 @@ export function createDetectionReporter(opts) { // Only if it is still THIS attempt's. On the acknowledged path `settle()` and `finish()` run inside // the block above, so by the time this executes `inFlight` can already be the NEXT batch — and // clearing its controller would leave that batch with nothing to abort it by. - if (inFlight && inFlight.controller === controller) inFlight.controller = null; + if (inFlight && inFlight.controller === controller) { + inFlight.controller = null; + inFlight.timeout = null; + } } if (mine !== epoch) return; @@ -593,8 +604,9 @@ export function createDetectionReporter(opts) { const events = takeBatch(droppedWith, state); sent += events.length; - // Counted where the declaration is actually attached to a request, so coalesced states count once — - // the number describes declarations made, not calls received. + // Counted where the declaration is committed to a request, so coalesced states count once — the + // number describes declarations this guard undertook to make, not calls received. A state still + // waiting when a shutdown ends counts here too, against a matching failure. if (state !== null) capabilityAnnounced += 1; sequence += 1; @@ -615,10 +627,18 @@ export function createDetectionReporter(opts) { epoch += 1; if (inFlight) { inFlight.controller?.abort(); + if (inFlight.timeout) clearTimeout(inFlight.timeout); failed += inFlight.events.length; if (inFlight.state !== null) capabilityFailed += 1; inFlight = null; } + // A state that was waiting for a request it will now never get. Counted, because "the platform was + // not told my final state" is exactly what a reader of these numbers is trying to find out. + if (pendingState !== null) { + pendingState = null; + capabilityAnnounced += 1; + capabilityFailed += 1; + } sending = false; drained(); }; diff --git a/src/protect/firewall-log.js b/src/protect/firewall-log.js index 221a96cc..7d39f6e9 100644 --- a/src/protect/firewall-log.js +++ b/src/protect/firewall-log.js @@ -5,6 +5,8 @@ import { isSafeOrigin } from './safe-origin.js'; // Opt out: PATCHSTACK_TELEMETRY=off. Never put api_key in the public widget. const DEFAULT_API_BASE = 'https://api.patchstack.com'; +/** The shutdown budget, matching the detection reporter's. */ +const STOP_BUDGET_MS = 5_000; const DEFAULT_FLUSH_MS = 1000; const MAX_BATCH = 50; const TOKEN_SKEW_MS = 60_000; @@ -85,13 +87,27 @@ export function createFirewallLogReporter(opts) { /** @type {ReturnType | null} */ let timer = null; let stopped = false; + /** + * Every send that has been started and not finished. + * + * A flush that has already taken its batch leaves an empty queue behind it, so a shutdown looking only + * at the queue would see nothing to wait for while a token exchange or a post was still open. What is + * outstanding is the set of sends, not the contents of the queue. + * + * @type {Set>} + */ + const outstanding = new Set(); + /** @type {Promise | null} */ + let drainPromise = null; + /** Aborts both phases of every open send once the shutdown budget is spent. */ + let shutdown = null; /** @type {{ token: string, expiresAt: number } | null} */ let cachedToken = null; /** @type {Promise | null} */ let tokenInflight = null; - const fetchAccessToken = async () => { + const fetchAccessToken = async (signal) => { if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS) { return cachedToken.token; } @@ -101,6 +117,7 @@ export function createFirewallLogReporter(opts) { try { const res = await fetchImpl(`${apiBase}/oauth/token`, { method: 'POST', + ...(signal ? { signal } : {}), headers: { 'Content-Type': 'application/json', Accept: 'application/json', @@ -139,12 +156,13 @@ export function createFirewallLogReporter(opts) { const batch = queue.splice(0, MAX_BATCH); - // The send is returned rather than discarded, so a shutdown can wait for it. It never rejects: a - // caller that ignores it must not produce an unhandled rejection, and one that awaits it is waiting - // for the attempt to finish, not asking whether it succeeded. - return (async () => { + // Returned so a shutdown can wait for it, and tracked so a shutdown can find it even when the queue + // it came from is already empty. It never rejects: a caller that ignores it must not produce an + // unhandled rejection, and one that awaits it is waiting for the attempt to finish, not asking + // whether it succeeded. + const send = (async () => { try { - const token = await fetchAccessToken(); + const token = await fetchAccessToken(shutdown?.signal); if (!token) return; const body = new URLSearchParams(); @@ -161,12 +179,19 @@ export function createFirewallLogReporter(opts) { ...(sourceHost ? { 'Source-Host': sourceHost } : {}), }, body, + // Both phases carry it, so a shutdown that runs out of time can end either one. + ...(shutdown ? { signal: shutdown.signal } : {}), }); if (p && typeof p.then === 'function') await p.catch(() => {}); } catch { /* A delivery problem is never worth disturbing the app over. */ } })(); + + outstanding.add(send); + void send.then(() => outstanding.delete(send)); + + return send; }; return { @@ -208,15 +233,46 @@ export function createFirewallLogReporter(opts) { * spinning would be worse than leaving the records where they are. */ stop() { + // The same wait every time. A second call must not hand back a resolved promise while the first + // drain is still running, and must not start a second drain behind it. + if (drainPromise) return drainPromise; stopped = true; - return (async () => { + const controller = typeof AbortController === 'function' ? new AbortController() : null; + shutdown = controller; + + /** @type {ReturnType | null} */ + let budget = null; + // Bounded like the detection reporter's, and for the same reason: a hung transport would otherwise + // keep a shutdown pending for as long as the process lived, which is not a bounded shutdown. + // + // The budget both aborts and DETACHES. Aborting alone would still leave the wait depending on the + // transport to honour it, and one that does not would hang the shutdown exactly as before. + const spent = new Promise((resolve) => { + budget = setTimeout(() => { + controller?.abort(); + resolve(); + }, STOP_BUDGET_MS); + if (typeof budget.unref === 'function') budget.unref(); + }); + + const work = (async () => { + // Sends already started, whose batches have left the queue and so cannot be found by looking at + // it, then the queue itself a batch at a time. The loop stops as soon as a pass cannot shrink the + // queue — with no usable transport there is nothing to wait for, and spinning would be worse. + await Promise.all([...outstanding]); while (queue.length > 0) { const before = queue.length; await flush(); - if (queue.length >= before) return; + if (queue.length >= before) break; } })(); + + drainPromise = Promise.race([work, spent]).then(() => { + if (budget) clearTimeout(budget); + }); + + return drainPromise; }, }; } diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 651b9afd..0242c51f 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -50,12 +50,16 @@ export interface Protection { /** * Stop everything holding a timer or a buffer. * - * Resolves once every buffer this reaches is finished with — the detection reporter and the block log, - * their outstanding batches delivered or else counted — so a shutdown handler can await it instead of - * racing process exit. Bounded: if an internal budget elapses first, the drain is ENDED rather than - * merely stopped being waited for, so nothing is left outstanding when this resolves. Still - * best-effort, because a runtime that terminates the process regardless still wins. Ignoring the - * promise behaves as it always has. + * Resolves once every buffer this reaches is finished with — the detection reporter and the block log — + * so a shutdown handler can await it instead of racing process exit. Bounded: each has its own budget, + * and when one elapses that drain is ENDED rather than merely stopped being waited for, so nothing is + * left outstanding when this resolves. Still best-effort, because a runtime that terminates the process + * regardless still wins. Ignoring the promise behaves as it always has. + * + * What "finished with" means differs by reporter. Detection events are delivered or else counted, and + * `detectionHealth()` accounts for every one. Block-log records only have their outstanding attempts + * completed: that path keeps no counters, so a record lost to a failed token exchange or post is not + * reported anywhere. */ stop: () => Promise; /** Alias of `stop`, under the name callers already have. */ diff --git a/tests/protect/detection-delivery.test.ts b/tests/protect/detection-delivery.test.ts index 6ab2188b..a82f2f3c 100644 --- a/tests/protect/detection-delivery.test.ts +++ b/tests/protect/detection-delivery.test.ts @@ -1023,6 +1023,8 @@ describe('stopping can be awaited', () => { one(r); const first = r.stop(); const second = r.stop(); + // The same shutdown, so the same wait — not a fresh promise that settles on its own schedule. + expect(second, 'the same wait, not a new one').toBe(first); await Promise.all([first, second]); // A second stop must not restart a drain or hand back a promise nothing will settle. @@ -1084,3 +1086,84 @@ describe('a redelivery after a refused token appears in the numbers', () => { r.stop(); }); }); + +describe('termination finalises everything the reporter was holding', () => { + const AUTH = 'the-secret-40-chars-long-ish-value-here-987'; + + beforeEach(() => { clearPulseToken(); }); + afterEach(() => { clearPulseToken(); }); + + it('does not let a credential refresh that lands late move the final numbers', async () => { + vi.useFakeTimers(); + let releaseSecond: ((r: Response) => void) | null = null; + let posts = 0; + const impl = vi.fn(async (url: string) => { + if (String(url).endsWith('/token')) { + return new Response(JSON.stringify({ access_token: `jwt-${posts}`, expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + posts++; + if (posts === 1) return new Response('{}', { status: 401 }); + + // The redelivery, held open past the shutdown budget. + return new Promise((resolve) => { releaseSecond = resolve; }); + }); + const r = reporterFor(impl, { pulseAuth: AUTH }); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + + const done = r.stop(); + await vi.advanceTimersByTimeAsync(5_000); + await done; + + const atBudget = r.health(); + releaseSecond?.(new Response('{}', { status: 202 })); + await vi.advanceTimersByTimeAsync(RETRY_CAP_MS); + + // The redelivery is counted as it is made, under this attempt's epoch — so one completing after the + // numbers were reported as final cannot change them. + expect(r.health(), 'the final numbers stayed final').toEqual(atBudget); + }); + + it('accounts for a state that was still waiting for a request', async () => { + vi.useFakeTimers(); + const impl = vi.fn(() => new Promise(() => {})); + const r = reporterFor(impl); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + // Queued behind the batch that is now stuck, so it never gets a request of its own. + r.announce('on'); + + const done = r.stop(); + await vi.advanceTimersByTimeAsync(5_000); + await done; + + // "The platform was never told my final state" is exactly what a reader of these numbers is after. + expect(r.health().capability).toMatchObject({ announced: 1, acknowledged: 0, failed: 1 }); + }); + + it('leaves no attempt timer scheduled behind a finished shutdown', async () => { + vi.useFakeTimers(); + const impl = vi.fn(() => new Promise(() => {})); + const r = reporterFor(impl); + + one(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + const whileRunning = vi.getTimerCount(); + + const done = r.stop(); + await vi.advanceTimersByTimeAsync(5_000); + await done; + + // The attempt's own ten-second bound outlives the five-second budget unless termination clears it. + expect(vi.getTimerCount(), 'nothing is still scheduled').toBeLessThan(whileRunning); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/tests/protect/firewall-log.test.ts b/tests/protect/firewall-log.test.ts index 6c1a3fde..c219f2f6 100644 --- a/tests/protect/firewall-log.test.ts +++ b/tests/protect/firewall-log.test.ts @@ -192,3 +192,144 @@ describe('createProtection connector log reporting', () => { protection.stopRefresh?.(); }); }); + +describe('stopping the block log waits for what is outstanding, and is bounded', () => { + const KEY = 'abcdefghijabcdefghijabcdefghijabcdefghij-42'; + const reporter = (fetchImpl: unknown, over: Record = {}) => + createFirewallLogReporter({ + apiKey: KEY, + apiBase: 'https://api.test', + fetchImpl: fetchImpl as typeof fetch, + flushMs: 1, + ...over, + }); + const record = (r: any, n = 1) => { + for (let i = 0; i < n; i++) r.record({ rule: { id: `r${i}` }, method: 'GET', path: '/a', ip: '1.2.3.4' }); + }; + const tick = async () => { await new Promise((r) => setTimeout(r, 5)); }; + + afterEach(() => { vi.useRealTimers(); }); + + it('waits for a send that was already running when it was called', async () => { + // A flush that has already taken its batch leaves an empty queue behind it. A shutdown that looked + // only at the queue would see nothing to wait for while the post was still open. + let releasePost: ((r: Response) => void) | null = null; + const impl = vi.fn(async (url: string) => { + if (String(url).includes('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Promise((resolve) => { releasePost = resolve; }); + }); + const r: any = reporter(impl); + + record(r); + r.flush(); + await tick(); + expect(releasePost, 'the post is open and the queue is empty').not.toBeNull(); + + let settled = false; + const done = r.stop().then(() => { settled = true; }); + await tick(); + expect(settled, 'the wait found the send the queue no longer knew about').toBe(false); + + releasePost?.(new Response('{}', { status: 200 })); + await done; + expect(settled).toBe(true); + }); + + it('returns the same wait when stopped twice', async () => { + let releasePost: ((r: Response) => void) | null = null; + const impl = vi.fn(async (url: string) => { + if (String(url).includes('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Promise((resolve) => { releasePost = resolve; }); + }); + const r: any = reporter(impl); + + record(r); + r.flush(); + await tick(); + + // The second call must not hand back a resolved promise while the first drain is still running, nor + // start a second drain behind it: it is the same shutdown, so it is the same wait. + const firstCall = r.stop(); + const secondCall = r.stop(); + expect(secondCall, 'the same wait, not a new one').toBe(firstCall); + + let firstDone = false; + let secondDone = false; + const first = firstCall.then(() => { firstDone = true; }); + const second = secondCall.then(() => { secondDone = true; }); + await tick(); + + expect(firstDone || secondDone, 'neither has finished yet').toBe(false); + releasePost?.(new Response('{}', { status: 200 })); + await Promise.all([first, second]); + expect(firstDone && secondDone).toBe(true); + }); + + it('gives up on a transport that never answers, and lets go of both phases', async () => { + vi.useFakeTimers(); + const aborted: string[] = []; + const impl = vi.fn( + (url: string, init?: RequestInit) => + new Promise(() => { + init?.signal?.addEventListener('abort', () => { + aborted.push(String(url).includes('/oauth/token') ? 'token' : 'post'); + }); + }), + ); + const r: any = reporter(impl); + + record(r); + let settled = false; + void r.stop().then(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(1); + expect(settled, 'still waiting on the token exchange').toBe(false); + + // A hung transport would otherwise keep a shutdown pending for as long as the process lived. + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(1); + expect(settled, 'the wait is bounded').toBe(true); + expect(aborted, 'and the open phase was let go of').toContain('token'); + }); + + it('aborts a hanging post, not only a hanging token exchange', async () => { + vi.useFakeTimers(); + const aborted: string[] = []; + const impl = vi.fn((url: string, init?: RequestInit) => { + if (String(url).includes('/oauth/token')) { + return Promise.resolve( + new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + + return new Promise(() => { + init?.signal?.addEventListener('abort', () => { aborted.push('post'); }); + }); + }); + const r: any = reporter(impl); + + record(r); + let settled = false; + void r.stop().then(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(1); + + expect(settled, 'bounded on the post too').toBe(true); + expect(aborted).toContain('post'); + }); +}); From 9717dbf10fde87f31e14d7422e6e4dd15f4d0b60 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 12:41:33 +0200 Subject: [PATCH 21/33] Give the block log a controller that exists before a shutdown needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shutdown controller was created inside `stop()`, by which time the sends worth ending had already started with no signal at all — so the budget could not abort the very requests the tracking set had been added to find. One controller now covers every request the reporter makes, for its whole life. Not one per send either: the token exchange is shared, so a second send awaits the first send's request, and a signal belonging to the second would not reach it. Racing the wait against the budget ended the wait but left the work alive: still blocked, still holding its entry, and free to run another flush if the transport answered later — after the shutdown had reported itself finished. The budget now ends the reporter. It is marked ended, which closes the send, the flush and the drain loop; the open requests are aborted; and what was never sent is discarded rather than retained by a reporter nobody will read again. The public wording no longer claims the underlying requests completed. An abort is a request to stop, not a guarantee, so a transport that ignores it is detached: the promise resolving means the reporter is finished with it. What is accounted for is also stated per reporter, because it differs — every detection event ends up delivered, refused or dropped and appears in the health counts, while block-log records have no counters and a lost one is reported nowhere. Co-Authored-By: Claude Opus 5 (1M context) --- AGENT-INSTALL.md | 15 +++-- src/protect/firewall-log.js | 46 ++++++++++----- src/protect/protect.d.ts | 23 +++++--- tests/protect/firewall-log.test.ts | 90 ++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 28 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 31886ef4..a5c54990 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -197,12 +197,15 @@ after the server has already taken a batch. One request is in flight at a time, the queue rather than opening more sockets; each attempt is abandoned after 10 seconds, so a request that never settles cannot hold that slot; and a batch that exhausts its attempts is dropped and counted rather than retried forever. Stopping a guard makes one last attempt at whatever is outstanding and counts -anything it could not send. `stop()` returns a promise that settles once every buffer it reaches is -finished with, so a shutdown handler can `await protection.stop()` instead of racing the last batch -against process exit. It is bounded: if the wait runs out first, the drain is ended rather than left -running, so nothing is still outstanding when it resolves. Still best-effort, since a runtime that -terminates the process regardless still wins. Detection events are delivered or else counted; block-log -records only have their outstanding attempts completed, since that path keeps no counters. +anything it could not send. `stop()` returns a promise that settles once the reporters have finished or +been given up on, so a shutdown handler can `await protection.stop()` instead of racing the last batch +against process exit. Each reporter has its own budget, and when it runs out that reporter is ended: its +requests are aborted, it starts nothing further, and it discards what it was holding. An abort is a +request to stop, not a guarantee — a transport that ignores it is detached rather than completed, so +"resolved" means the reporter is finished with it, and a runtime that kills the process still wins +regardless. Every detection event ends up delivered, refused or dropped and is reported in the health +counts; block-log records have no counters, so one lost to a failed send or an expired shutdown is +reported nowhere. The client address is reported with its **provenance**, because an address is only as trustworthy as whatever supplied it. `client_ip_source` is one of `runtime` (the address the transport observed), diff --git a/src/protect/firewall-log.js b/src/protect/firewall-log.js index 7d39f6e9..627f8bc4 100644 --- a/src/protect/firewall-log.js +++ b/src/protect/firewall-log.js @@ -99,8 +99,17 @@ export function createFirewallLogReporter(opts) { const outstanding = new Set(); /** @type {Promise | null} */ let drainPromise = null; - /** Aborts both phases of every open send once the shutdown budget is spent. */ - let shutdown = null; + /** + * One controller for every request this reporter makes, for its whole life. + * + * Not created at shutdown: by then the sends worth ending have already started, and a signal handed + * out afterwards reaches none of them. Not one per send either, because the token exchange is SHARED — + * a second send awaits the first send's exchange, so a signal belonging to the second would not reach + * the request it is waiting on. + */ + const lifetime = typeof AbortController === 'function' ? new AbortController() : null; + /** Set when a shutdown gives up waiting: nothing may start, continue, or be retained after it. */ + let ended = false; /** @type {{ token: string, expiresAt: number } | null} */ let cachedToken = null; @@ -152,7 +161,7 @@ export function createFirewallLogReporter(opts) { clearTimeout(timer); timer = null; } - if (queue.length === 0 || typeof fetchImpl !== 'function') return Promise.resolve(); + if (ended || queue.length === 0 || typeof fetchImpl !== 'function') return Promise.resolve(); const batch = queue.splice(0, MAX_BATCH); @@ -162,8 +171,9 @@ export function createFirewallLogReporter(opts) { // whether it succeeded. const send = (async () => { try { - const token = await fetchAccessToken(shutdown?.signal); - if (!token) return; + const token = await fetchAccessToken(lifetime?.signal); + // Not after a shutdown gave up: it has already reported itself finished. + if (!token || ended) return; const body = new URLSearchParams(); body.set('type', 'firewall'); @@ -180,7 +190,7 @@ export function createFirewallLogReporter(opts) { }, body, // Both phases carry it, so a shutdown that runs out of time can end either one. - ...(shutdown ? { signal: shutdown.signal } : {}), + ...(lifetime ? { signal: lifetime.signal } : {}), }); if (p && typeof p.then === 'function') await p.catch(() => {}); } catch { @@ -238,19 +248,29 @@ export function createFirewallLogReporter(opts) { if (drainPromise) return drainPromise; stopped = true; - const controller = typeof AbortController === 'function' ? new AbortController() : null; - shutdown = controller; + /** + * End the drain, rather than merely stop waiting for it. + * + * Racing the wait against a timer would leave the work alive: still blocked, still holding its + * entry, and free to run another flush if the transport answered later — after the shutdown had + * reported itself finished. So the reporter is marked ended, which closes `flush` and the loop + * below, the open requests are aborted, and what was never sent is discarded rather than retained + * by a reporter nobody will read again. + */ + const terminate = () => { + ended = true; + lifetime?.abort(); + queue = []; + outstanding.clear(); + }; /** @type {ReturnType | null} */ let budget = null; // Bounded like the detection reporter's, and for the same reason: a hung transport would otherwise // keep a shutdown pending for as long as the process lived, which is not a bounded shutdown. - // - // The budget both aborts and DETACHES. Aborting alone would still leave the wait depending on the - // transport to honour it, and one that does not would hang the shutdown exactly as before. const spent = new Promise((resolve) => { budget = setTimeout(() => { - controller?.abort(); + terminate(); resolve(); }, STOP_BUDGET_MS); if (typeof budget.unref === 'function') budget.unref(); @@ -261,7 +281,7 @@ export function createFirewallLogReporter(opts) { // it, then the queue itself a batch at a time. The loop stops as soon as a pass cannot shrink the // queue — with no usable transport there is nothing to wait for, and spinning would be worse. await Promise.all([...outstanding]); - while (queue.length > 0) { + while (!ended && queue.length > 0) { const before = queue.length; await flush(); if (queue.length >= before) break; diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 0242c51f..5c22a62a 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -50,16 +50,21 @@ export interface Protection { /** * Stop everything holding a timer or a buffer. * - * Resolves once every buffer this reaches is finished with — the detection reporter and the block log — - * so a shutdown handler can await it instead of racing process exit. Bounded: each has its own budget, - * and when one elapses that drain is ENDED rather than merely stopped being waited for, so nothing is - * left outstanding when this resolves. Still best-effort, because a runtime that terminates the process - * regardless still wins. Ignoring the promise behaves as it always has. + * Resolves once the reporters this reaches — the detection reporter and the block log — have finished + * or been given up on, so a shutdown handler can await it instead of racing process exit. Each has its + * own budget, and when one elapses that reporter is ENDED: its requests are aborted, it starts nothing + * further, and it discards what it was holding. Ignoring the promise behaves as it always has. * - * What "finished with" means differs by reporter. Detection events are delivered or else counted, and - * `detectionHealth()` accounts for every one. Block-log records only have their outstanding attempts - * completed: that path keeps no counters, so a record lost to a failed token exchange or post is not - * reported anywhere. + * Two limits are worth knowing, because neither can be promised away: + * + * - A request is aborted, not guaranteed to stop. A transport that ignores its abort signal is + * DETACHED — this stops waiting on it and stops acting on its result — so "resolved" means the + * reporter is finished with it, not that the underlying request has ended. + * - A runtime that terminates the process regardless still wins, whatever this resolves. + * + * What is accounted for also differs by reporter. Every detection event ends up delivered, refused or + * dropped, and `detectionHealth()` reports each. Block-log records have no counters at all, so one lost + * to a failed token exchange, a failed post, or a shutdown that ran out of time is reported nowhere. */ stop: () => Promise; /** Alias of `stop`, under the name callers already have. */ diff --git a/tests/protect/firewall-log.test.ts b/tests/protect/firewall-log.test.ts index c219f2f6..37a61365 100644 --- a/tests/protect/firewall-log.test.ts +++ b/tests/protect/firewall-log.test.ts @@ -277,6 +277,96 @@ describe('stopping the block log waits for what is outstanding, and is bounded', expect(firstDone && secondDone).toBe(true); }); + it.each([ + ['the token exchange', 'token'], + ['the log post', 'post'], + ])('aborts %s of a send that started before the shutdown did', async (_what, phase) => { + vi.useFakeTimers(); + // The sends worth ending have already started by the time a shutdown begins, so a signal created at + // that point reaches none of them. This starts the flush FIRST, which is the case the tracking set + // was added for in the first place. + const aborted: string[] = []; + const impl = vi.fn((url: string, init?: RequestInit) => { + const isToken = String(url).includes('/oauth/token'); + if (isToken && phase === 'post') { + return Promise.resolve( + new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + + return new Promise(() => { + init?.signal?.addEventListener('abort', () => { aborted.push(isToken ? 'token' : 'post'); }); + }); + }); + const r: any = reporter(impl); + + record(r); + r.flush(); + await vi.advanceTimersByTimeAsync(1); + expect(impl.mock.calls.length, 'the send is already running').toBeGreaterThan(0); + + let settled = false; + void r.stop().then(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(1); + + expect(settled, 'the wait is bounded').toBe(true); + expect(aborted, `${phase} was let go of`).toContain(phase); + }); + + it('runs nothing more once the shutdown has given up', async () => { + vi.useFakeTimers(); + let releaseToken: ((r: Response) => void) | null = null; + const impl = vi.fn((url: string) => { + if (String(url).includes('/oauth/token')) { + if (releaseToken) { + return Promise.resolve( + new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + + return new Promise((resolve) => { releaseToken = resolve; }); + } + + return Promise.resolve(new Response('{}', { status: 200 })); + }); + // A long interval, so records stay in the queue instead of being swept into sends: the case here + // needs BOTH a send that is stuck and records still waiting behind it. + const r: any = reporter(impl, { flushMs: 60_000 }); + + record(r, 50); // reaches the batch bound and starts a send, which hangs on the token exchange + await vi.advanceTimersByTimeAsync(1); + record(r, 20); // and these stay queued + expect(impl.mock.calls.length, 'one send is stuck').toBe(1); + + const done = r.stop(); + await vi.advanceTimersByTimeAsync(5_000); + await done; + const callsAtBudget = impl.mock.calls.length; + + // The transport answers after the shutdown reported itself finished. Ending the WAIT but leaving the + // work alive would let the drain resume here and post the twenty records still queued behind it. + releaseToken?.( + new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await vi.advanceTimersByTimeAsync(1_000); + + expect(impl.mock.calls.length, 'nothing ran after the shutdown finished').toBe(callsAtBudget); + // And a later flush cannot restart it either. + r.flush(); + await vi.advanceTimersByTimeAsync(1_000); + expect(impl.mock.calls.length).toBe(callsAtBudget); + }); + it('gives up on a transport that never answers, and lets go of both phases', async () => { vi.useFakeTimers(); const aborted: string[] = []; From 7e8d69ea7bf3e8fb2f56111620dfc50689717cba Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 12:47:30 +0200 Subject: [PATCH 22/33] Let a shutdown own the block log's queue from the moment it begins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An armed flush interval could fire mid-drain, take the queued records for itself, and start a send the drain never learned about — so the drain looked at an empty queue, concluded it was finished, and resolved with that send still open. The interval is now cleared as the shutdown starts, before anything else: from that point the drain is the only thing that takes from the queue. The drain also asks again after every wait instead of waiting on one snapshot of what was in flight. A send holds records that have left the queue and the queue holds records that will become a send, so either can produce the other; a snapshot is satisfied as soon as the sends it happened to capture are done, whatever appeared meanwhile. It ends when both are empty, when a pass cannot shrink the queue, or when the budget ends it. A finished send now removes itself in its own `finally`, before its promise settles, so a waiter looking at the set the moment its wait resolves cannot see a send that has already completed. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/firewall-log.js | 33 +++++++++++++--- tests/protect/firewall-log.test.ts | 61 +++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/protect/firewall-log.js b/src/protect/firewall-log.js index 627f8bc4..f493d03a 100644 --- a/src/protect/firewall-log.js +++ b/src/protect/firewall-log.js @@ -169,6 +169,8 @@ export function createFirewallLogReporter(opts) { // it came from is already empty. It never rejects: a caller that ignores it must not produce an // unhandled rejection, and one that awaits it is waiting for the attempt to finish, not asking // whether it succeeded. + /** @type {Promise} */ + let entry; const send = (async () => { try { const token = await fetchAccessToken(lifetime?.signal); @@ -195,11 +197,15 @@ export function createFirewallLogReporter(opts) { if (p && typeof p.then === 'function') await p.catch(() => {}); } catch { /* A delivery problem is never worth disturbing the app over. */ + } finally { + // Here rather than in a `.then`: this runs before the promise settles, so a waiter that looks at + // the set the moment its wait resolves cannot see a send that has already finished. + outstanding.delete(entry); } })(); + entry = send; outstanding.add(send); - void send.then(() => outstanding.delete(send)); return send; }; @@ -247,6 +253,13 @@ export function createFirewallLogReporter(opts) { // drain is still running, and must not start a second drain behind it. if (drainPromise) return drainPromise; stopped = true; + // Before anything else. An armed interval would otherwise fire mid-drain, take the queued records + // for itself, and start a send the drain never learns about — leaving the drain to look at an empty + // queue, conclude it is finished, and resolve with that send still open. + if (timer) { + clearTimeout(timer); + timer = null; + } /** * End the drain, rather than merely stop waiting for it. @@ -277,11 +290,19 @@ export function createFirewallLogReporter(opts) { }); const work = (async () => { - // Sends already started, whose batches have left the queue and so cannot be found by looking at - // it, then the queue itself a batch at a time. The loop stops as soon as a pass cannot shrink the - // queue — with no usable transport there is nothing to wait for, and spinning would be worse. - await Promise.all([...outstanding]); - while (!ended && queue.length > 0) { + // Two things can be outstanding and each can produce the other: a send holds records that have + // left the queue, and the queue holds records that will become a send. So this asks again after + // every wait rather than taking one snapshot — a snapshot resolves as soon as the sends it + // happened to capture are done, whatever appeared in the meantime. + // + // It ends when both are empty, when a pass cannot shrink the queue (with no usable transport + // there is nothing to wait for, and spinning would be worse), or when the budget ends it. + while (!ended) { + if (outstanding.size > 0) { + await Promise.all([...outstanding]); + continue; + } + if (queue.length === 0) break; const before = queue.length; await flush(); if (queue.length >= before) break; diff --git a/tests/protect/firewall-log.test.ts b/tests/protect/firewall-log.test.ts index 37a61365..f14bdc58 100644 --- a/tests/protect/firewall-log.test.ts +++ b/tests/protect/firewall-log.test.ts @@ -203,8 +203,11 @@ describe('stopping the block log waits for what is outstanding, and is bounded', flushMs: 1, ...over, }); - const record = (r: any, n = 1) => { - for (let i = 0; i < n; i++) r.record({ rule: { id: `r${i}` }, method: 'GET', path: '/a', ip: '1.2.3.4' }); + let recorded = 0; + const record = (r: any, n = 1, tag = 'r') => { + for (let i = 0; i < n; i++) { + r.record({ rule: { id: `${tag}${i}` }, method: 'GET', path: `/a${recorded++}`, ip: '1.2.3.4' }); + } }; const tick = async () => { await new Promise((r) => setTimeout(r, 5)); }; @@ -317,6 +320,60 @@ describe('stopping the block log waits for what is outstanding, and is bounded', expect(aborted, `${phase} was let go of`).toContain(phase); }); + it('does not resolve while records buffered behind an armed interval are still going out', async () => { + vi.useFakeTimers(); + // Send A is open. Records B sit in the queue with the flush interval armed behind them. A drain that + // waited on the sends it happened to find at the start would resolve the moment A finished — while + // the interval had quietly taken B and started a send of its own. + const posts: Array<{ release: (r: Response) => void; body: string }> = []; + const impl = vi.fn((url: string, init?: RequestInit) => { + if (String(url).includes('/oauth/token')) { + return Promise.resolve( + new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + + return new Promise((resolve) => { + posts.push({ release: resolve, body: String(init?.body ?? '') }); + }); + }); + // Short enough that the interval fires during the drain. + const r: any = reporter(impl, { flushMs: 10 }); + + record(r, 50, 'a'); // A: reaches the batch bound and starts a send + await vi.advanceTimersByTimeAsync(1); + record(r, 5, 'b'); // B: queued, with the interval now armed behind them + expect(posts.length, 'A is in flight').toBe(1); + + let settled = false; + const done = r.stop().then(() => { settled = true; }); + + // The interval's moment passes FIRST, while A is still open — that is the whole race. An interval + // left armed takes B here, starting a send the drain never learns about. + await vi.advanceTimersByTimeAsync(50); + + // Nothing took B: from the moment a shutdown begins, the drain owns the queue. Were the interval + // still armed it would have started B's send here, outside the drain's knowledge. + expect(posts.length, 'the interval did not take B').toBe(1); + + // A finishes. A drain waiting on the sends it found at the start is now satisfied, and would resolve + // with B's send open. + posts[0].release(new Response('{}', { status: 200 })); + await vi.advanceTimersByTimeAsync(5); + + expect(posts.length, "B's post has started").toBe(2); + expect(settled, 'and the shutdown is still waiting for it').toBe(false); + expect(posts[1].body, 'B is what is being sent').toContain('b0'); + + posts[1].release(new Response('{}', { status: 200 })); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(settled).toBe(true); + }); + it('runs nothing more once the shutdown has given up', async () => { vi.useFakeTimers(); let releaseToken: ((r: Response) => void) | null = null; From 615311c01b4e4326180effec8289e3483bea3a46 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 12:59:21 +0200 Subject: [PATCH 23/33] Derive what a rule permits to be captured, and capture nothing yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A detection says a rule matched. That is enough to count hits and not enough to act on: whoever triages it still has to decide whether the request was really an attack, and for that they need to see what the rule saw. It is also the point where a security channel could quietly become a copy of an application's traffic. So capture is not a switch. What may be captured is derived from the rule, and a rule earns each permission by naming what it reads. A named parameter permits its own value, because the rule was written to inspect it. A prefix permits the keys that match it and no others, bounded by a count, so that a rule reading a prefix does not become a rule reading the whole body. `raw` and `all` permit nothing: they read the entire request, so deriving permission from them would have the broadest rules granting the broadest capture. Response sources permit nothing either — the phase that reads them exists to redact secrets, and capturing them would collect the values that redaction is there to stop leaving. Raw request bytes need an explicit opt-in on the rule, bounded regardless of what the rule asks for. A plan covers the union of everything its rule reads, because the engine reports which rule matched and not which of its conditions did; a plan claiming clause-level precision would be claiming a precision the detection does not have. It is derived from the immutable revision and cached against it, and carries a content-derived reference, so a captured value can be traced to the policy that permitted it rather than to whatever the rule says by the time someone looks. An unreadable rule permits nothing, in every direction tested: failing to understand a rule must never be the reason something gets captured. Nothing is wired. This module is imported nowhere, no value is captured, and the payload is unchanged — the policy is reviewable before anything can act on it. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/capture-plan.js | 188 +++++++++++++++++++++++++ tests/protect/capture-plan.test.ts | 217 +++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 src/protect/capture-plan.js create mode 100644 tests/protect/capture-plan.test.ts diff --git a/src/protect/capture-plan.js b/src/protect/capture-plan.js new file mode 100644 index 00000000..e66267e6 --- /dev/null +++ b/src/protect/capture-plan.js @@ -0,0 +1,188 @@ +/** + * What a rule permits to be captured as evidence — derived from the rule, never from the traffic. + * + * A detection says a rule matched. On its own that is enough to count hits, and not enough to act on: + * whoever triages it still has to decide whether the request was really an attack, and for that they need + * to see what the rule saw. Capturing that is the difference between a counter and evidence. + * + * It is also the point where a security channel could quietly become a copy of an application's traffic. + * So capture is not a switch. What may be captured is DERIVED FROM THE RULE, and a rule earns each + * permission by naming what it reads: + * + * - A rule naming a parameter (`post.title`, `cookie.session`, `server.HTTP_AUTHORIZATION`) permits that + * parameter's value. The rule was written to inspect it, so its value is what the finding is about. + * - A prefix (`post.field_*`) permits the values of keys that match, and no others — bounded by a count, + * because a prefix can match an unbounded number of keys and "a rule that reads a prefix" must not + * become "a rule that reads the whole body". + * - `raw` and `all` permit NOTHING. They read the entire request, so deriving a permission from them + * would derive permission for everything — the broadest rules granting the broadest capture, which is + * exactly backwards. + * - Raw request bytes are capturable only when the rule carries an explicit, reviewed `capture` opt-in. + * That is a decision someone makes about one rule, not a consequence of how the rule happens to match. + * + * Two properties matter as much as the policy itself. + * + * **It is the rule's, not the clause's.** The engine reports which RULE matched, not which of its + * conditions did, so a plan covers the union of everything the rule reads. Narrowing to the matching + * clause would mean changing evaluation to report one, and a plan that claimed clause-level precision it + * did not have would be worse than one that says what it is. + * + * **It is fixed to a revision.** The plan is derived from the immutable rule revision and cached against + * it, and the event records which plan governed it. A rule that changes gets a new revision and a new + * plan, so a captured value can always be traced to the policy that permitted it — rather than to + * whatever the rule says by the time anyone looks. + */ + +/** Sources whose named values a rule may capture by naming them. */ +const CAPTURABLE = new Set(['get', 'post', 'request', 'cookie', 'server', 'files', 'egress']); + +/** + * `response.*` is deliberately absent. + * + * Those read what the application is about to send, so they are the app's own output rather than + * something a client submitted — the phase that inspects them exists to REDACT secrets, and a channel + * that captured them would collect the very values the redaction is there to stop leaving. + */ +const NEVER_CAPTURABLE = new Set(['response']); + +/** Bounds every plan carries, so a permission cannot become an unbounded one. */ +export const CAPTURE_LIMITS = { + /** Values in one event, across every permission it holds. */ + values: 10, + /** Characters of any single captured value. */ + valueChars: 512, + /** Keys one prefix permission may match. */ + prefixKeys: 5, +}; + +/** The parameters a rule reads, as the union over its conditions and nested groups. */ +function parametersOf(rule) { + const out = new Set(); + const walk = (conditions, depth) => { + if (!Array.isArray(conditions) || depth > 20) return; + for (const condition of conditions) { + if (!condition || typeof condition !== 'object') continue; + if (typeof condition.parameter === 'string' && condition.parameter !== 'rules') { + out.add(condition.parameter); + } + if (Array.isArray(condition.rules)) walk(condition.rules, depth + 1); + } + }; + walk(rule?.rule_v2, 0); + + return [...out]; +} + +/** + * The raw-body opt-in a rule carries, if it carries a valid one. + * + * Only ever a prefix of the body, never the whole of it, and only up to a reviewed number of characters. + * A rule asking for more than the cap gets the cap rather than what it asked for: the opt-in says a + * reviewer agreed raw bytes are needed here, not that this rule sets its own bounds. + */ +function rawOptIn(rule) { + const requested = Number(rule?.capture?.raw_chars); + if (!Number.isFinite(requested) || requested <= 0) return null; + + return { chars: Math.min(Math.floor(requested), CAPTURE_LIMITS.valueChars) }; +} + +/** + * Derive what may be captured for a rule. + * + * Pure, and total: an unreadable rule yields a plan that permits nothing, because the failure to + * understand a rule must never be the reason something gets captured. + */ +export function derivePlan(rule) { + /** @type {Set} */ + const named = new Set(); + /** @type {Set} */ + const prefixes = new Set(); + + for (const parameter of parametersOf(rule)) { + // `raw` and `all` read everything, so they permit nothing. + const dot = parameter.indexOf('.'); + if (dot === -1) continue; + + const source = parameter.slice(0, dot); + const key = parameter.slice(dot + 1); + if (!CAPTURABLE.has(source) || NEVER_CAPTURABLE.has(source) || key === '') continue; + + if (key.endsWith('*')) { + const prefix = key.slice(0, -1); + // A bare `source.*` is `all` wearing a different hat: it names nothing, so it permits nothing. + if (prefix !== '') prefixes.add(`${source}.${prefix}`); + continue; + } + named.add(`${source}.${key}`); + } + + return { + named: [...named].sort(), + prefixes: [...prefixes].sort(), + raw: rawOptIn(rule), + limits: { ...CAPTURE_LIMITS }, + }; +} + +/** Whether a plan permits anything at all — the common case is that it does not. */ +export function permitsAnything(plan) { + return plan.named.length > 0 || plan.prefixes.length > 0 || plan.raw !== null; +} + +/** + * A short, stable reference for a plan, recorded on every event the plan governed. + * + * Content-derived rather than a counter, so the same policy has the same reference across processes and + * releases, and two events carrying the same reference were really governed by the same permissions. A + * reader can then ask what a capture was allowed to include without needing the rule in front of them. + */ +export function planReference(plan) { + // Sorted here as well as in `derivePlan`: this is the value that ties a captured value to the policy + // that permitted it, so the tie must not rest on an ordering established somewhere else. + const canonical = JSON.stringify([ + [...plan.named].sort(), + [...plan.prefixes].sort(), + plan.raw?.chars ?? 0, + ]); + // FNV-1a: short, dependency-free and stable. Not a security boundary — nothing is authenticated by + // this, it only has to name a plan consistently. + let hash = 0x811c9dc5; + for (let i = 0; i < canonical.length; i++) { + hash ^= canonical.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return `cp1-${hash.toString(16).padStart(8, '0')}`; +} + +/** + * Plans for the rules of one bundle, derived once and reused. + * + * Keyed by the rule's immutable revision where the bundle carried one, so a rule that changes gets a new + * plan rather than an old one that no longer describes it. A rule with no revision is keyed by its own + * derived reference, which is the same thing computed the long way. + */ +export function createPlanCache() { + const byKey = new Map(); + + return { + for(rule) { + const revision = rule?.rule_revision ?? rule?.revision ?? null; + const key = typeof revision === 'string' && revision !== '' ? `r:${revision}` : null; + if (key !== null) { + const hit = byKey.get(key); + if (hit) return hit; + } + + const plan = derivePlan(rule); + const entry = { plan, reference: planReference(plan) }; + if (key !== null) byKey.set(key, entry); + + return entry; + }, + get size() { + return byKey.size; + }, + }; +} diff --git a/tests/protect/capture-plan.test.ts b/tests/protect/capture-plan.test.ts new file mode 100644 index 00000000..c6592070 --- /dev/null +++ b/tests/protect/capture-plan.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { + CAPTURE_LIMITS, + createPlanCache, + derivePlan, + permitsAnything, + planReference, +} from '../../src/protect/capture-plan.js'; + +/** + * What a rule permits to be captured, and — mostly — what it does not. + * + * The interesting direction is refusal. A rule earns each permission by naming what it reads, so the + * cases that matter are the ones where a rule reads broadly and must therefore permit nothing: those are + * where a capture policy quietly turns a security channel into a copy of an application's traffic. + */ +const rule = (parameters: string[], extra: Record = {}) => ({ + id: 'r1', + rule_v2: parameters.map((parameter) => ({ parameter, match: { type: 'contains', value: 'x' } })), + ...extra, +}); + +describe('a rule permits what it names', () => { + it.each([ + 'get.redirect_to', + 'post.title', + 'cookie.session', + 'server.HTTP_AUTHORIZATION', + 'files.avatar', + 'egress.url', + 'request.q', + ])('permits the value of %s, because the rule was written to inspect it', (parameter) => { + expect(derivePlan(rule([parameter])).named).toEqual([parameter]); + }); + + it('takes the union across conditions and nested groups', async () => { + // The engine reports which RULE matched, not which condition, so a plan narrower than the rule would + // claim a precision the detection does not have. + const nested = { + id: 'r1', + rule_v2: [ + { parameter: 'post.title', match: { type: 'contains', value: 'x' } }, + { + parameter: 'rules', + rules: [ + { parameter: 'get.q', match: { type: 'contains', value: 'x' } }, + { parameter: 'cookie.session', match: { type: 'contains', value: 'x' } }, + ], + }, + ], + }; + + expect(derivePlan(nested).named).toEqual(['cookie.session', 'get.q', 'post.title']); + }); + + it('names each parameter once, however often the rule reads it', () => { + expect(derivePlan(rule(['post.title', 'post.title', 'post.title'])).named).toEqual(['post.title']); + }); +}); + +describe('a rule that reads everything permits nothing', () => { + it.each(['raw', 'all'])('derives no permission from %s', (parameter) => { + // These read the whole request. Deriving a permission from them would mean the broadest rules + // granting the broadest capture, which is exactly backwards. + const plan = derivePlan(rule([parameter])); + + expect(plan.named).toEqual([]); + expect(plan.prefixes).toEqual([]); + expect(permitsAnything(plan)).toBe(false); + }); + + it('derives nothing from a bare source wildcard', () => { + // `post.*` names nothing in particular: it is `all` wearing a different hat. + expect(permitsAnything(derivePlan(rule(['post.*'])))).toBe(false); + }); + + it('still permits the parameters a broad rule ALSO names', () => { + // `raw` adds nothing, but it does not poison what the rule names beside it. + expect(derivePlan(rule(['raw', 'post.title'])).named).toEqual(['post.title']); + }); +}); + +describe('a prefix permits matching keys and no others', () => { + it('records the prefix rather than the pattern', () => { + expect(derivePlan(rule(['post.field_*'])).prefixes).toEqual(['post.field_']); + }); + + it('bounds how many keys a prefix may match', () => { + // A prefix can match an unbounded number of keys, and "a rule that reads a prefix" must not become + // "a rule that reads the whole body". + expect(derivePlan(rule(['post.field_*'])).limits.prefixKeys).toBe(CAPTURE_LIMITS.prefixKeys); + expect(CAPTURE_LIMITS.prefixKeys).toBeLessThan(CAPTURE_LIMITS.values + 1); + }); +}); + +describe('the response phase is never a capture source', () => { + it.each(['response.body', 'response.headers', 'response.status'])('refuses %s', (parameter) => { + // Those read what the application is about to SEND. The phase inspecting them exists to redact + // secrets, so capturing them would collect the values that redaction is there to stop leaving. + expect(permitsAnything(derivePlan(rule([parameter])))).toBe(false); + }); +}); + +describe('raw bytes need an explicit opt-in, and are bounded anyway', () => { + it('permits nothing without one', () => { + expect(derivePlan(rule(['post.title'])).raw).toBeNull(); + }); + + it('permits a bounded prefix when a rule carries one', () => { + expect(derivePlan(rule(['raw'], { capture: { raw_chars: 128 } })).raw).toEqual({ chars: 128 }); + }); + + it('gives a rule the cap rather than what it asked for', () => { + // The opt-in says a reviewer agreed raw bytes are needed here, not that this rule sets its own + // bounds. + expect(derivePlan(rule(['raw'], { capture: { raw_chars: 10_000 } })).raw).toEqual({ + chars: CAPTURE_LIMITS.valueChars, + }); + }); + + it.each([ + ['a negative request', -1], + ['zero', 0], + ['text', 'lots'], + ['nothing', undefined], + ])('refuses %s', (_what, raw_chars) => { + expect(derivePlan(rule(['raw'], { capture: { raw_chars } })).raw).toBeNull(); + }); +}); + +describe('an unreadable rule permits nothing', () => { + it.each([ + ['no rule at all', undefined], + ['null', null], + ['a rule with no conditions', { id: 'r1' }], + ['conditions that are not a list', { id: 'r1', rule_v2: 'nonsense' }], + ['conditions that are not objects', { id: 'r1', rule_v2: [null, 3, 'x'] }], + ['a parameter that is not a string', { id: 'r1', rule_v2: [{ parameter: 7 }] }], + ['a source nobody defines', { id: 'r1', rule_v2: [{ parameter: 'invented.thing' }] }], + // `all` and `raw` are whole-request reads, not sources with keys. Nothing resolves these, so a plan + // that granted them would be granting capture for a parameter the rule cannot even read. + ['a key hung off all', { id: 'r1', rule_v2: [{ parameter: 'all.x' }] }], + ['a key hung off raw', { id: 'r1', rule_v2: [{ parameter: 'raw.x' }] }], + ['a source with no key', { id: 'r1', rule_v2: [{ parameter: 'post.' }] }], + ])('permits nothing for %s', (_what, input) => { + // Failing to understand a rule must never be the reason something gets captured. + const plan = derivePlan(input as never); + + expect(permitsAnything(plan)).toBe(false); + expect(plan.named).toEqual([]); + }); + + it('does not recurse without bound on a self-referencing group', () => { + const loop: any = { id: 'r1', rule_v2: [{ parameter: 'rules', rules: [] }] }; + loop.rule_v2[0].rules.push(loop.rule_v2[0]); + + expect(() => derivePlan(loop)).not.toThrow(); + }); +}); + +describe('a plan reference names the policy, not the moment', () => { + it('is the same for the same permissions', () => { + // Two events carrying one reference were governed by the same permissions, across processes and + // releases — otherwise a reader cannot tell what a capture was allowed to include. + expect(planReference(derivePlan(rule(['post.title', 'get.q'])))).toBe( + planReference(derivePlan(rule(['get.q', 'post.title']))), + ); + }); + + it('does not depend on the order the permissions are listed in', () => { + // `derivePlan` sorts, so this cannot arise from it today — but the reference is what ties a captured + // value to the policy that allowed it, and that tie must not rest on an ordering somewhere else. + const limits = { values: 10, valueChars: 512, prefixKeys: 5 }; + const one = { named: ['get.q', 'post.title'], prefixes: ['post.a.', 'post.b.'], raw: null, limits }; + const other = { named: ['post.title', 'get.q'], prefixes: ['post.b.', 'post.a.'], raw: null, limits }; + + expect(planReference(other)).toBe(planReference(one)); + }); + + it('differs when the permissions differ', () => { + const reads = planReference(derivePlan(rule(['post.title']))); + + expect(planReference(derivePlan(rule(['post.body'])))).not.toBe(reads); + expect(planReference(derivePlan(rule(['post.title', 'get.q'])))).not.toBe(reads); + expect(planReference(derivePlan(rule(['post.title'], { capture: { raw_chars: 64 } })))).not.toBe(reads); + }); +}); + +describe('plans are derived once per revision', () => { + it('reuses the plan for a revision it has seen', () => { + const cache = createPlanCache(); + const r = { ...rule(['post.title']), rule_revision: 'rev-1' }; + + expect(cache.for(r).plan).toBe(cache.for(r).plan); + expect(cache.size).toBe(1); + }); + + it('derives again when the revision changes', () => { + // A rule that changes gets a new revision, so a captured value can always be traced to the policy + // that permitted it rather than to whatever the rule says by the time someone looks. + const cache = createPlanCache(); + const first = cache.for({ ...rule(['post.title']), rule_revision: 'rev-1' }); + const second = cache.for({ ...rule(['post.title', 'cookie.session']), rule_revision: 'rev-2' }); + + expect(second.plan).not.toBe(first.plan); + expect(second.reference).not.toBe(first.reference); + expect(cache.size).toBe(2); + }); + + it('does not cache a rule the bundle gave no revision', () => { + // Nothing identifies it, so a cache entry would answer for a rule that had since changed. + const cache = createPlanCache(); + cache.for(rule(['post.title'])); + + expect(cache.size).toBe(0); + }); +}); From 554bf7d376144ce4ff00f96f6422dc6b61123d6a Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 14:23:44 +0200 Subject: [PATCH 24/33] Take capture permissions from the rule contract, and publish the opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract version 2.8. The contract now describes `capture`, so a consumer holding an earlier copy holds a different document and the version says so. `capture` is a rule property the contract defines, validates and publishes as structure rather than prose: the keys it takes, that `version` is required and must be one this guard implements, that `raw_chars` is a required positive integer, and that no other key is accepted. A consumer implements against that instead of reproducing the check in its own language. The ceiling on `raw_chars` is published beside the schema, not inside it. Asking for more than the runtime delivers is a request rather than an authoring error, so the schema accepts any positive integer and the runtime answers with the ceiling; a schema maximum would have a consumer refuse a rule this guard accepts and runs. Capture validity governs collection and never protection. A rule is a mitigation, so a `capture` this guard cannot read grants no capture and leaves the rule running, including when it is authored as null. The properties exempt from the null rule are published, because a consumer has only the artifact and an exception it cannot see is one it cannot honour. Deciding a rule's fate on evidence metadata would let a newer server, by adding a capture version, switch shielding off on every older guard. What a rule permits is derived from the contract's own grammar: `parameterProblem` and `SOURCES` decide what a parameter is, so a permission cannot exist for something the engine cannot read, and there is no second grammar to keep in step. A parameter list is one level deep, which is what the engine expands — a member that is itself a list resolves to nothing, so a rule holding one names parameters and matches on none, and the contract refuses it rather than accepting protection that reads as present. A permission exists to explain a detection, so the question a plan asks first is whether this is a rule the guard would run — the validator's own judgement, minus capture validity. A rule naming a parameter with no match is refused there and authorises nothing here, while a rule matching on the whole request carries no parameter at all and is perfectly able to fire. The plan reference covers the whole plan, limits included, since two plans naming the same parameters under different bounds are different permissions. It is 128 output bits from four FNV-1a lanes: not cryptographic, and not built to resist anyone trying to collide it, but comfortably wide for the accidental case. The algorithm and canonical form are what `cp1-` means, so a vector over a literal plan pins them — literal, so that a future change to the limits gives a different reference under the same algorithm rather than reading as a reason to change the prefix. Plans are keyed on the rule object, because a revision identifies a version of one rule rather than a rule, and two rules sharing one must not share permissions. Plans and the limits are frozen: the reference names a set of permissions, and permissions that can change afterwards leave it naming something else. Still wired nowhere, and still capturing nothing. Co-Authored-By: Claude Opus 5 (1M context) --- rule-contract.json | 30 ++- src/protect/capture-plan.js | 186 ++++++++++---- src/protect/rules/contract.js | 86 ++++++- src/protect/rules/validate.js | 14 +- tests/protect/capture-plan.test.ts | 373 ++++++++++++++++++++++++++-- tests/protect/rule-contract.test.ts | 16 +- 6 files changed, 629 insertions(+), 76 deletions(-) diff --git a/rule-contract.json b/rule-contract.json index 6e629f44..25aac94e 100644 --- a/rule-contract.json +++ b/rule-contract.json @@ -1,6 +1,6 @@ { "$comment": "Generated from src/protect/rules/contract.js by scripts/emit-rule-contract.mjs. Do not edit.", - "version": "2.7", + "version": "2.8", "sources": { "raw": { "keyed": false @@ -359,6 +359,9 @@ "method" ], "null_valued_properties": "refused", + "null_exempt_properties": [ + "capture" + ], "rule_property_shapes": { "max_bytes": "positive-number", "bypass_limit": "boolean", @@ -386,8 +389,31 @@ "set_headers", "remove_headers", "cookie_flags", - "ensure" + "ensure", + "capture" ], + "capture": { + "version": 1, + "required": [ + "version", + "raw_chars" + ], + "additional_properties": false, + "properties": { + "version": { + "type": "integer", + "const": 1 + }, + "raw_chars": { + "type": "integer", + "minimum": 1 + } + }, + "raw_chars_effective_maximum": 512, + "unknown_version": "grants no capture; the rule still applies", + "unreadable": "grants no capture; the rule still applies", + "raw_chars_note": "a request for a bounded PREFIX of the body; more than the effective maximum yields the maximum" + }, "limits": { "maxRules": 5000, "maxWhitelists": 2000, diff --git a/src/protect/capture-plan.js b/src/protect/capture-plan.js index e66267e6..a83bd9c8 100644 --- a/src/protect/capture-plan.js +++ b/src/protect/capture-plan.js @@ -33,11 +33,16 @@ * whatever the rule says by the time anyone looks. */ -/** Sources whose named values a rule may capture by naming them. */ -const CAPTURABLE = new Set(['get', 'post', 'request', 'cookie', 'server', 'files', 'egress']); +import { + CAPTURE_RAW_CHARS_MAX, + SOURCES, + captureProblem, + parameterProblem, +} from './rules/contract.js'; +import { enforceableRuleProblem } from './rules/validate.js'; /** - * `response.*` is deliberately absent. + * `response.*` is the one keyed source a rule can never capture from. * * Those read what the application is about to send, so they are the app's own output rather than * something a client submitted — the phase that inspects them exists to REDACT secrets, and a channel @@ -46,25 +51,38 @@ const CAPTURABLE = new Set(['get', 'post', 'request', 'cookie', 'server', 'files const NEVER_CAPTURABLE = new Set(['response']); /** Bounds every plan carries, so a permission cannot become an unbounded one. */ -export const CAPTURE_LIMITS = { +export const CAPTURE_LIMITS = Object.freeze({ /** Values in one event, across every permission it holds. */ values: 10, /** Characters of any single captured value. */ valueChars: 512, /** Keys one prefix permission may match. */ prefixKeys: 5, -}; +}); /** The parameters a rule reads, as the union over its conditions and nested groups. */ function parametersOf(rule) { const out = new Set(); + // A condition may name one parameter or a list of them, and the engine reads every member. A walker + // that saw only the string form would derive an empty plan from a rule that reads a dozen fields. + const add = (parameter) => { + if (typeof parameter === 'string' && parameter !== 'rules') out.add(parameter); + }; + const collect = (parameter) => { + // One level, because the engine expands one level. A nested list resolves to nothing there, so + // flattening it here would grant a permission for a parameter no match can read. + if (Array.isArray(parameter)) { + for (const member of parameter) add(member); + + return; + } + add(parameter); + }; const walk = (conditions, depth) => { if (!Array.isArray(conditions) || depth > 20) return; for (const condition of conditions) { if (!condition || typeof condition !== 'object') continue; - if (typeof condition.parameter === 'string' && condition.parameter !== 'rules') { - out.add(condition.parameter); - } + collect(condition.parameter); if (Array.isArray(condition.rules)) walk(condition.rules, depth + 1); } }; @@ -81,10 +99,18 @@ function parametersOf(rule) { * reviewer agreed raw bytes are needed here, not that this rule sets its own bounds. */ function rawOptIn(rule) { - const requested = Number(rule?.capture?.raw_chars); - if (!Number.isFinite(requested) || requested <= 0) return null; + // The rule's OWN property, validated by the contract. A `capture` reachable through a polluted + // prototype belongs to no rule, and would grant raw capture to every rule at once; a value coerced + // into a number — a string, a boolean, a one-element array — is not an opt-in anyone reviewed. + if (!rule || typeof rule !== 'object' || !Object.hasOwn(rule, 'capture')) return null; + + const capture = rule.capture; + // `captureProblem` reads an absent capture as "no opt-in", which is the default and not a problem — so + // the shape is confirmed here before anything is read off it. + if (capture === null || typeof capture !== 'object') return null; + if (captureProblem(capture) !== null) return null; - return { chars: Math.min(Math.floor(requested), CAPTURE_LIMITS.valueChars) }; + return { chars: Math.min(capture.raw_chars, CAPTURE_RAW_CHARS_MAX) }; } /** @@ -93,20 +119,41 @@ function rawOptIn(rule) { * Pure, and total: an unreadable rule yields a plan that permits nothing, because the failure to * understand a rule must never be the reason something gets captured. */ +const NOTHING = Object.freeze({ + named: Object.freeze([]), + prefixes: Object.freeze([]), + raw: null, + limits: CAPTURE_LIMITS, +}); + export function derivePlan(rule) { /** @type {Set} */ const named = new Set(); /** @type {Set} */ const prefixes = new Set(); - for (const parameter of parametersOf(rule)) { - // `raw` and `all` read everything, so they permit nothing. + // A permission exists to explain a detection, and a rule the guard would not run produces none. So the + // question is whether this rule is one the validator accepts — the same judgement that decides whether + // it protects anything — rather than whether its parameters happen to be spelled correctly. A rule with + // a parameter and no match is refused there and authorises nothing here, and a rule matching on the + // whole request carries no parameter at all yet is perfectly able to fire. + // + // Capture validity is deliberately not part of that judgement: it governs collection, never protection. + if (enforceableRuleProblem(rule) !== null) return NOTHING; + + // The contract decides what is a parameter at all. Judging that here would be a second grammar to keep + // in step with the engine's, and the two drifting apart means authorising capture of something no rule + // can even read — `server.HTTP_*` and `egress.anything` are refused there, not here. + const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null); + + for (const parameter of parameters) { const dot = parameter.indexOf('.'); + // A keyless source reads the whole request, so it names nothing to capture. if (dot === -1) continue; const source = parameter.slice(0, dot); const key = parameter.slice(dot + 1); - if (!CAPTURABLE.has(source) || NEVER_CAPTURABLE.has(source) || key === '') continue; + if (SOURCES[source]?.keyed !== true || NEVER_CAPTURABLE.has(source)) continue; if (key.endsWith('*')) { const prefix = key.slice(0, -1); @@ -117,12 +164,16 @@ export function derivePlan(rule) { named.add(`${source}.${key}`); } - return { - named: [...named].sort(), - prefixes: [...prefixes].sort(), - raw: rawOptIn(rule), - limits: { ...CAPTURE_LIMITS }, - }; + const raw = rawOptIn(rule); + + // Frozen: the reference below identifies a set of permissions, so a plan that could be edited after + // its reference was computed would leave the reference naming permissions that no longer apply. + return Object.freeze({ + named: Object.freeze([...named].sort()), + prefixes: Object.freeze([...prefixes].sort()), + raw: raw === null ? null : Object.freeze(raw), + limits: CAPTURE_LIMITS, + }); } /** Whether a plan permits anything at all — the common case is that it does not. */ @@ -131,58 +182,95 @@ export function permitsAnything(plan) { } /** - * A short, stable reference for a plan, recorded on every event the plan governed. + * A stable reference for a plan, recorded on every event the plan governed. * - * Content-derived rather than a counter, so the same policy has the same reference across processes and - * releases, and two events carrying the same reference were really governed by the same permissions. A - * reader can then ask what a capture was allowed to include without needing the rule in front of them. + * Content-derived rather than a counter, so the same permissions have the same reference across + * processes and releases, and two events carrying one reference really were governed by the same + * permissions. A reader can then ask what a capture was allowed to include without the rule in front of + * them. + * + * It covers the WHOLE plan, limits included. Two plans naming the same parameters but allowing 512 and + * 4096 characters are different permissions, and a reference that could not tell them apart would be + * making exactly the claim it exists to support. + * + * Not a security boundary. Nothing is authenticated by it, and it is not built to resist anyone trying + * to collide it: four FNV-1a lanes with distinct bases give 128 output bits from a non-cryptographic + * function, chosen because it needs no dependency and no runtime API an edge target may lack. What it is + * built for is accidental collision between the small number of plans a bundle produces, which that is + * comfortably wide enough for. + * + * The algorithm and the canonical form are part of what `cp1-` means. Changing either changes what every + * existing reference refers to, so it takes a new prefix rather than a new implementation under the old + * one — a pinned vector in the tests is what makes that a decision instead of an accident. */ +const LANES = Object.freeze([0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b]); + export function planReference(plan) { // Sorted here as well as in `derivePlan`: this is the value that ties a captured value to the policy // that permitted it, so the tie must not rest on an ordering established somewhere else. + const limits = plan?.limits ?? {}; const canonical = JSON.stringify([ - [...plan.named].sort(), - [...plan.prefixes].sort(), - plan.raw?.chars ?? 0, + [...(plan?.named ?? [])].sort(), + [...(plan?.prefixes ?? [])].sort(), + plan?.raw?.chars ?? 0, + Object.entries(limits) + .map(([key, value]) => [key, value]) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), ]); - // FNV-1a: short, dependency-free and stable. Not a security boundary — nothing is authenticated by - // this, it only has to name a plan consistently. - let hash = 0x811c9dc5; - for (let i = 0; i < canonical.length; i++) { - hash ^= canonical.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return `cp1-${hash.toString(16).padStart(8, '0')}`; + const digest = LANES.map((base) => { + let hash = base >>> 0; + for (let i = 0; i < canonical.length; i++) { + hash ^= canonical.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash.toString(16).padStart(8, '0'); + }).join(''); + + return `cp1-${digest}`; } /** - * Plans for the rules of one bundle, derived once and reused. + * Plans for the rules in play, derived once and reused. + * + * Keyed on the rule OBJECT, not on a revision string. A revision identifies a version of one rule, not a + * rule — two rules can carry the same revision, and a cache keyed on that alone would answer for the + * second with the first's permissions, capturing a field the second rule never authorised. Object + * identity cannot make that mistake, and a bundle that is re-fetched brings new objects, so a changed + * rule is derived again rather than answered from a stale entry. * - * Keyed by the rule's immutable revision where the bundle carried one, so a rule that changes gets a new - * plan rather than an old one that no longer describes it. A rule with no revision is keyed by its own - * derived reference, which is the same thing computed the long way. + * A `WeakMap` also means an entry lives exactly as long as the rule it describes. */ export function createPlanCache() { - const byKey = new Map(); + const byRule = new WeakMap(); + let derivations = 0; return { for(rule) { - const revision = rule?.rule_revision ?? rule?.revision ?? null; - const key = typeof revision === 'string' && revision !== '' ? `r:${revision}` : null; - if (key !== null) { - const hit = byKey.get(key); - if (hit) return hit; + if (rule === null || typeof rule !== 'object') { + // Nothing to key on, and nothing to cache: derive an empty plan and let the caller carry on. + const plan = derivePlan(rule); + + derivations += 1; + + return Object.freeze({ plan, reference: planReference(plan) }); } + const hit = byRule.get(rule); + if (hit) return hit; + const plan = derivePlan(rule); - const entry = { plan, reference: planReference(plan) }; - if (key !== null) byKey.set(key, entry); + derivations += 1; + // Frozen with the plan, so the reference and the permissions it names cannot come apart. + const entry = Object.freeze({ plan, reference: planReference(plan) }); + byRule.set(rule, entry); return entry; }, - get size() { - return byKey.size; + /** How many plans have been derived — the observable difference between a hit and a miss. */ + get derivations() { + return derivations; }, }; } diff --git a/src/protect/rules/contract.js b/src/protect/rules/contract.js index 4098ae1a..cec9c830 100644 --- a/src/protect/rules/contract.js +++ b/src/protect/rules/contract.js @@ -12,7 +12,7 @@ // `rule-contract.json` is the published form. `tests/protect/rule-contract.test.ts` reads the engine's own // source and asserts these descriptions match what it implements. -export const CONTRACT_VERSION = '2.7'; +export const CONTRACT_VERSION = '2.8'; /** * Every parameter source, and what it accepts after the dot. @@ -354,8 +354,60 @@ export const RULE_PROPERTIES = Object.freeze([ 'message', 'enforcement', 'source_revision', 'prefilter', 'max_bytes', 'bypass_limit', 'set_headers', 'remove_headers', 'cookie_flags', 'ensure', + 'capture', ]); +/** + * The reviewed opt-in that lets one rule's evidence include raw request bytes. + * + * Versioned, because it authorises collection: a guard that met a `capture` it did not understand and + * guessed would be guessing about what may be gathered from an application. An unrecognised version + * grants nothing, which is the only safe direction — a newer server can then extend this without an + * older guard quietly capturing under rules it cannot read. + * + * `raw_chars` is a request for a bounded PREFIX of the body, never the whole of it, and the runtime caps + * it regardless of what is asked for. The property says a reviewer agreed raw bytes are needed for this + * rule; it does not let the rule set its own bounds. + */ +export const CAPTURE_VERSION = 1; +export const CAPTURE_KEYS = Object.freeze(['version', 'raw_chars']); +/** The most raw characters any opt-in can obtain, whatever it asks for. Published, so it is agreed. */ +export const CAPTURE_RAW_CHARS_MAX = 512; + +/** + * Properties whose null does NOT refuse the rule. + * + * A null is normally a value that was meant to be something and is not, and refusing it stops a rule + * running on a default nobody chose. These are the exception because they authorise collection rather + * than protection: `capture: null` says "collect nothing", which is already the default and costs no + * shielding. + */ +export const NULL_EXEMPT_PROPERTIES = Object.freeze(['capture']); + +/** + * @returns {string|null} why a `capture` opt-in cannot be honoured as written, or null + */ +export function captureProblem(capture) { + if (capture === undefined || capture === null) return null; + if (typeof capture !== 'object' || Array.isArray(capture)) return 'capture must be an object'; + + for (const key of Object.keys(capture)) { + if (!CAPTURE_KEYS.includes(key)) return `capture has no "${key}" — it takes ${CAPTURE_KEYS.join(', ')}`; + } + if (!Object.hasOwn(capture, 'version')) return 'capture must name the version it was written against'; + if (capture.version !== CAPTURE_VERSION) { + return `capture version ${String(capture.version)} is not one this guard understands`; + } + if (!Object.hasOwn(capture, 'raw_chars')) return 'capture must say what it is opting into'; + + const chars = capture.raw_chars; + if (typeof chars !== 'number' || !Number.isInteger(chars) || chars <= 0) { + return 'capture.raw_chars must be a positive whole number of characters'; + } + + return null; +} + /** * What each action needs, what it defaults, and which phases can carry it out. * @@ -447,6 +499,10 @@ export function parameterProblem(parameter) { if (Array.isArray(parameter)) { if (parameter.length === 0) return 'parameter list is empty'; for (const member of parameter) { + // One level, because the engine expands one level: it resolves each member of the list, and a + // member that is itself a list resolves to nothing. Accepting it would validate a rule that names + // parameters and matches on none of them. + if (Array.isArray(member)) return 'a parameter list holds parameters, not more lists'; const problem = parameterProblem(member); if (problem !== null) return problem; } @@ -598,6 +654,7 @@ export function nullPropertyProblem(rule) { if (!rule || typeof rule !== 'object') return null; for (const property of RULE_PROPERTIES) { + if (NULL_EXEMPT_PROPERTIES.includes(property)) continue; if (property in rule && rule[property] === null) { return `"${property}" is present but null; omit it to mean the default, since a null is a value that ` + 'was meant to be something and is not'; @@ -619,6 +676,12 @@ export function rulePropertyProblem(rule) { if (problem) return `"${property}" ${problem}`; } + // `capture` is deliberately NOT checked here. It authorises collection, and a rule is a mitigation: + // letting a capture value the guard cannot read decide whether the rule runs would turn a question + // about evidence into the loss of the protection itself — a newer server adding a capture version + // would switch off shielding on every older guard. An unreadable capture grants no capture; the rule + // still applies. `captureProblem` is where that is decided, and the plan is its only caller. + // // `enforcement` is deliberately NOT checked here, and this is the one place in the contract where a // value the runtime does not recognise is left alone on purpose. // @@ -697,9 +760,30 @@ export function ruleContract() { ), when_keys: [...WHEN_KEYS], null_valued_properties: NULL_VALUED_PROPERTIES, + // Except these. They authorise collection rather than protection, so a malformed one costs evidence + // and never the mitigation — a consumer deriving the general rule would otherwise refuse a document + // this guard runs. + null_exempt_properties: [...NULL_EXEMPT_PROPERTIES], rule_property_shapes: { ...RULE_PROPERTY_SHAPES }, enforcement_values: [...ENFORCEMENT_VALUES], rule_properties: [...RULE_PROPERTIES], + capture: { + version: CAPTURE_VERSION, + required: ['version', 'raw_chars'], + additional_properties: false, + properties: { + version: { type: 'integer', const: CAPTURE_VERSION }, + raw_chars: { type: 'integer', minimum: 1 }, + }, + // The runtime's ceiling, published apart from the schema on purpose. A schema maximum would have a + // consumer refuse a rule this guard accepts and runs: asking for more than the ceiling is not an + // authoring error, it is a request the runtime answers with the ceiling. + raw_chars_effective_maximum: CAPTURE_RAW_CHARS_MAX, + // What a guard does with a capture it cannot read, stated rather than left to be discovered. + unknown_version: 'grants no capture; the rule still applies', + unreadable: 'grants no capture; the rule still applies', + raw_chars_note: 'a request for a bounded PREFIX of the body; more than the effective maximum yields the maximum', + }, limits: { ...LIMITS }, }; } diff --git a/src/protect/rules/validate.js b/src/protect/rules/validate.js index 0aeaa68e..47d7509c 100644 --- a/src/protect/rules/validate.js +++ b/src/protect/rules/validate.js @@ -51,7 +51,7 @@ export function validateBundle(bundle, opts = {}) { rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` }); continue; } - const reason = ruleProblem(rule); + const reason = enforceableRuleProblem(rule); if (reason) rejected.push({ id: idOf(rule), reason }); else firewall.push(rule); } @@ -85,8 +85,16 @@ function idOf(rule) { return id === undefined || id === null ? '(unidentified)' : String(id); } -/** @returns {string|null} a reason the rule must be dropped, or null when it's acceptable. */ -function ruleProblem(rule) { +/** + * Why a rule would not be run, or null when it would. + * + * Exported because a capture permission depends on it: a permission exists to explain a detection, and a + * rule this refuses produces none. Capture validity is deliberately not part of the answer — it governs + * collection, never protection. + * + * @returns {string|null} a reason the rule must be dropped, or null when it's acceptable. + */ +export function enforceableRuleProblem(rule) { if (!rule || typeof rule !== 'object') return 'not an object'; // Before anything reads a property: a property that is PRESENT and null is not an omission. Every layer diff --git a/tests/protect/capture-plan.test.ts b/tests/protect/capture-plan.test.ts index c6592070..e3b9404e 100644 --- a/tests/protect/capture-plan.test.ts +++ b/tests/protect/capture-plan.test.ts @@ -6,6 +6,8 @@ import { permitsAnything, planReference, } from '../../src/protect/capture-plan.js'; +import { validateBundle } from '../../src/protect/rules/validate.js'; +import { captureProblem, ruleContract } from '../../src/protect/rules/contract.js'; /** * What a rule permits to be captured, and — mostly — what it does not. @@ -14,6 +16,7 @@ import { * cases that matter are the ones where a rule reads broadly and must therefore permit nothing: those are * where a capture policy quietly turns a security channel into a copy of an application's traffic. */ +const leafFor = (parameter: string) => ({ parameter, match: { type: 'contains', value: 'x' } }); const rule = (parameters: string[], extra: Record = {}) => ({ id: 'r1', rule_v2: parameters.map((parameter) => ({ parameter, match: { type: 'contains', value: 'x' } })), @@ -21,6 +24,20 @@ const rule = (parameters: string[], extra: Record = {}) => ({ }); describe('a rule permits what it names', () => { + it('reads every member of a parameter list', () => { + // A condition may name one parameter or a list of them, and the engine reads each. A plan blind to + // the list form would be empty for a rule that reads a dozen fields. + const list = { + id: 'r1', + rule_v2: [{ parameter: ['post.q', 'raw', 'cookie.session'], match: { type: 'contains', value: 'x' } }], + }; + + expect(derivePlan(list).named, 'the named members, and not the whole-request one').toEqual([ + 'cookie.session', + 'post.q', + ]); + }); + it.each([ 'get.redirect_to', 'post.title', @@ -93,6 +110,36 @@ describe('a prefix permits matching keys and no others', () => { }); }); +describe('the contract decides what is a parameter at all', () => { + it.each([ + // `server` and `egress` enumerate their keys and do not fan out, so these are not parameters the + // engine can read — and a permission for something no rule can read is a permission with no rule + // behind it. + 'server.HTTP_*', + 'server.not-real', + 'egress.not-real', + 'egress.*', + 'response.header.*', + ])('derives nothing from %s, which no rule may read', (parameter) => { + expect(permitsAnything(derivePlan(rule([parameter])))).toBe(false); + }); + + it.each([ + 'server.HTTP_AUTHORIZATION', + 'server.REQUEST_URI', + 'egress.url', + 'files.avatar.filename', + // `files` keys are application field names, so the contract accepts any of them — including one + // whose suffix it does not enumerate. + 'files.avatar.invented', + ])( + 'still permits %s, which a rule may read', + (parameter) => { + expect(derivePlan(rule([parameter])).named).toEqual([parameter]); + }, + ); +}); + describe('the response phase is never a capture source', () => { it.each(['response.body', 'response.headers', 'response.status'])('refuses %s', (parameter) => { // Those read what the application is about to SEND. The phase inspecting them exists to redact @@ -107,13 +154,13 @@ describe('raw bytes need an explicit opt-in, and are bounded anyway', () => { }); it('permits a bounded prefix when a rule carries one', () => { - expect(derivePlan(rule(['raw'], { capture: { raw_chars: 128 } })).raw).toEqual({ chars: 128 }); + expect(derivePlan(rule(['raw'], { capture: { version: 1, raw_chars: 128 } })).raw).toEqual({ chars: 128 }); }); it('gives a rule the cap rather than what it asked for', () => { // The opt-in says a reviewer agreed raw bytes are needed here, not that this rule sets its own // bounds. - expect(derivePlan(rule(['raw'], { capture: { raw_chars: 10_000 } })).raw).toEqual({ + expect(derivePlan(rule(['raw'], { capture: { version: 1, raw_chars: 10_000 } })).raw).toEqual({ chars: CAPTURE_LIMITS.valueChars, }); }); @@ -121,10 +168,127 @@ describe('raw bytes need an explicit opt-in, and are bounded anyway', () => { it.each([ ['a negative request', -1], ['zero', 0], + ['a fraction', 12.5], + // Coercion is not consent: none of these is a number a reviewer wrote. + ['a string of digits', '128'], + ['true', true], + ['a one-element array', [128]], ['text', 'lots'], ['nothing', undefined], ])('refuses %s', (_what, raw_chars) => { - expect(derivePlan(rule(['raw'], { capture: { raw_chars } })).raw).toBeNull(); + expect(derivePlan(rule(['raw'], { capture: { version: 1, raw_chars } })).raw).toBeNull(); + }); + + it.each([ + ['no version at all', { raw_chars: 128 }], + // An opt-in that authorises nothing is a property with no reason to exist, and a consumer cannot tell + // it from one that was meant to say something and does not. + ['nothing it is opting into', { version: 1 }], + ['a version this guard does not know', { version: 2, raw_chars: 128 }], + ['a version that is not a number', { version: '1', raw_chars: 128 }], + ['a key the contract does not define', { version: 1, raw_chars: 128, everything: true }], + ['a list instead of an object', [{ version: 1, raw_chars: 128 }]], + ['a string', 'raw'], + ])('refuses an opt-in with %s', (_what, capture) => { + // The opt-in authorises collection, so a guard meeting one it cannot read must grant nothing rather + // than guess. That is also what lets a newer server extend it without an older guard capturing under + // rules it does not understand. + expect(derivePlan(rule(['raw'], { capture })).raw).toBeNull(); + }); + + it('refuses an opt-in the rule does not own', () => { + // One write to a prototype would otherwise grant raw capture to every rule at once. + (Object.prototype as any).capture = { version: 1, raw_chars: 512 }; + try { + const r = rule(['post.title']); + + expect((r as any).capture, 'the chain does offer one').toBeTruthy(); + expect(derivePlan(r).raw, 'but it belongs to no rule').toBeNull(); + } finally { + delete (Object.prototype as any).capture; + } + }); +}); + +describe('a parameter list is one level deep', () => { + const nested = { + id: 'r1', + rule_v2: [{ parameter: [['post.q']], match: { type: 'contains', value: 'x' } }], + }; + + it('is refused by the validator rather than accepted and inert', () => { + // The engine expands one level: a member that is itself a list resolves to nothing. Accepting this + // would pass a rule that names a parameter and matches on none — protection that reads as present. + const { bundle, rejected } = validateBundle({ firewall: [nested], whitelists: [], whitelist_keys: {} }); + + expect(bundle.firewall).toEqual([]); + expect(rejected[0].reason).toMatch(/parameters, not more lists/); + }); + + it('grants no permission for a parameter buried inside one', () => { + expect(permitsAnything(derivePlan(nested as never))).toBe(false); + }); + + it('still reads a flat list, which is what the engine expands', () => { + const flat = { + id: 'r1', + rule_v2: [{ parameter: ['post.q', 'cookie.session'], match: { type: 'contains', value: 'x' } }], + }; + + expect(derivePlan(flat as never).named).toEqual(['cookie.session', 'post.q']); + }); +}); + +describe('a rule that cannot produce a detection permits nothing', () => { + it('grants no raw capture to a rule with no conditions', () => { + // A permission exists to explain a detection. The opt-in is a property ON a rule, not a licence of + // its own, so a rule the engine cannot read authorises nothing however the opt-in is written. + const plan = derivePlan({ id: 'r1', capture: { version: 1, raw_chars: 128 } } as never); + + expect(plan.raw).toBeNull(); + expect(permitsAnything(plan)).toBe(false); + }); + + it.each([ + ['conditions that are not a list', { id: 'r1', rule_v2: 'nonsense' }], + ['an empty condition list', { id: 'r1', rule_v2: [] }], + ['only parameters no rule may read', { id: 'r1', rule_v2: [{ parameter: 'server.HTTP_*' }] }], + // Spelled correctly and still not a rule: the guard refuses it, so it produces no detection to + // explain. A permission derived from spelling alone would outlive the rule that justified it. + ['a parameter and no match', { id: 'r1', rule_v2: [{ parameter: 'post.q' }] }], + [ + 'a match the engine does not have', + { id: 'r1', rule_v2: [{ parameter: 'post.q', match: { type: 'invented', value: 'x' } }] }, + ], + ['a phase that does not exist', { id: 'r1', rule_v2: [leafFor('post.q')], phase: 'sideways' }], + ])('grants nothing to a rule with %s', (_what, shape) => { + const plan = derivePlan({ ...shape, capture: { version: 1, raw_chars: 128 } } as never); + + expect(plan.raw, 'no raw permission').toBeNull(); + expect(permitsAnything(plan), 'and no named permission either').toBe(false); + }); + + it('grants capture to a rule that matches on the whole request and names no parameter', () => { + // The positive control the validator gate needs. `cross_origin` and its kin read the whole request + // and carry no parameter, so a gate asking for a parameter would deny a perfectly good rule the + // evidence it was opted into. + const wholeRequest = { + id: 'csrf', + rule_v2: [{ match: { type: 'cross_origin', value: '' } }], + capture: { version: 1, raw_chars: 128 }, + }; + + expect(derivePlan(wholeRequest as never).raw, 'it can fire, so it can explain itself').toEqual({ + chars: 128, + }); + }); + + it('still grants it to a rule that reads the raw body', () => { + // The positive control, and the opt-in's actual purpose: a rule matching on `raw` may capture a + // bounded prefix of it. + expect(derivePlan(rule(['raw'], { capture: { version: 1, raw_chars: 128 } })).raw).toEqual({ + chars: 128, + }); }); }); @@ -177,41 +341,210 @@ describe('a plan reference names the policy, not the moment', () => { expect(planReference(other)).toBe(planReference(one)); }); + it('covers the limits, not only the parameters', () => { + // Two plans naming the same parameters but allowing 512 and 4096 characters are different + // permissions. A reference that could not tell them apart would fail at exactly the claim it exists + // to support. + const base = derivePlan(rule(['post.title'])); + const looser = { ...base, limits: { ...base.limits, valueChars: 4096 } }; + const fewer = { ...base, limits: { ...base.limits, values: 1 } }; + + expect(planReference(looser)).not.toBe(planReference(base)); + expect(planReference(fewer)).not.toBe(planReference(base)); + }); + + it('is exactly this, for this literal plan', () => { + // A pinned vector for the ALGORITHM, over a plan written out here rather than derived. Every + // reference already emitted means whatever this algorithm and canonical form produced, so replacing + // either silently would change what all of them refer to while every relative assertion above still + // passed. Changing them deliberately takes a new prefix, not a new implementation under the old one. + // + // The limits are literal too. A future policy change to `CAPTURE_LIMITS` should give a different + // reference under the SAME algorithm, and a vector reading the current limits would call that a + // reason to change the prefix. + const plan = { + named: ['get.q', 'post.title'], + prefixes: [], + raw: null, + limits: { values: 10, valueChars: 512, prefixKeys: 5 }, + }; + + expect(planReference(plan)).toBe('cp1-bf0f418cf2f24752c827a4f8e3b6334a'); + }); + + it('changes when a limit changes, without changing the algorithm', () => { + // The policy moving is not the algorithm moving: the reference follows the permissions, the prefix + // stays put. + const base = { named: ['post.title'], prefixes: [], raw: null, limits: { values: 10, valueChars: 512 } }; + const tighter = { ...base, limits: { values: 10, valueChars: 128 } }; + + expect(planReference(tighter)).not.toBe(planReference(base)); + expect(planReference(tighter).startsWith('cp1-')).toBe(true); + }); + + it('is what derivePlan produces under the limits in force', () => { + // Kept apart from the vector above: this one is allowed to change when the policy does. + const plan = derivePlan(rule(['post.title', 'get.q'])); + + expect(plan.named).toEqual(['get.q', 'post.title']); + expect(plan.limits).toEqual(CAPTURE_LIMITS); + }); + + it('is wide enough to be a durable identity', () => { + // It outlives the process and is compared across systems, so two different plans meeting on one + // reference must not be something a reader has to think about. + const reference = planReference(derivePlan(rule(['post.title']))); + + expect(reference).toMatch(/^cp1-[0-9a-f]{32}$/); + }); + it('differs when the permissions differ', () => { const reads = planReference(derivePlan(rule(['post.title']))); expect(planReference(derivePlan(rule(['post.body'])))).not.toBe(reads); expect(planReference(derivePlan(rule(['post.title', 'get.q'])))).not.toBe(reads); - expect(planReference(derivePlan(rule(['post.title'], { capture: { raw_chars: 64 } })))).not.toBe(reads); + expect(planReference(derivePlan(rule(['post.title'], { capture: { version: 1, raw_chars: 64 } })))).not.toBe(reads); + }); +}); + +describe('a plan cannot change after its reference is computed', () => { + it('is frozen, along with everything it holds', () => { + // The reference identifies a set of permissions. A plan that could be edited afterwards would leave + // the reference naming permissions that no longer apply. + const plan = derivePlan(rule(['post.title'], { capture: { version: 1, raw_chars: 64 } })); + + expect(Object.isFrozen(plan)).toBe(true); + expect(Object.isFrozen(plan.named)).toBe(true); + expect(Object.isFrozen(plan.prefixes)).toBe(true); + expect(Object.isFrozen(plan.raw)).toBe(true); + expect(Object.isFrozen(plan.limits)).toBe(true); + expect(Object.isFrozen(CAPTURE_LIMITS)).toBe(true); }); }); -describe('plans are derived once per revision', () => { - it('reuses the plan for a revision it has seen', () => { +describe('plans are derived once per rule', () => { + it('reuses the plan for a rule it has seen', () => { const cache = createPlanCache(); - const r = { ...rule(['post.title']), rule_revision: 'rev-1' }; + const r = { ...rule(['post.title']), source_revision: 'rev-1' }; expect(cache.for(r).plan).toBe(cache.for(r).plan); - expect(cache.size).toBe(1); + expect(cache.derivations, 'derived once, answered twice').toBe(1); + }); + + it('does not answer for one rule with another rule\'s permissions', () => { + // A revision identifies a version of ONE rule, not a rule. Two rules can carry the same revision, and + // a cache keyed on that alone would capture a field the second rule never authorised. + const cache = createPlanCache(); + const first = { ...rule(['post.title']), id: 'rule-a', source_revision: 'shared-rev' }; + const second = { ...rule(['cookie.session']), id: 'rule-b', source_revision: 'shared-rev' }; + + expect(cache.for(first).plan.named).toEqual(['post.title']); + expect(cache.for(second).plan.named, 'its own permissions, not the first rule\'s').toEqual([ + 'cookie.session', + ]); + }); + + it('derives again for a rule that arrived as a new object', () => { + // A refreshed bundle brings new rule objects, so a changed rule is derived again rather than answered + // from an entry describing what it used to say. + const cache = createPlanCache(); + cache.for({ ...rule(['post.title']), source_revision: 'rev-1' }); + const updated = cache.for({ ...rule(['post.title', 'cookie.session']), source_revision: 'rev-2' }); + + expect(updated.plan.named).toEqual(['cookie.session', 'post.title']); + expect(cache.derivations).toBe(2); }); - it('derives again when the revision changes', () => { - // A rule that changes gets a new revision, so a captured value can always be traced to the policy - // that permitted it rather than to whatever the rule says by the time someone looks. + it('answers for a rule that is not an object at all', () => { const cache = createPlanCache(); - const first = cache.for({ ...rule(['post.title']), rule_revision: 'rev-1' }); - const second = cache.for({ ...rule(['post.title', 'cookie.session']), rule_revision: 'rev-2' }); - expect(second.plan).not.toBe(first.plan); - expect(second.reference).not.toBe(first.reference); - expect(cache.size).toBe(2); + expect(permitsAnything(cache.for(undefined as never).plan)).toBe(false); + expect(cache.for(null as never).reference).toMatch(/^cp1-/); }); - it('does not cache a rule the bundle gave no revision', () => { - // Nothing identifies it, so a cache entry would answer for a rule that had since changed. + it('hands out an entry that cannot be edited', () => { const cache = createPlanCache(); - cache.for(rule(['post.title'])); + const entry = cache.for({ ...rule(['post.title']), source_revision: 'rev-1' }); + + expect(Object.isFrozen(entry)).toBe(true); + expect(Object.isFrozen(entry.plan)).toBe(true); + }); +}); + +describe('capture metadata never costs the mitigation', () => { + const leaf = { parameter: 'get.q', match: { type: 'contains', value: 'x' } }; + const served = (capture: unknown) => ({ + firewall: [{ id: 'r1', title: 'a rule that still has to protect', rule_v2: [leaf], capture }], + whitelists: [], + whitelist_keys: {}, + }); + + it.each([ + ['a version this guard does not know', { version: 2, raw_chars: 128 }], + ['a version that is not a number', { version: '1', raw_chars: 128 }], + ['a key the contract does not define', { version: 1, raw_chars: 128, everything: true }], + ['a malformed size', { version: 1, raw_chars: -5 }], + ['nothing at all', null], + ['a string', 'raw'], + ])('keeps a rule carrying %s, and grants it no capture', (_what, capture) => { + // A rule is a mitigation. Letting a capture value the guard cannot read decide whether the rule runs + // would turn a question about evidence into the loss of the protection — a newer server adding a + // capture version would switch off shielding on every older guard. + const { bundle, rejected } = validateBundle(served(capture)); + + expect(rejected, 'the rule was not dropped').toEqual([]); + expect(bundle.firewall.length, 'the rule still protects').toBe(1); + expect(derivePlan(bundle.firewall[0]).raw, 'and collects nothing').toBeNull(); + }); + + it('grants capture for the opt-in it does understand', () => { + // The positive control: the separation is only meaningful if a valid opt-in still works. + const { bundle, rejected } = validateBundle(served({ version: 1, raw_chars: 128 })); + + expect(rejected).toEqual([]); + expect(bundle.firewall.length).toBe(1); + expect(derivePlan(bundle.firewall[0]).raw).toEqual({ chars: 128 }); + }); +}); + +describe('the published contract and the runtime give the same answer', () => { + const published = ruleContract().capture; + + it('does not publish a maximum a consumer would refuse a working rule over', () => { + // A schema maximum would have a consumer reject what this guard accepts and runs. Asking for more + // than the ceiling is a request, not an authoring error, so the ceiling is published beside the + // schema rather than inside it. + expect(published.properties.raw_chars.maximum, 'no schema maximum').toBeUndefined(); + expect(published.raw_chars_effective_maximum).toBe(CAPTURE_LIMITS.valueChars); + }); + + it('accepts a request above the ceiling and answers with the ceiling', () => { + const asked = published.raw_chars_effective_maximum * 20; + const served = { + firewall: [{ id: 'r1', rule_v2: [leafFor('raw')], capture: { version: 1, raw_chars: asked } }], + whitelists: [], + whitelist_keys: {}, + }; + const { bundle, rejected } = validateBundle(served); + + expect(rejected, 'the contract does not refuse it').toEqual([]); + expect(derivePlan(bundle.firewall[0]).raw, 'and the runtime answers with the ceiling').toEqual({ + chars: published.raw_chars_effective_maximum, + }); + }); + + it('refuses everything the published schema refuses', () => { + // The schema is what a consumer implements against, so the two must agree on rejection too. + expect(published.required).toEqual(['version', 'raw_chars']); + expect(published.additional_properties).toBe(false); + expect(published.properties.version.const).toBe(1); + expect(published.properties.raw_chars.minimum).toBe(1); - expect(cache.size).toBe(0); + expect(captureProblem({ raw_chars: 8 }), 'version is required').not.toBeNull(); + expect(captureProblem({ version: 1 }), 'raw_chars is required').not.toBeNull(); + expect(captureProblem({ version: 2, raw_chars: 8 }), 'version must be the one published').not.toBeNull(); + expect(captureProblem({ version: 1, raw_chars: 0 }), 'below the minimum').not.toBeNull(); + expect(captureProblem({ version: 1, raw_chars: 8, extra: 1 }), 'no other keys').not.toBeNull(); + expect(captureProblem({ version: 1, raw_chars: 8 }), 'and this one is valid').toBeNull(); }); }); diff --git a/tests/protect/rule-contract.test.ts b/tests/protect/rule-contract.test.ts index b7d29760..f792af07 100644 --- a/tests/protect/rule-contract.test.ts +++ b/tests/protect/rule-contract.test.ts @@ -412,7 +412,12 @@ describe('what the delivered-bundle validator does with it', () => { // a quirk of those three fields rather than a rule about documents. It now covers all of them. const leaf = { parameter: 'get.q', match: { type: 'contains', value: 'x' } }; - for (const property of ruleContract().rule_properties) { + const { rule_properties, null_exempt_properties } = ruleContract(); + + for (const property of rule_properties) { + // Read from the contract, not written out here. A consumer has only the artifact, so an exception + // this test knew and the artifact did not would be an exception nobody else could honour. + if (null_exempt_properties.includes(property)) continue; // `rule_v2` last would be overwritten by the spread, so the null goes last and wins for every one. expect(ruleReasonFor({ rule_v2: [leaf], [property]: null }), property) .toMatch(/present but null/); @@ -423,6 +428,15 @@ describe('what the delivered-bundle validator does with it', () => { expect(ruleReasonFor({ rule_v2: [leaf] })).toBeNull(); expect(ruleReasonFor({ phase: 'request', action: 'block', rule_v2: [leaf] })).toBeNull(); + // The other side of the same policy: every exemption the artifact publishes really is exempt, and + // there is at least one — a published list nothing honours would be worse than no list. + expect(null_exempt_properties.length).toBeGreaterThan(0); + for (const property of null_exempt_properties) { + // These authorise collection rather than protection, so a malformed one costs evidence and never + // the mitigation. Dropping the rule would trade a working shield for a piece of metadata. + expect(ruleReasonFor({ rule_v2: [leaf], [property]: null }), property).toBeNull(); + } + // ...and it is a rule about the PROPERTY being present, not about nulls appearing anywhere in the // document. A null inside a condition is judged by the condition's own rules. expect(reasonFor({ parameter: 'get.q', match: { type: 'in_array', value: [1, null] } })) From 84f13e400782add6cd0dcaa03084f905e722deec Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 1 Sep 2026 15:03:49 +0200 Subject: [PATCH 25/33] Read the evidence a plan permits, from the reading the match was decided by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan says what may be captured. This reads it: only the parameters the plan names, in the plan's order, and through the resolver handed in from the match — never one built here. A request can answer differently every time it is asked, so a second reading records a value the rule never saw, and evidence disagreeing with the match it belongs to is worse than no evidence. There is no request and no factory to read one with, so there is nothing here that could diverge; the engine hands out the resolver its decision was made on. Which form of a parameter that is: three exist. The engine normalises a request — URL-decoding, entity-decoding, stripping comments and control characters, collapsing whitespace — and then a condition applies whatever mutations it asks for. This records the middle one, the resolver's answer. So a percent-encoded payload arrives decoded, while a rule's own `base64_decode` is not applied and its subject arrives still encoded. Reproducing what the matcher finally compared would need condition-level evidence: the engine reports which rule fired, not which condition, and this plan deliberately does not model one. Absence and failure are different answers, and the result says which. A field that was not there, a value refused for its type, a parameter whose read threw, and a request nothing could be read from would otherwise arrive alike as "no evidence", letting a reviewer treat incomplete evidence as complete. `unavailable` means no read completed at all — no resolver, or every permitted read threw — which is the opposite conclusion from a request that simply carried none of what the rule reads. One parameter failing among several is partial, not unreadable. The counts carry no content. An empty string is a value. A rule can be written so that its finding IS that a parameter is empty, and an absent field already resolves to no value at all. What a value is, is settled before there is any question of room for it — for named values and for prefix values alike. Otherwise "refused for its type" and "left out by a bound" swap places depending on where a value falls in a request, which misreports why the evidence is short. Every value a bound excludes is counted, not merely the first: one named parameter can resolve to many values, as several files uploaded under one field name do. The bounds are named for what they count. `capturedValues` covers named and prefix values together; raw is bounded by its own opt-in and does not draw on that budget, since making it consume a value slot would have an opt-in silently reduce the named evidence a reviewer needs. `prefixValues` counts resolved values rather than matched keys, because the resolver answers a wildcard with values and a bound on keys is one this cannot enforce. Renaming them changes the canonical form, so the plan reference reads `cp2-`. Still wired nowhere, and still capturing nothing: the payload is unchanged and the disclosure still says no values of any kind, which remains true until this is connected. Co-Authored-By: Claude Opus 5 (1M context) --- src/protect/capture-plan.js | 189 ++++++++++++++- src/protect/engine/engine.js | 7 +- tests/protect/capture-plan.test.ts | 359 ++++++++++++++++++++++++++++- 3 files changed, 535 insertions(+), 20 deletions(-) diff --git a/src/protect/capture-plan.js b/src/protect/capture-plan.js index a83bd9c8..fa80f9c0 100644 --- a/src/protect/capture-plan.js +++ b/src/protect/capture-plan.js @@ -50,14 +50,23 @@ import { enforceableRuleProblem } from './rules/validate.js'; */ const NEVER_CAPTURABLE = new Set(['response']); -/** Bounds every plan carries, so a permission cannot become an unbounded one. */ +/** + * Bounds every plan carries, so a permission cannot become an unbounded one. + * + * Named for what they count. `capturedValues` is the budget for named and prefix captures together; raw + * is NOT drawn from it, because raw is separately opted into and separately bounded by its own + * `raw.chars`, and making it consume a value slot would have a raw opt-in silently reduce the named + * evidence a reviewer needs. `prefixValues` counts resolved VALUES rather than matched keys: the + * resolver answers a wildcard with values, so keys are not available to count and claiming otherwise + * would describe a bound this cannot enforce. + */ export const CAPTURE_LIMITS = Object.freeze({ - /** Values in one event, across every permission it holds. */ - values: 10, + /** Named and prefix values in one event, together. Raw has its own allowance. */ + capturedValues: 10, /** Characters of any single captured value. */ valueChars: 512, - /** Keys one prefix permission may match. */ - prefixKeys: 5, + /** Resolved values one prefix permission may contribute. */ + prefixValues: 5, }); /** The parameters a rule reads, as the union over its conditions and nested groups. */ @@ -199,9 +208,10 @@ export function permitsAnything(plan) { * built for is accidental collision between the small number of plans a bundle produces, which that is * comfortably wide enough for. * - * The algorithm and the canonical form are part of what `cp1-` means. Changing either changes what every - * existing reference refers to, so it takes a new prefix rather than a new implementation under the old - * one — a pinned vector in the tests is what makes that a decision instead of an accident. + * The algorithm and the canonical form are part of what the prefix means. Changing either changes what + * every existing reference refers to, so it takes a new prefix rather than a new implementation under the + * old one — a pinned vector in the tests is what makes that a decision instead of an accident. Renaming a + * limit changes the canonical form, which is why this reads `cp2-`. */ const LANES = Object.freeze([0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b]); @@ -228,7 +238,7 @@ export function planReference(plan) { return hash.toString(16).padStart(8, '0'); }).join(''); - return `cp1-${digest}`; + return `cp2-${digest}`; } /** @@ -274,3 +284,164 @@ export function createPlanCache() { }, }; } + +/** + * The evidence a plan permits, read from one request. + * + * Reads only what the plan names, in the plan's order, and through THE resolver the match was decided by + * — handed in, never built here. Building one would read the request a second time, and a request can + * answer differently twice: a getter, a stream, anything lazy. Evidence that disagrees with the match it + * belongs to is worse than no evidence, so the only reading available here is the one that already + * happened. + * + * **It is the resolved value, which is neither the bytes as sent nor the string the matcher compared.** + * Three forms of a parameter exist. The engine normalises a request first — URL-decoding, HTML-entity + * decoding, stripping comments and control characters, collapsing whitespace — and then applies whatever + * mutations a rule's own condition asks for. This records the middle one, because that is what the + * resolver answers with and reading either of the others would be a second interpretation of the request. + * + * So a percent-encoded payload arrives here decoded, while a rule's `base64_decode` is not applied and its + * subject arrives still encoded. Reproducing the matched subject would need condition-level evidence: the + * engine reports which RULE fired, not which condition, and this plan deliberately does not model one. + * + * Bounds apply, and each reports what it left out: + * + * - a total across named and prefix values, so one detection cannot carry an unbounded amount of an + * application's data + * - a length per value, so one field cannot + * - a number of resolved values per prefix, so a prefix permission stays narrower than the body it sits in + * + * Raw is bounded separately by its own opt-in and does not draw on the value total. + * + * Absence and failure are different answers, and the result distinguishes them. A field that was not + * there, a value refused for its type, a parameter whose read threw, and a request nothing could be read + * from would otherwise all arrive as "no evidence" — letting a reviewer read incomplete evidence as + * complete. None of it is content: they are counts of what did not make it, and why. + * + * `unavailable` means no read completed at all: there was no resolver to read with, or every permitted + * read threw. It is the difference between "this request carried none of what the rule reads" and "this + * request could not be read", which are opposite conclusions from the same empty list. + */ +export function captureValues(plan, resolver) { + const nothing = { values: [], omitted: 0, unsupported: 0, failed: 0, unavailable: false, raw: null }; + if (!plan || !permitsAnything(plan)) return nothing; + if (!resolver || typeof resolver.resolve !== 'function') return { ...nothing, unavailable: true }; + + const limits = plan.limits ?? CAPTURE_LIMITS; + const values = []; + let omitted = 0; + let unsupported = 0; + let failed = 0; + let attempted = 0; + + const read = (parameter) => { + attempted += 1; + try { + const resolved = resolver.resolve(parameter); + + return Array.isArray(resolved) ? resolved : []; + } catch { + // Fail-open for the application: a capture that cannot be taken is not taken. Counted, so that + // "nothing was captured" is not mistaken for "there was nothing to capture". + failed += 1; + + return null; + } + }; + + /** + * What one resolved value is, before anything is decided about room for it. + * + * Classified here rather than inside the recording step, so that "refused for its type" and "left out + * by a bound" cannot swap places depending on where a value happens to fall in a request. + */ + const classify = (value) => { + const text = asText(value); + if (text === null) { + unsupported += 1; + + return null; + } + + return text; + }; + + /** @returns {boolean} whether there was room for it */ + const record = (parameter, text) => { + if (values.length >= limits.capturedValues) { + omitted += 1; + + return false; + } + + const capped = text.length > limits.valueChars; + values.push({ + parameter, + value: capped ? text.slice(0, limits.valueChars) : text, + ...(capped ? { truncated: true } : {}), + }); + + return true; + }; + + for (const parameter of plan.named) { + // Every resolved value is offered, not just up to the first refusal: a parameter can resolve to many + // values, and stopping at the first excess would report one omission where there were several. + for (const value of read(parameter) ?? []) { + const text = classify(value); + if (text !== null) record(parameter, text); + } + } + + for (const prefix of plan.prefixes) { + // The pattern the rule wrote, put back together: the plan holds the prefix, the resolver reads the + // wildcard. Labelled by the pattern rather than by the key it matched, because the resolver answers + // with values and inventing a key here would mean enumerating the request a second way. + const pattern = `${prefix}*`; + let taken = 0; + for (const value of read(pattern) ?? []) { + // Classified before the prefix bound is consulted, for the same reason as above: what a value IS + // does not depend on how many came before it. + const text = classify(value); + if (text === null) continue; + if (taken >= limits.prefixValues) { + omitted += 1; + continue; + } + if (record(pattern, text)) taken += 1; + } + } + + let raw = null; + if (plan.raw !== null) { + const text = asText((read('raw') ?? [])[0]); + if (text !== null) { + const capped = text.length > plan.raw.chars; + raw = { value: capped ? text.slice(0, plan.raw.chars) : text, ...(capped ? { truncated: true } : {}) }; + } + } + + // Nothing was readable, as opposed to nothing being there to read. + const unavailable = attempted > 0 && failed === attempted; + + return { values, omitted, unsupported, failed, unavailable, raw }; +} + +/** + * A value as text, or null when its type is not one a request carries as a value. + * + * An empty string is text. A rule can be written so that its finding IS that a parameter is empty, and + * collapsing that into absence would erase the evidence for exactly those rules — an absent field + * resolves to no value at all, which is already a different answer. + * + * An object is refused rather than serialised, because serialising it would reach past the field the plan + * named into whatever it contains: a permission for `post.profile` is not a permission for everything + * under it. + */ +function asText(value) { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'boolean') return String(value); + + return null; +} diff --git a/src/protect/engine/engine.js b/src/protect/engine/engine.js index b2a8af55..be9d75b4 100644 --- a/src/protect/engine/engine.js +++ b/src/protect/engine/engine.js @@ -829,7 +829,12 @@ export class RuleEngine { return { blocked: true, rule, - message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}` + message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`, + // The resolver this match was decided by, for whatever needs to report on it. A consumer + // that re-read the request instead would be reading it a second time: a getter, a stream or + // anything else that answers once can give a different value, and evidence that disagrees + // with the match it belongs to is worse than none. + resolver }; } } catch (err) { diff --git a/tests/protect/capture-plan.test.ts b/tests/protect/capture-plan.test.ts index e3b9404e..7ae5065d 100644 --- a/tests/protect/capture-plan.test.ts +++ b/tests/protect/capture-plan.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; +import { RequestResolver } from '../../src/protect/engine/request.js'; +import { RuleEngine } from '../../src/protect/engine/engine.js'; +import { normalizeRequest } from '../../src/protect/engine/normalizer.js'; import { CAPTURE_LIMITS, + captureValues, createPlanCache, derivePlan, permitsAnything, @@ -105,8 +109,8 @@ describe('a prefix permits matching keys and no others', () => { it('bounds how many keys a prefix may match', () => { // A prefix can match an unbounded number of keys, and "a rule that reads a prefix" must not become // "a rule that reads the whole body". - expect(derivePlan(rule(['post.field_*'])).limits.prefixKeys).toBe(CAPTURE_LIMITS.prefixKeys); - expect(CAPTURE_LIMITS.prefixKeys).toBeLessThan(CAPTURE_LIMITS.values + 1); + expect(derivePlan(rule(['post.field_*'])).limits.prefixValues).toBe(CAPTURE_LIMITS.prefixValues); + expect(CAPTURE_LIMITS.prefixValues).toBeLessThan(CAPTURE_LIMITS.capturedValues + 1); }); }); @@ -334,7 +338,7 @@ describe('a plan reference names the policy, not the moment', () => { it('does not depend on the order the permissions are listed in', () => { // `derivePlan` sorts, so this cannot arise from it today — but the reference is what ties a captured // value to the policy that allowed it, and that tie must not rest on an ordering somewhere else. - const limits = { values: 10, valueChars: 512, prefixKeys: 5 }; + const limits = { capturedValues: 10, valueChars: 512, prefixValues: 5 }; const one = { named: ['get.q', 'post.title'], prefixes: ['post.a.', 'post.b.'], raw: null, limits }; const other = { named: ['post.title', 'get.q'], prefixes: ['post.b.', 'post.a.'], raw: null, limits }; @@ -366,20 +370,20 @@ describe('a plan reference names the policy, not the moment', () => { named: ['get.q', 'post.title'], prefixes: [], raw: null, - limits: { values: 10, valueChars: 512, prefixKeys: 5 }, + limits: { capturedValues: 10, valueChars: 512, prefixValues: 5 }, }; - expect(planReference(plan)).toBe('cp1-bf0f418cf2f24752c827a4f8e3b6334a'); + expect(planReference(plan)).toBe('cp2-470617d87e6943b67e48ec6c4022705e'); }); it('changes when a limit changes, without changing the algorithm', () => { // The policy moving is not the algorithm moving: the reference follows the permissions, the prefix // stays put. - const base = { named: ['post.title'], prefixes: [], raw: null, limits: { values: 10, valueChars: 512 } }; - const tighter = { ...base, limits: { values: 10, valueChars: 128 } }; + const base = { named: ['post.title'], prefixes: [], raw: null, limits: { capturedValues: 10, valueChars: 512 } }; + const tighter = { ...base, limits: { capturedValues: 10, valueChars: 128 } }; expect(planReference(tighter)).not.toBe(planReference(base)); - expect(planReference(tighter).startsWith('cp1-')).toBe(true); + expect(planReference(tighter).startsWith('cp2-')).toBe(true); }); it('is what derivePlan produces under the limits in force', () => { @@ -395,7 +399,7 @@ describe('a plan reference names the policy, not the moment', () => { // reference must not be something a reader has to think about. const reference = planReference(derivePlan(rule(['post.title']))); - expect(reference).toMatch(/^cp1-[0-9a-f]{32}$/); + expect(reference).toMatch(/^cp2-[0-9a-f]{32}$/); }); it('differs when the permissions differ', () => { @@ -459,7 +463,7 @@ describe('plans are derived once per rule', () => { const cache = createPlanCache(); expect(permitsAnything(cache.for(undefined as never).plan)).toBe(false); - expect(cache.for(null as never).reference).toMatch(/^cp1-/); + expect(cache.for(null as never).reference).toMatch(/^cp2-/); }); it('hands out an entry that cannot be edited', () => { @@ -548,3 +552,338 @@ describe('the published contract and the runtime give the same answer', () => { expect(captureProblem({ version: 1, raw_chars: 8 }), 'and this one is valid').toBeNull(); }); }); + +describe('what a plan actually reads from a request', () => { + const resolverFor = (req: unknown) => new RequestResolver(normalizeRequestFor(req)); + // The engine reads a normalized request, so capture reads the same one — otherwise it would be holding + // something other than what the rule matched on. + function normalizeRequestFor(req: any) { + return { ...req, ...normalizeRequest(req) }; + } + const request = (over: Record = {}) => ({ + method: 'POST', + url: '/checkout', + originalUrl: '/checkout', + headers: { 'content-type': 'application/json', 'user-agent': 'scanner/1.0' }, + query: {}, + body: {}, + cookies: {}, + ...over, + }); + + it('reads the parameter the plan names, and nothing beside it', () => { + const plan = derivePlan(rule(['post.title'])); + const taken = captureValues(plan, resolverFor(request({ body: { title: 'payload', secret: 'not-permitted' } }))); + + expect(taken.values).toEqual([{ parameter: 'post.title', value: 'payload' }]); + expect(JSON.stringify(taken), 'a field the plan did not name is not read').not.toContain( + 'not-permitted', + ); + }); + + it('takes nothing when the plan permits nothing', () => { + // The common case, and the one that must cost nothing: a rule reading `raw` with no opt-in. + const taken = captureValues(derivePlan(rule(['raw'])), resolverFor(request({ body: { title: 'payload' } }))); + + expect(taken).toEqual({ values: [], omitted: 0, unsupported: 0, failed: 0, unavailable: false, raw: null }); + }); + + it('reads a header and a cookie the rule named', () => { + const plan = derivePlan(rule(['server.HTTP_USER_AGENT', 'cookie.session'])); + const taken = captureValues(plan, resolverFor(request({ cookies: { session: 'abc123' } }))); + + expect(taken.values.map((v: any) => v.parameter).sort()).toEqual([ + 'cookie.session', + 'server.HTTP_USER_AGENT', + ]); + }); + + it('shortens a long value and says which one', () => { + const plan = derivePlan(rule(['post.title'])); + const taken = captureValues(plan, resolverFor(request({ body: { title: 'x'.repeat(5000) } }))); + + expect(taken.values[0].value.length).toBe(CAPTURE_LIMITS.valueChars); + expect(taken.values[0].truncated).toBe(true); + }); + + it('says nothing about truncation when nothing was shortened', () => { + const taken = captureValues(derivePlan(rule(['post.title'])), resolverFor(request({ body: { title: 'short' } }))); + + expect(Object.hasOwn(taken.values[0], 'truncated')).toBe(false); + }); + + it('stops at the total it is allowed, and counts what it left', () => { + // A capture holding less than it appears to would have a reader drawing conclusions from a sample + // without knowing it was one. + const many = Array.from({ length: 30 }, (_, i) => `post.f${i}`); + const body = Object.fromEntries(many.map((_, i) => [`f${i}`, `value-${i}`])); + const taken = captureValues(derivePlan(rule(many)), resolverFor(request({ body }))); + + expect(taken.values.length).toBe(CAPTURE_LIMITS.capturedValues); + expect(taken.omitted).toBe(30 - CAPTURE_LIMITS.capturedValues); + }); + + it('bounds how many keys one prefix contributes', () => { + const body = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`field_${i}`, `v${i}`])); + const taken = captureValues(derivePlan(rule(['post.field_*'])), resolverFor(request({ body }))); + + expect(taken.values.length).toBe(CAPTURE_LIMITS.prefixValues); + expect(taken.values.every((v: any) => v.parameter === 'post.field_*')).toBe(true); + expect(taken.omitted).toBe(20 - CAPTURE_LIMITS.prefixValues); + }); + + it('does not serialise an object the plan named', () => { + // A permission for `post.profile` is not a permission for everything under it. + const plan = derivePlan(rule(['post.profile'])); + const taken = captureValues(plan, resolverFor(request({ body: { profile: { name: 'ada', password: 'hunter2' } } }))); + + expect(JSON.stringify(taken)).not.toContain('hunter2'); + expect(taken.unsupported, 'refused for its type, and said so').toBe(1); + expect(taken.omitted, 'which is not the same as a bound leaving it out').toBe(0); + }); + + it('reads a bounded prefix of the raw body only with the opt-in', () => { + const raw = 'not-json __proto__ ' + 'y'.repeat(1000); + const req = request({ headers: { 'content-type': 'application/json' }, body: {}, _rawBody: raw }); + + const without = captureValues(derivePlan(rule(['raw'])), resolverFor(req)); + expect(without.raw, 'no opt-in, no raw evidence').toBeNull(); + + const withOptIn = captureValues( + derivePlan(rule(['raw'], { capture: { version: 1, raw_chars: 64 } })), + resolverFor(req), + ); + expect(withOptIn.raw.value.length).toBe(64); + expect(withOptIn.raw.truncated).toBe(true); + expect(withOptIn.raw.value).toBe(raw.slice(0, 64)); + }); + + it('never fails a request over evidence', () => { + // Fail-open, like everything else on this path: a capture that cannot be taken is not taken. + const hostile = { + resolve() { + throw new Error('hostile parameter'); + }, + }; + + // No resolver to read with at all. + expect(captureValues(derivePlan(rule(['post.title'])), undefined as never)).toEqual({ + values: [], + omitted: 0, + unsupported: 0, + failed: 0, + // Distinguishable from "there was nothing to capture": a reviewer must not read incomplete + // evidence as complete. + unavailable: true, + raw: null, + }); + + const threw = captureValues(derivePlan(rule(['post.title'])), hostile as never); + + expect(threw.values).toEqual([]); + expect(threw.failed, 'the failure is recorded, not silently empty').toBe(1); + expect(threw.unavailable, 'and no read completed, so nothing was readable').toBe(true); + }); + + it('reports a partial read as partial, not as unreadable', () => { + // One parameter failing is not the request being unreadable, and the two lead to opposite + // conclusions from a short list of values. + const flaky = { + resolve(parameter: string) { + if (parameter === 'post.bad') throw new Error('nope'); + + return ['fine']; + }, + }; + const taken = captureValues(derivePlan(rule(['post.bad', 'post.good'])), flaky as never); + + expect(taken.values).toEqual([{ parameter: 'post.good', value: 'fine' }]); + expect(taken.failed).toBe(1); + expect(taken.unavailable).toBe(false); + }); + +}); + +describe('evidence records what it could not take, and why', () => { + const resolverFor = (req: unknown) => new RequestResolver({ ...(req as object), ...normalizeRequest(req as never) }); + const request = (over: Record = {}) => ({ + method: 'POST', + url: '/checkout', + originalUrl: '/checkout', + headers: { 'content-type': 'application/json' }, + query: {}, + body: {}, + cookies: {}, + ...over, + }); + + it('keeps a value that is present and empty', () => { + // A rule can be written so that its finding IS that a parameter is empty. Collapsing that into + // absence erases the evidence for exactly those rules. + const taken = captureValues(derivePlan(rule(['post.title'])), resolverFor(request({ body: { title: '' } }))); + + expect(taken.values).toEqual([{ parameter: 'post.title', value: '' }]); + }); + + it('tells a present-but-empty value apart from an absent one', () => { + const absent = captureValues(derivePlan(rule(['post.title'])), resolverFor(request({ body: {} }))); + + // An absent field resolves to no value at all, which is already a different answer — and neither is + // a failure, so nothing is counted against the bounds. + expect(absent.values).toEqual([]); + expect(absent).toMatchObject({ omitted: 0, unsupported: 0, failed: 0, unavailable: false }); + }); + + it('counts every value one NAMED parameter had excluded, not just the first', () => { + // A single named parameter can resolve to many values — several files uploaded under one field name + // fan out. Reporting one omission where there were several would have a reviewer take a truncated + // sample for a nearly complete one. + const upload = Array.from({ length: 15 }, (_, i) => ({ + filename: `f${i}.php`, + type: 'text/php', + content: `content-${i}`, + })); + const taken = captureValues(derivePlan(rule(['files.upload.content'])), resolverFor(request({ files: { upload } }))); + + expect(taken.values.length).toBe(CAPTURE_LIMITS.capturedValues); + expect(taken.omitted, 'every one that did not fit').toBe(15 - CAPTURE_LIMITS.capturedValues); + }); + + it('counts every value a prefix had excluded', () => { + const files = Object.fromEntries( + Array.from({ length: 15 }, (_, i) => [`f${i}`, { filename: `f${i}.php`, type: 'text/php', content: 'x' }]), + ); + const taken = captureValues(derivePlan(rule(['files.f*'])), resolverFor(request({ files }))); + + expect(taken.values.length).toBe(CAPTURE_LIMITS.prefixValues); + expect(taken.omitted).toBe(15 - CAPTURE_LIMITS.prefixValues); + }); + + it('judges an unsupported type wherever it appears, not by where the budget ran out', () => { + // Type is judged before capacity, so the same value is refused the same way at the front of a + // request and at the back of it. + // The object sorts LAST, so the budget is already full when it is reached. Judging capacity first + // would file it as omitted — a value a bound left out — rather than as one whose type is refused. + const body: Record = { z: { nested: true } }; + for (let i = 0; i < 12; i++) body[`f${i}`] = `v${i}`; + const plan = derivePlan(rule(['post.z', ...Array.from({ length: 12 }, (_, i) => `post.f${i}`)])); + + expect(plan.named[plan.named.length - 1], 'the object is reached last').toBe('post.z'); + + const taken = captureValues(plan, resolverFor(request({ body }))); + + expect(taken.unsupported, 'refused for its type, not for arriving late').toBe(1); + expect(taken.values.length).toBe(CAPTURE_LIMITS.capturedValues); + expect(taken.omitted).toBe(12 - CAPTURE_LIMITS.capturedValues); + }); + + it('classifies a prefix value before the prefix bound, not after', () => { + // Five strings, then an object. What a value IS does not depend on how many came before it: refused + // for its type reads as a value this channel will not carry, while left out by a bound reads as one + // that would have fitted in a larger event. Swapping them misreports why the evidence is short. + const body: Record = {}; + for (let i = 0; i < CAPTURE_LIMITS.prefixValues; i++) body[`field_${i}`] = `v${i}`; + body.field_last = { nested: true }; + + const taken = captureValues(derivePlan(rule(['post.field_*'])), resolverFor(request({ body }))); + + expect(taken.values.length).toBe(CAPTURE_LIMITS.prefixValues); + expect(taken.unsupported, 'the object was refused for its type').toBe(1); + expect(taken.omitted, 'and no bound left anything out').toBe(0); + }); + + it('gives raw its own allowance rather than a slot from the value total', () => { + // Raw is separately opted into and separately bounded. Making it consume a value slot would have an + // opt-in silently reduce the named evidence a reviewer needs. + const body = Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`f${i}`, `v${i}`])); + const named = Array.from({ length: 12 }, (_, i) => `post.f${i}`); + const plan = derivePlan(rule([...named, 'raw'], { capture: { version: 1, raw_chars: 32 } })); + const taken = captureValues(plan, resolverFor(request({ body, _rawBody: 'r'.repeat(200) }))); + + expect(taken.values.length, 'the full value budget').toBe(CAPTURE_LIMITS.capturedValues); + expect(taken.raw, 'and raw besides').not.toBeNull(); + expect(taken.raw.value.length).toBe(32); + }); + + it('records the resolved value: normalised, but not mutated by the rule', () => { + // Three forms of a parameter exist. The engine normalises the request, then a condition applies its + // own mutations. This records the middle one — what the resolver answers with — because reading + // either of the others would be a second interpretation of the request. + const encoded = '%3Cscript%3E'; + const urlRule = { + id: 'r1', + rule_v2: [{ parameter: 'get.q', mutations: ['urldecode'], match: { type: 'contains', value: '