diff --git a/src/protect/engine/pulse-client.js b/src/protect/engine/pulse-client.js index 7459f52..dc676ec 100644 --- a/src/protect/engine/pulse-client.js +++ b/src/protect/engine/pulse-client.js @@ -27,7 +27,9 @@ export class PulseRuleClient { #etag; #pulseAuth; - constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) { + #reportsDetections; + + constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) { // 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; @@ -37,6 +39,16 @@ export class PulseRuleClient { this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL; this.#etag = etag ?? null; this.#pulseAuth = pulseAuth ?? null; + // Whether this guard reports detections, declared on a request it already makes. + // + // Detections are only sent when a rule fires, so silence at the server means one of three things — + // nothing matched, reporting is off, or reports are not arriving — and nothing distinguishes them. + // Saying "reporting is on" on the rules fetch does, without a new outbound path or any request data: + // the fetch is already periodic, already authenticated, and already carries this site's identity. + // + // 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; if (!this.#siteUuid) { throw new Error('Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.'); } @@ -52,13 +64,24 @@ export class PulseRuleClient { // Unauthenticated when no credential resolved, or when the exchange // fails — the server still accepts the UUID, and protection must never // hinge on getting a token. - const headers = { - Accept: 'application/json', - ...(await pulseAuthHeader( - { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs }, - fetch, - )), - }; + const auth = await pulseAuthHeader( + { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs }, + fetch, + ); + const headers = { Accept: 'application/json', ...auth }; + // Claimed only on an authenticated request. The rules endpoint still accepts a bare UUID, so on that + // path this header would be an assertion anyone holding the UUID could make — and it asserts the + // reassuring thing: that reporting is on. A dashboard would then say a site is covered because a + // stranger said so. + // + // Fetching rules must never hinge on getting a token (protection comes first), but CLAIMING a + // capability may: an unauthenticated request is one whose statements about this site carry no weight. + // This check only removes the ACCIDENTAL case. The forgeable one is not the client's to prevent, so + // anything acting on this header has to require a verified token itself before believing it — a + // client-side gate is a courtesy, never the guarantee. + if (this.#reportsDetections && typeof auth.Authorization === 'string') { + headers['X-Patchstack-Detections'] = 'enabled'; + } 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/rules/source.js b/src/protect/rules/source.js index f352207..ec33c67 100644 --- a/src/protect/rules/source.js +++ b/src/protect/rules/source.js @@ -42,7 +42,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 }); + 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 normalizeBundle(prior.bundle, options); if (res.success && !res.notModified) { diff --git a/src/protect/runtime.js b/src/protect/runtime.js index b1e062c..fdd72cf 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -28,6 +28,7 @@ import { renderBlockPage } from './block-page.js'; 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 { createFirewallLogReporter, resolveApiBase, telemetryEnabled } from './firewall-log.js'; // Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase). diff --git a/tests/protect/detections.test.ts b/tests/protect/detections.test.ts index 53cfb61..91f5f41 100644 --- a/tests/protect/detections.test.ts +++ b/tests/protect/detections.test.ts @@ -212,3 +212,131 @@ describe('wiring', () => { p.stopRefresh?.(); }); }); + +describe('declaring the capability', () => { + it('tells the server reporting is on, on a request it already makes', async () => { + // Detections are sent only when a rule fires, so silence at the server means nothing matched, or + // reporting is off, or reports are not arriving — and nothing tells those apart. The rules fetch does, + // with a header: no new outbound path, no request data, and no client timestamp (the server records + // when IT saw this, because "alive as of" is the claim a wrong clock would fake). + const seen: Array> = []; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).includes('token')) { + return new Response(JSON.stringify({ access_token: 'jwt-abc', expires_in: 3600 }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + seen.push((init?.headers ?? {}) as Record); + + return new Response(JSON.stringify({ firewall: [], whitelists: [], enforcement: 'dry-run' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + pulseAuth: 'the-secret-40-chars-long-ish-value-here-987', + reportDetections: true, + }); + + // Authenticated, so the claim carries weight and is made. + const claimed = seen.filter((h) => h['X-Patchstack-Detections'] === 'enabled'); + expect(claimed.length).toBeGreaterThan(0); + for (const headers of claimed) { + expect(headers.Authorization, 'the claim only travels on an authenticated request').toContain('Bearer'); + } + p.stopRefresh?.(); + }); + + it('says nothing when reporting is off', async () => { + // The declaration has to mean something: a guard that is not reporting must not claim it is, or the + // server cannot tell a configured site from an unconfigured one — which is the whole point. + const seen: Array> = []; + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + seen.push((init?.headers ?? {}) as Record); + + return new Response(JSON.stringify({ firewall: [], whitelists: [], enforcement: 'dry-run' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const p: any = await createProtection({ siteUuid: 'site-1', pulseRulesUrl: 'https://x.test/monitor/pulse' }); + + expect(seen.every((h) => h['X-Patchstack-Detections'] === undefined)).toBe(true); + p.stopRefresh?.(); + }); +}); + +describe('the wiring actually runs', () => { + it('posts a detection when reporting is switched on', async () => { + // The gap that let a broken build merge: every other test here either exercised the reporter directly + // or asserted that NOTHING is posted when the feature is off. Neither enters the branch that builds the + // reporter, so an unresolved import in it threw only for someone who turned the feature on — which, + // being opt-in, was nobody. This test is the one that fails if the wiring is broken. + const posted: string[] = []; + const fetchMock = vi.fn(async (url: string) => { + posted.push(String(url)); + if (String(url).includes('/detections/')) return new Response('{}', { status: 202 }); + + return new Response( + JSON.stringify({ + firewall: [{ id: 'r1', title: 'boom', rule_v2: [{ parameter: 'get.q', match: { type: 'contains', value: 'boom' } }] }], + whitelists: [], enforcement: 'dry-run', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + reportDetections: true, + detectionFlushMs: 1, + }); + + await p.fetchGuard()(new Request('https://app.test/api/x?q=boom')); + p.stopRefresh?.(); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(posted.some((url) => url.includes('/detections/site-1'))).toBe(true); + }); +}); + +describe('the capability claim is only made when it carries weight', () => { + it('stays silent on an unauthenticated rules fetch', async () => { + // The rules endpoint still accepts a bare UUID, so on that path this header is an assertion anyone + // holding the UUID could make — and it asserts the reassuring thing, that reporting is on. A dashboard + // would then report a site as covered because a stranger said so. Fetching rules must not hinge on a + // token; claiming a capability must. + const seen: Array> = []; + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + seen.push((init?.headers ?? {}) as Record); + + return new Response(JSON.stringify({ firewall: [], whitelists: [], enforcement: 'dry-run' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + // No credential anywhere: no `pulseAuth`, and nothing for the token exchange to find. + const p: any = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + reportDetections: true, + }); + + const rulesRequests = seen.filter((h) => h.Accept === 'application/json'); + expect(rulesRequests.length).toBeGreaterThan(0); + for (const headers of rulesRequests) { + expect(headers.Authorization).toBeUndefined(); + expect(headers['X-Patchstack-Detections'], 'an unauthenticated request may not claim the capability') + .toBeUndefined(); + } + + p.stopRefresh?.(); + }); +});