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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/protect/engine/pulse-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export class PulseRuleClient {
firewall: data.firewall,
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
whitelist_keys: data.whitelist_keys ?? {},
...enforcementField(data),
};
this.#cache = result;
this.#etag = result.etag;
Expand All @@ -89,3 +90,10 @@ export class PulseRuleClient {
this.#etag = null;
}
}

/** @param {unknown} data @returns {{ enforcement?: 'block'|'dry-run' }} */
function enforcementField(data) {
if (!data || typeof data !== 'object') return {};
const v = data.enforcement ?? data.mode;
return v === 'block' || v === 'dry-run' ? { enforcement: v } : {};
}
8 changes: 7 additions & 1 deletion src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface RuleBundle {
firewall: unknown[];
whitelists: unknown[];
whitelist_keys: Record<string, unknown>;
/** From the Pulse rules API when present (`block` = enforce, `dry-run` = detect only). */
enforcement?: "block" | "dry-run";
}

export type Phase = "request" | "response" | "egress";
Expand Down Expand Up @@ -33,7 +35,11 @@ export interface Protection {
}

export interface CreateProtectionOptions {
/** Default "dry-run". The scaffolded guard sets "block". */
/**
* Fallback when the Pulse rules API does not send `enforcement`.
* Overridden by `PATCHSTACK_MODE` when set, otherwise by API `enforcement`.
* Default "dry-run". Scaffolded guards pass "block" when env is unset.
*/
mode?: "block" | "dry-run";
/** Explicit rule bundle (used as the token-less fallback). */
rules?: unknown;
Expand Down
2 changes: 2 additions & 0 deletions src/protect/rules/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ export async function resolveRules(options, store) {
}

export function normalizeBundle(b) {
const enforcement = b?.enforcement ?? b?.mode;
return {
firewall: Array.isArray(b.firewall) ? b.firewall : [],
whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
whitelist_keys: b.whitelist_keys ?? {},
...(enforcement === 'block' || enforcement === 'dry-run' ? { enforcement } : {}),
};
}

Expand Down
25 changes: 22 additions & 3 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,15 @@ export function createServerFnGuard({ protection }) {
}

export async function createProtection(options = {}) {
const mode = options.mode === 'block' ? 'block' : 'dry-run';
const onError = options.onError;
const onDetect = options.onDetect ?? defaultOnDetect;

// One tiered store (memory → filesystem/pluggable) shared by the initial load and every refresh.
const store = makeStore(options);
const bundle = await resolveRules(options, store);
// 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);
// Rule-derived runtime state. Held in `let` bindings the guard methods below close over, so a
// refresh (see the loop near the end) can hot-swap the engines by reassigning them — the
// egress interception and the protection object itself stay in place, no re-install.
Expand Down Expand Up @@ -280,7 +282,9 @@ export async function createProtection(options = {}) {
};

const protection = {
mode,
get mode() {
return mode;
},
get rules() {
return { request: requestRules, response: responseRules, egress: egressRules };
},
Expand Down Expand Up @@ -443,7 +447,9 @@ export async function createProtection(options = {}) {
onError?.(err); // a failed report must not stop the rule refresh
}
}
applyBundle(await resolveRules(options, store));
const next = await resolveRules(options, store);
mode = resolveMode(options, next);
applyBundle(next);
};

if (live) {
Expand All @@ -462,6 +468,19 @@ export async function createProtection(options = {}) {
return protection;
}

/**
* Resolve runtime enforcement mode.
* Precedence: PATCHSTACK_MODE env > Pulse `enforcement` on the rules bundle > options.mode > dry-run.
*/
function resolveMode(options, bundle) {
const env = typeof process !== 'undefined' ? process.env?.PATCHSTACK_MODE : undefined;
if (env === 'block' || env === 'dry-run') return env;
if (bundle?.enforcement === 'block' || bundle?.enforcement === 'dry-run') return bundle.enforcement;
if (options?.mode === 'block') return 'block';
if (options?.mode === 'dry-run') return 'dry-run';
return 'dry-run';
}

// --- phase / response helpers -------------------------------------------

function byPhase(rules, phase) {
Expand Down
28 changes: 28 additions & 0 deletions tests/protect/pulse-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,32 @@ describe('PulseRuleClient', () => {
it('requires a siteUuid', () => {
expect(() => new PulseRuleClient({})).toThrow();
});

it('parses enforcement from the Pulse rules response', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ ...RULES, enforcement: 'dry-run' }), { status: 200 })),
);
const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules();
expect(res.success).toBe(true);
expect(res.enforcement).toBe('dry-run');
});

