Skip to content

Commit 51c4fc4

Browse files
Report detections the guard did not block, not only the ones it did (#156)
A rule that blocks nothing reports nothing. `firewall-log.js` posts enforced blocks in the WordPress-compatible shape and answers "what did we stop"; there was no way to see that a rule carrying `enforcement: dry-run` matched traffic it would otherwise have blocked. Without that, a rule which is quietly wrong and a rule which is protecting look identical from the outside. ## The payload is small on purpose 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 never carries the matched value, the request body, headers, or query-string values. A channel that counts detections is a different thing from a copy of an application's traffic, and the difference is one careless field: once values are collected, every question about retention, access and jurisdiction arrives with them. Anything value-level belongs behind its own explicit opt-in with its own controls, not as a side effect of counting. The route drops the query because `?token=…` is a value, and the guard against regression is a scan of the SERIALIZED payload rather than of the object being built: a field added later (`message`, `value`, `headers`) passes every structural assertion and fails that one. `parameters` is the set the rule READS, from its own definition — not the condition that matched. The engine reports a rule, not which of its conditions fired, and threading that out would mean changing evaluation for the sake of a reporting field. ## Off by default Enabling it adds an outbound POST to every guard configured with a site UUID, which is a change in what an installed app does on the network — that belongs in the shipped docs before it becomes a default rather than after. `reportDetections: true` switches it on. Bounded (500 events, oldest dropped) and fail-open: an unreachable endpoint is silent and never retries into a loop. The drop count is sent WITH the batch, because a consumer computing a rate from these needs to know its denominator is short, and nobody infers that from a gap. 1249 tests, typecheck clean. Four guarantees are mutation-checked: keeping the query string, adding the block message to the payload, removing the queue cap, and deriving `enforced` from the site mode rather than the rule each fail the assertion that names them. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8d4c2d7 commit 51c4fc4

4 files changed

Lines changed: 455 additions & 1 deletion

File tree

‎src/protect/detections.js‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { pulseAuthHeader } from '../pulse-token.js';
2+
import { isSafeOrigin } from './safe-origin.js';
3+
4+
/**
5+
* Detection reporter: every rule that fired, whether or not it blocked.
6+
*
7+
* Separate from `firewall-log.js`, which posts ENFORCED blocks in the WordPress-compatible shape
8+
* (`fid`, `request_uri`, `ip`, `user_agent`). That path answers "what did we stop". This one answers a
9+
* question nothing else could: **what would this rule have stopped**, for a rule that carries
10+
* `enforcement: dry-run` and therefore blocks nothing.
11+
*
12+
* Without it, a rule that is quietly wrong and a rule that is protecting look identical from the
13+
* outside, because neither produces a block to report.
14+
*
15+
* ## The payload is deliberately small
16+
*
17+
* `rule_id`, route PATH, the parameters the rule reads, a timestamp, whether it was enforced, the phase,
18+
* and the bundle identity. That is enough to count hits per rule, compare them against traffic, and
19+
* decide whether a rule is wrong.
20+
*
21+
* What it never carries: **the matched value, the request body, headers, or query-string values**. A
22+
* channel that counts detections is a different thing from a copy of an application's traffic, and once
23+
* values are collected every question about retention, access and jurisdiction arrives with them.
24+
* Anything value-level belongs behind its own explicit opt-in with its own controls, not as a side
25+
* effect of counting.
26+
*
27+
* The route is the request PATH with any query string dropped, because `?token=…` is a value.
28+
*/
29+
30+
const DEFAULT_BASE_URL = 'https://api.patchstack.com/monitor/pulse';
31+
const DEFAULT_FLUSH_MS = 5000;
32+
const MAX_BATCH = 50;
33+
/** Bounded so a detection storm costs memory it cannot grow out of. Oldest go first. */
34+
const MAX_QUEUE = 500;
35+
36+
/**
37+
* The parameters a rule reads, from its own definition.
38+
*
39+
* NOT "the condition that matched": the engine reports a rule, not which of its conditions fired, and
40+
* threading that out would mean changing evaluation for the sake of a reporting field. A narrowly scoped
41+
* rule reads exactly one parameter, so the two answers coincide there; for a broad rule this is the set
42+
* it reads, which is what the field name says.
43+
*
44+
* @param {any} rule
45+
* @returns {string[]}
46+
*/
47+
export function ruleParameters(rule) {
48+
const out = new Set();
49+
const walk = (conditions) => {
50+
if (!Array.isArray(conditions)) return;
51+
for (const condition of conditions) {
52+
if (!condition || typeof condition !== 'object') continue;
53+
if (typeof condition.parameter === 'string' && condition.parameter !== 'rules') {
54+
out.add(condition.parameter);
55+
}
56+
if (Array.isArray(condition.rules)) walk(condition.rules);
57+
}
58+
};
59+
walk(rule?.rule_v2);
60+
61+
return [...out];
62+
}
63+
64+
/**
65+
* The request path with the query string removed.
66+
*
67+
* A path is a route; a query string is data. `/api/preview?url=http://169.254.169.254/` names both the
68+
* endpoint and the attack payload, and only the first belongs in a counting channel.
69+
*
70+
* @param {unknown} path
71+
* @returns {string | null}
72+
*/
73+
export function routeOf(path) {
74+
if (typeof path !== 'string' || path === '') return null;
75+
const cut = path.search(/[?#]/);
76+
77+
return cut === -1 ? path : path.slice(0, cut);
78+
}
79+
80+
/**
81+
* @param {{
82+
* siteUuid?: string,
83+
* baseUrl?: string,
84+
* pulseAuth?: unknown,
85+
* rulesEtag?: string | null,
86+
* fetchImpl?: typeof fetch,
87+
* flushMs?: number,
88+
* maxQueue?: number,
89+
* }} opts
90+
*/
91+
export function createDetectionReporter(opts) {
92+
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
93+
if (!siteUuid) {
94+
// Nothing to report against. A no-op rather than a throw: reporting is never worth failing a boot.
95+
return { record() {}, flush() {}, stop() {}, dropped: () => 0 };
96+
}
97+
98+
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
99+
const baseUrl = typeof configured === 'string' && isSafeOrigin(configured)
100+
? configured.replace(/\/$/, '')
101+
: DEFAULT_BASE_URL;
102+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
103+
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
104+
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
105+
106+
/** @type {Array<Record<string, unknown>>} */
107+
let queue = [];
108+
/** @type {ReturnType<typeof setTimeout> | null} */
109+
let timer = null;
110+
let stopped = false;
111+
let dropped = 0;
112+
113+
const flush = () => {
114+
if (timer) {
115+
clearTimeout(timer);
116+
timer = null;
117+
}
118+
if (queue.length === 0 || typeof fetchImpl !== 'function') return;
119+
120+
const batch = queue.splice(0, MAX_BATCH);
121+
// The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer
122+
// computing a false-positive rate needs to know its denominator is short, and silence about that
123+
// would make a truncated sample look like a complete one.
124+
const droppedWith = dropped;
125+
dropped = 0;
126+
127+
void (async () => {
128+
try {
129+
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
130+
method: 'POST',
131+
headers: {
132+
'Content-Type': 'application/json',
133+
Accept: 'application/json',
134+
'User-Agent': '@patchstack/connect',
135+
// Same credential path as the rules fetch, and unauthenticated when none resolves: the
136+
// server accepts the UUID, and reporting must never hinge on getting a token.
137+
...(await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)),
138+
},
139+
body: JSON.stringify({ detections: batch, dropped: droppedWith }),
140+
});
141+
// Fail-open and silent: a rejected or unreachable endpoint must not disturb the app, and must
142+
// not retry into a loop either. The next flush carries whatever arrives next.
143+
if (res && typeof res.then === 'function') res.catch(() => {});
144+
} catch {
145+
/* ignore */
146+
}
147+
})();
148+
};
149+
150+
return {
151+
/**
152+
* @param {{
153+
* rule?: { id?: string, rule_v2?: unknown },
154+
* phase?: string,
155+
* mode?: string,
156+
* path?: string,
157+
* }} detection
158+
*/
159+
record(detection) {
160+
if (stopped) return;
161+
const ruleId = detection?.rule?.id;
162+
if (ruleId === undefined || ruleId === null || ruleId === '') return;
163+
164+
if (queue.length >= maxQueue) {
165+
queue.shift();
166+
dropped++;
167+
}
168+
169+
queue.push({
170+
rule_id: ruleId,
171+
route: routeOf(detection.path),
172+
parameters: ruleParameters(detection.rule),
173+
phase: detection.phase ?? null,
174+
// The state this detection was handled under, which is the whole point: `false` is a rule that
175+
// saw traffic it would have stopped.
176+
enforced: detection.mode === 'block',
177+
rules_etag: opts.rulesEtag ?? null,
178+
detected_at: new Date().toISOString(),
179+
});
180+
181+
if (queue.length >= MAX_BATCH) {
182+
flush();
183+
184+
return;
185+
}
186+
if (!timer) timer = setTimeout(flush, flushMs);
187+
},
188+
flush,
189+
stop() {
190+
stopped = true;
191+
flush();
192+
},
193+
dropped: () => dropped,
194+
};
195+
}

‎src/protect/protect.d.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ export interface CreateProtectionOptions {
6868
* Also disabled when `PATCHSTACK_TELEMETRY=off` or when no apiKey is available.
6969
*/
7070
reportFirewallLog?: boolean;
71+
/**
72+
* Report EVERY rule that fired — including one in `dry-run` that did not block — to the Pulse
73+
* detections endpoint. Off unless explicitly `true`.
74+
*
75+
* Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a
76+
* rule that is protecting look identical from the outside.
77+
*
78+
* What it sends, per detection: the rule id, the request PATH with the query string removed, the
79+
* parameters the rule reads, the phase, whether it was enforced, the rule-bundle ETag, and a
80+
* timestamp. It does NOT send the matched value, the request body, headers, or query-string values —
81+
* this is a counting channel, not a copy of your traffic.
82+
*
83+
* Off by default because switching it on adds an outbound request to every guard with a site UUID.
84+
*/
85+
reportDetections?: boolean;
86+
/** How long to buffer detections before posting a batch. Default 5000ms. */
87+
detectionFlushMs?: number;
7188
/** Optional Source-Host header for connector hostname checks. */
7289
sourceHost?: string;
7390
/** Optional fetch override (tests). */

‎src/protect/runtime.js‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,15 @@ export async function createProtection(options = {}) {
8686
})
8787
: null;
8888