it('accepts mode as an alias for enforcement', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ ...RULES, mode: 'block' }), { status: 200 })),
);
const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules();
expect(res.enforcement).toBe('block');
});

it('ignores invalid enforcement values', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ ...RULES, enforcement: 'maybe' }), { status: 200 })),
);
const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules();
expect(res.enforcement).toBeUndefined();
});
});
88 changes: 87 additions & 1 deletion tests/protect/runtime-pulse.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createProtection, createServerFnGuard } from '../../src/protect/runtime.js';

// A single-rule bundle mirroring the Pulse rules endpoint's { firewall, whitelists, whitelist_keys }.
Expand Down Expand Up @@ -80,6 +80,92 @@ describe('createProtection with a siteUuid (live Pulse rules)', () => {
});
});

describe('createProtection Pulse enforcement field', () => {
const prevMode = process.env.PATCHSTACK_MODE;

afterEach(() => {
if (prevMode === undefined) delete process.env.PATCHSTACK_MODE;
else process.env.PATCHSTACK_MODE = prevMode;
vi.restoreAllMocks();
});

it('honors API enforcement=dry-run over options.mode=block (detect, do not block)', async () => {
delete process.env.PATCHSTACK_MODE;
const onDetect = vi.fn();
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'dry-run' }), {
status: 200,
}),
),
);
const protection = await createProtection({
siteUuid: 'site-1',
pulseRulesUrl: 'https://x.test/monitor/pulse',
mode: 'block',
onDetect,
});
expect(protection.mode).toBe('dry-run');
expect(await createServerFnGuard({ protection })({ title: '<img src=x onerror="steal()">' })).toBeNull();
expect(onDetect).toHaveBeenCalled();
});

it('lets PATCHSTACK_MODE override API enforcement', async () => {
process.env.PATCHSTACK_MODE = 'block';
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'dry-run' }), {
status: 200,
}),
),
);
const protection = await createProtection({
siteUuid: 'site-1',
pulseRulesUrl: 'https://x.test/monitor/pulse',
mode: 'dry-run',
});
expect(protection.mode).toBe('block');
expect((await createServerFnGuard({ protection })({ title: '<img src=x onerror="steal()">' }))?.rule).toBe('rm-npm-0001');
});

it('hot-swaps mode when a refresh flips enforcement to block', async () => {
delete process.env.PATCHSTACK_MODE;
vi.useFakeTimers();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'dry-run' }), {
status: 200,
}),
)
.mockResolvedValue(
new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'block' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);

const protection = await createProtection({
siteUuid: 'site-1',
pulseRulesUrl: 'https://x.test/monitor/pulse',
mode: 'block',
refreshMs: 1000,
reportManifest: false,
});
expect(protection.mode).toBe('dry-run');
expect(await createServerFnGuard({ protection })({ title: '<img src=x onerror="steal()">' })).toBeNull();

await vi.advanceTimersByTimeAsync(1100);
expect(protection.mode).toBe('block');
expect((await createServerFnGuard({ protection })({ title: '<img src=x onerror="steal()">' }))?.rule).toBe('rm-npm-0001');

protection.stopRefresh?.();
vi.useRealTimers();
});
});

describe('createProtection live rule refresh (refreshMs)', () => {
it('hot-swaps in a rule that appears after boot, without recreating the protection', async () => {
vi.useFakeTimers();
Expand Down
Loading