89+
// Every detection, enforced or not, to the Pulse detections endpoint. Distinct from the block log
90+
// above: that records what was STOPPED, in the WordPress-compatible shape; this records what a rule
91+
// WOULD have stopped, which is otherwise unobservable for a rule carrying `enforcement: dry-run`.
92+
// Minimal payload by design; see `detections.js`.
93+
let detections = null;
94+
8995
const onDetect = (detection) => {
9096
userOnDetect(detection);
97+
if (detections) detections.record(detection);
9198
if (firewallLog && detection?.mode === 'block') {
9299
firewallLog.record({
93100
rule: detection.rule,
@@ -109,6 +116,23 @@ export async function createProtection(options = {}) {
109116
// runtimes that have one, and refreshes should not repeat it.
110117
const pulseAuth = await resolvePulseAuth(options);
111118
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
119+
// OPT-IN, deliberately. Two reasons, and the first is not about privacy: switching it on adds an
120+
// outbound POST to every guard that has a site UUID, which is a change in what an installed app does
121+
// on the network — the kind of thing that must be disclosed in the shipped docs before it is a default,
122+
// not after. The second is that the default belongs to whoever owns that disclosure, so the capability
123+
// lands here and the flip is a separate, deliberate change.
124+
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
125+
detections = createDetectionReporter({
126+
siteUuid: options.siteUuid,
127+
baseUrl: options.pulseRulesUrl,
128+
pulseAuth,
129+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
130+
// it rather than to whatever is current when the report is read.
131+
rulesEtag: (await store.read())?.etag ?? null,
132+
fetchImpl: options.fetchImpl,
133+
flushMs: options.detectionFlushMs,
134+
});
135+
}
112136
// Mode is mutable so a Pulse refresh can flip dry-run ↔ block when SaaS enables production.
113137
// Precedence: PATCHSTACK_MODE env (local override) > API enforcement > options.mode > dry-run.
114138
let mode = resolveMode(options, bundle);
@@ -630,9 +654,13 @@ export async function createProtection(options = {}) {
630654
protection.stopRefresh = () => {
631655
loop.stop();
632656
firewallLog?.stop();
657+
detections?.stop();
633658
};
634659
} else if (firewallLog) {
635-
protection.stopRefresh = () => firewallLog.stop();
660+
protection.stopRefresh = () => {
661+
firewallLog.stop();
662+
detections?.stop();
663+
};
636664
}
637665

638666
return protection;

0 commit comments

Comments
 (0)