@patchstack/connect 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protect.cjs CHANGED
@@ -30,6 +30,94 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
30
  ));
31
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
32
 
33
+ // src/pulse-token.ts
34
+ function buildTokenUrl(manifestEndpoint) {
35
+ const url = new URL(manifestEndpoint);
36
+ const path7 = url.pathname.replace(/\/$/, "");
37
+ url.pathname = path7.endsWith("/manifest") ? `${path7.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
38
+ url.search = "";
39
+ url.hash = "";
40
+ return url.toString();
41
+ }
42
+ function parsePulseAuth(credential) {
43
+ const index = credential.lastIndexOf("-");
44
+ if (index <= 0 || index === credential.length - 1) return null;
45
+ const clientId = credential.slice(index + 1);
46
+ if (!/^\d+$/.test(clientId)) return null;
47
+ return { clientId, clientSecret: credential.slice(0, index) };
48
+ }
49
+ function clearPulseToken() {
50
+ cached = null;
51
+ }
52
+ async function getPulseToken(config, fetchImpl = fetch) {
53
+ if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
54
+ if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
55
+ return cached.token;
56
+ }
57
+ if (inflight !== null) return inflight;
58
+ const credentials = parsePulseAuth(config.pulseAuth);
59
+ if (credentials === null) return null;
60
+ inflight = (async () => {
61
+ try {
62
+ const response = await fetchImpl(buildTokenUrl(config.endpoint), {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ Accept: "application/json",
67
+ "User-Agent": "@patchstack/connect"
68
+ },
69
+ body: JSON.stringify({
70
+ grant_type: "client_credentials",
71
+ client_id: credentials.clientId,
72
+ client_secret: credentials.clientSecret
73
+ }),
74
+ signal: AbortSignal.timeout(config.timeoutMs)
75
+ });
76
+ if (!response.ok) return null;
77
+ const body = await response.json();
78
+ if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
79
+ const expiresIn = Number(body.expires_in);
80
+ const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
81
+ cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
82
+ return body.access_token;
83
+ } catch {
84
+ return null;
85
+ } finally {
86
+ inflight = null;
87
+ }
88
+ })();
89
+ return inflight;
90
+ }
91
+ async function pulseAuthHeader(config, fetchImpl = fetch) {
92
+ const token = await getPulseToken(config, fetchImpl);
93
+ return token === null ? {} : { Authorization: `Bearer ${token}` };
94
+ }
95
+ async function pulseFetch(config, url, init, fetchImpl = fetch) {
96
+ const send = async () => {
97
+ const auth = await pulseAuthHeader(config, fetchImpl);
98
+ const response = await fetchImpl(url, {
99
+ ...init,
100
+ headers: { ...init.headers, ...auth }
101
+ });
102
+ return { response, authenticated: auth.Authorization !== void 0 };
103
+ };
104
+ const first = await send();
105
+ if (first.response.status === 401 && first.authenticated) {
106
+ clearPulseToken();
107
+ return (await send()).response;
108
+ }
109
+ return first.response;
110
+ }
111
+ var TOKEN_SKEW_MS, cached, inflight;
112
+ var init_pulse_token = __esm({
113
+ "src/pulse-token.ts"() {
114
+ "use strict";
115
+ TOKEN_SKEW_MS = 6e4;
116
+ cached = null;
117
+ inflight = null;
118
+ }
119
+ });
120
+
33
121
  // src/types.ts
34
122
  var PatchstackError;
35
123
  var init_types = __esm({
@@ -58,7 +146,7 @@ async function postManifest(config, payload) {
58
146
  const timeoutMs = config.timeoutMs;
59
147
  let response;
60
148
  try {
61
- response = await fetch(url, {
149
+ response = await pulseFetch(config, url, {
62
150
  method: "POST",
63
151
  headers: {
64
152
  "Content-Type": "application/json",
@@ -123,6 +211,7 @@ var init_client = __esm({
123
211
  "src/client.ts"() {
124
212
  "use strict";
125
213
  init_types();
214
+ init_pulse_token();
126
215
  DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
127
216
  DEFAULT_TIMEOUT_MS = 3e4;
128
217
  }
@@ -155,8 +244,12 @@ async function resolveConfig(options) {
155
244
  "CONFIG_MISSING"
156
245
  );
157
246
  }
247
+ const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
248
+ const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
158
249
  return {
159
250
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
251
+ apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
252
+ pulseAuth: pulseAuthRaw === null || pulseAuthRaw.length === 0 ? null : pulseAuthRaw,
160
253
  endpoint,
161
254
  timeoutMs,
162
255
  environment,
@@ -164,7 +257,7 @@ async function resolveConfig(options) {
164
257
  };
165
258
  }
166
259
  async function readConfigFile(cwd) {
167
- const target = import_node_path2.default.join(cwd, CONFIG_FILENAME);
260
+ const target = import_node_path.default.join(cwd, CONFIG_FILENAME);
168
261
  let raw;
169
262
  try {
170
263
  raw = await (0, import_promises.readFile)(target, "utf8");
@@ -204,6 +297,8 @@ function readEnv() {
204
297
  const environmentRaw = process.env.PATCHSTACK_ENVIRONMENT;
205
298
  return {
206
299
  siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
300
+ apiKey: process.env.PATCHSTACK_API_KEY ?? void 0,
301
+ pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? void 0,
207
302
  endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
208
303
  timeoutMs,
209
304
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
@@ -215,12 +310,12 @@ function isUuid(value) {
215
310
  function isEnvironment(value) {
216
311
  return value === "production" || value === "sandbox";
217
312
  }
218
- var import_promises, import_node_path2, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
313
+ var import_promises, import_node_path, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
219
314
  var init_config = __esm({
220
315
  "src/config.ts"() {
221
316
  "use strict";
222
317
  import_promises = require("fs/promises");
223
- import_node_path2 = __toESM(require("path"), 1);
318
+ import_node_path = __toESM(require("path"), 1);
224
319
  init_types();
225
320
  init_client();
226
321
  CONFIG_FILENAME = ".patchstackrc.json";
@@ -232,7 +327,7 @@ var init_config = __esm({
232
327
  async function readDeclaredDependencyNames(cwd) {
233
328
  let raw;
234
329
  try {
235
- raw = await (0, import_promises2.readFile)(import_node_path3.default.join(cwd, "package.json"), "utf8");
330
+ raw = await (0, import_promises2.readFile)(import_node_path2.default.join(cwd, "package.json"), "utf8");
236
331
  } catch {
237
332
  return [];
238
333
  }
@@ -264,12 +359,12 @@ function missingDependencies(declared, packages) {
264
359
  const present = new Set(packages.map((entry) => entry.name));
265
360
  return declared.filter((name) => !present.has(name));
266
361
  }
267
- var import_promises2, import_node_path3;
362
+ var import_promises2, import_node_path2;
268
363
  var init_consistency = __esm({
269
364
  "src/parsers/consistency.ts"() {
270
365
  "use strict";
271
366
  import_promises2 = require("fs/promises");
272
- import_node_path3 = __toESM(require("path"), 1);
367
+ import_node_path2 = __toESM(require("path"), 1);
273
368
  }
274
369
  });
275
370
 
@@ -335,7 +430,7 @@ function extractFromV1(deps, acc = [], depth = 0) {
335
430
  return acc;
336
431
  }
337
432
  function extractNameFromPath(pkgPath) {
338
- const segments = pkgPath.split("node_modules" + import_node_path4.default.sep === pkgPath ? import_node_path4.default.sep : "/");
433
+ const segments = pkgPath.split("node_modules" + import_node_path3.default.sep === pkgPath ? import_node_path3.default.sep : "/");
339
434
  const parts = pkgPath.split("/");
340
435
  const nmIndex = parts.lastIndexOf("node_modules");
341
436
  if (nmIndex === -1 || nmIndex >= parts.length - 1) {
@@ -356,19 +451,19 @@ function isDirectV2(pkgPath) {
356
451
  const nmCount = parts.filter((p) => p === "node_modules").length;
357
452
  return nmCount === 1;
358
453
  }
359
- var import_promises3, import_node_path4;
454
+ var import_promises3, import_node_path3;
360
455
  var init_npm = __esm({
361
456
  "src/parsers/npm.ts"() {
362
457
  "use strict";
363
458
  import_promises3 = require("fs/promises");
364
- import_node_path4 = __toESM(require("path"), 1);
459
+ import_node_path3 = __toESM(require("path"), 1);
365
460
  init_types();
366
461
  }
367
462
  });
368
463
 
369
464
  // src/parsers/node_modules.ts
370
465
  async function walkNodeModules(cwd) {
371
- const root = import_node_path5.default.join(cwd, "node_modules");
466
+ const root = import_node_path4.default.join(cwd, "node_modules");
372
467
  try {
373
468
  const info = await (0, import_promises4.stat)(root);
374
469
  if (!info.isDirectory()) {
@@ -402,7 +497,7 @@ async function walk(dir, acc, depth) {
402
497
  if (name.startsWith(".")) {
403
498
  continue;
404
499
  }
405
- const fullPath = import_node_path5.default.join(dir, name);
500
+ const fullPath = import_node_path4.default.join(dir, name);
406
501
  if (!await isPlainDirectory(fullPath)) {
407
502
  continue;
408
503
  }
@@ -417,7 +512,7 @@ async function walk(dir, acc, depth) {
417
512
  if (sub.startsWith(".")) {
418
513
  continue;
419
514
  }
420
- const scopedDir = import_node_path5.default.join(fullPath, sub);
515
+ const scopedDir = import_node_path4.default.join(fullPath, sub);
421
516
  if (!await isPlainDirectory(scopedDir)) {
422
517
  continue;
423
518
  }
@@ -433,7 +528,7 @@ async function walk(dir, acc, depth) {
433
528
  async function readPackage(pkgDir, depth, acc) {
434
529
  let raw;
435
530
  try {
436
- raw = await (0, import_promises4.readFile)(import_node_path5.default.join(pkgDir, "package.json"), "utf8");
531
+ raw = await (0, import_promises4.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
437
532
  } catch {
438
533
  return;
439
534
  }
@@ -456,7 +551,7 @@ async function readPackage(pkgDir, depth, acc) {
456
551
  });
457
552
  }
458
553
  async function walkNested(pkgDir, acc, depth) {
459
- const nested = import_node_path5.default.join(pkgDir, "node_modules");
554
+ const nested = import_node_path4.default.join(pkgDir, "node_modules");
460
555
  if (!await isPlainDirectory(nested)) {
461
556
  return;
462
557
  }
@@ -470,12 +565,12 @@ async function isPlainDirectory(dir) {
470
565
  return false;
471
566
  }
472
567
  }
473
- var import_promises4, import_node_path5;
568
+ var import_promises4, import_node_path4;
474
569
  var init_node_modules = __esm({
475
570
  "src/parsers/node_modules.ts"() {
476
571
  "use strict";
477
572
  import_promises4 = require("fs/promises");
478
- import_node_path5 = __toESM(require("path"), 1);
573
+ import_node_path4 = __toESM(require("path"), 1);
479
574
  init_types();
480
575
  }
481
576
  });
@@ -758,7 +853,7 @@ async function parseYarnLockfile(lockfilePath) {
758
853
  "LOCKFILE_PARSE_ERROR"
759
854
  );
760
855
  }
761
- const directNames = await readDirectDepNames(import_node_path6.default.dirname(lockfilePath));
856
+ const directNames = await readDirectDepNames(import_node_path5.default.dirname(lockfilePath));
762
857
  const entries = [];
763
858
  const seen = /* @__PURE__ */ new Set();
764
859
  for (const block of blocks) {
@@ -902,7 +997,7 @@ async function readDirectDepNames(cwd) {
902
997
  const names = /* @__PURE__ */ new Set();
903
998
  let raw;
904
999
  try {
905
- raw = await (0, import_promises6.readFile)(import_node_path6.default.join(cwd, "package.json"), "utf8");
1000
+ raw = await (0, import_promises6.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
906
1001
  } catch {
907
1002
  return names;
908
1003
  }
@@ -927,12 +1022,12 @@ async function readDirectDepNames(cwd) {
927
1022
  }
928
1023
  return names;
929
1024
  }
930
- var import_promises6, import_node_path6;
1025
+ var import_promises6, import_node_path5;
931
1026
  var init_yarn = __esm({
932
1027
  "src/parsers/yarn.ts"() {
933
1028
  "use strict";
934
1029
  import_promises6 = require("fs/promises");
935
- import_node_path6 = __toESM(require("path"), 1);
1030
+ import_node_path5 = __toESM(require("path"), 1);
936
1031
  init_types();
937
1032
  }
938
1033
  });
@@ -941,7 +1036,7 @@ var init_yarn = __esm({
941
1036
  async function detectLockfile(cwd) {
942
1037
  const probed = await Promise.all(
943
1038
  LOCKFILE_CANDIDATES.map(async (candidate) => {
944
- const filePath = import_node_path7.default.join(cwd, candidate.filename);
1039
+ const filePath = import_node_path6.default.join(cwd, candidate.filename);
945
1040
  return { ...candidate, filePath, present: await exists(filePath) };
946
1041
  })
947
1042
  );
@@ -1019,7 +1114,7 @@ function staleWarning(source, missing) {
1019
1114
  async function presentLockfiles(cwd) {
1020
1115
  const probed = await Promise.all(
1021
1116
  LOCKFILE_CANDIDATES.map(async (candidate) => {
1022
- const filePath = import_node_path7.default.join(cwd, candidate.filename);
1117
+ const filePath = import_node_path6.default.join(cwd, candidate.filename);
1023
1118
  return { ...candidate, filePath, present: await exists(filePath) };
1024
1119
  })
1025
1120
  );
@@ -1045,12 +1140,12 @@ async function exists(filePath) {
1045
1140
  return false;
1046
1141
  }
1047
1142
  }
1048
- var import_promises7, import_node_path7, LOCKFILE_CANDIDATES;
1143
+ var import_promises7, import_node_path6, LOCKFILE_CANDIDATES;
1049
1144
  var init_parsers = __esm({
1050
1145
  "src/parsers/index.ts"() {
1051
1146
  "use strict";
1052
1147
  import_promises7 = require("fs/promises");
1053
- import_node_path7 = __toESM(require("path"), 1);
1148
+ import_node_path6 = __toESM(require("path"), 1);
1054
1149
  init_types();
1055
1150
  init_consistency();
1056
1151
  init_npm();
@@ -1209,6 +1304,29 @@ __export(runtime_exports, {
1209
1304
  });
1210
1305
  module.exports = __toCommonJS(runtime_exports);
1211
1306
 
1307
+ // src/protect/safe-origin.js
1308
+ function isSafeOrigin(value) {
1309
+ try {
1310
+ const u = new URL(value);
1311
+ if (u.protocol === "https:") return true;
1312
+ return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
1313
+ } catch {
1314
+ return false;
1315
+ }
1316
+ }
1317
+ function safeBaseUrl(candidate, fallback, label) {
1318
+ if (typeof candidate !== "string" || candidate === "") return fallback;
1319
+ if (isSafeOrigin(candidate)) return candidate;
1320
+ warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
1321
+ return fallback;
1322
+ }
1323
+ var warned = /* @__PURE__ */ new Set();
1324
+ function warnOnce(key, message) {
1325
+ if (warned.has(key)) return;
1326
+ warned.add(key);
1327
+ console.warn(message);
1328
+ }
1329
+
1212
1330
  // src/protect/engine/client.js
1213
1331
  var DEFAULT_BASE_URL = "https://api.patchstack.com";
1214
1332
  var DEFAULT_CACHE_TTL = 3e5;
@@ -1221,9 +1339,11 @@ var PatchstackRuleClient = class {
1221
1339
  #cacheTime = null;
1222
1340
  #ttlEffective = 0;
1223
1341
  #etag;
1224
- constructor({ token, baseUrl, cacheTtl, etag } = {}) {
1342
+ #timeoutMs;
1343
+ constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
1344
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
1225
1345
  this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
1226
- this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL ?? DEFAULT_BASE_URL;
1346
+ this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_WAF_API_URL, DEFAULT_BASE_URL, "rule endpoint");
1227
1347
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
1228
1348
  this.#etag = etag ?? null;
1229
1349
  if (!this.#token) {
@@ -1248,7 +1368,7 @@ var PatchstackRuleClient = class {
1248
1368
  method: "POST",
1249
1369
  headers,
1250
1370
  body: JSON.stringify({}),
1251
- signal: AbortSignal.timeout(3e4)
1371
+ signal: AbortSignal.timeout(this.#timeoutMs)
1252
1372
  });
1253
1373
  if (response.status === 304) {
1254
1374
  this.#touch(now);
@@ -1301,6 +1421,9 @@ var PatchstackRuleClient = class {
1301
1421
  };
1302
1422
 
1303
1423
  // src/protect/engine/request.js
1424
+ var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
1425
+ var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
1426
+ var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
1304
1427
  function base64DecodeUtf8(value) {
1305
1428
  const str = String(value);
1306
1429
  if (typeof Buffer !== "undefined") {
@@ -1504,7 +1627,9 @@ var RequestResolver = class {
1504
1627
  default: {
1505
1628
  if (key.startsWith("HTTP_")) {
1506
1629
  const headerName = key.substring(5).toLowerCase().replace(/_/g, "-");
1507
- return req.headers?.[headerName] ? [req.headers[headerName]] : [];
1630
+ const headers = req.headers;
1631
+ if (headers === null || typeof headers !== "object") return [];
1632
+ return Object.prototype.hasOwnProperty.call(headers, headerName) ? [headers[headerName]] : [];
1508
1633
  }
1509
1634
  return [];
1510
1635
  }
@@ -1512,14 +1637,32 @@ var RequestResolver = class {
1512
1637
  }
1513
1638
  #resolveFiles(key) {
1514
1639
  const files = this.#req.files;
1515
- if (!files) {
1640
+ if (!files || typeof files !== "object") {
1516
1641
  return [];
1517
1642
  }
1643
+ const dot = key.lastIndexOf(".");
1644
+ if (dot !== -1 && FILE_ATTRS.has(key.slice(dot + 1)) && Object.prototype.hasOwnProperty.call(files, key.slice(0, dot))) {
1645
+ const attr = key.slice(dot + 1);
1646
+ const entry = files[key.slice(0, dot)];
1647
+ const list = Array.isArray(entry) ? entry : [entry];
1648
+ const out = [];
1649
+ for (const f of list) {
1650
+ const v = fileAttribute(f, attr);
1651
+ if (v !== void 0 && v !== "") out.push(v);
1652
+ }
1653
+ return out;
1654
+ }
1655
+ const filenamesOf = (entry) => Array.isArray(entry) ? entry.map(fileFilename) : [fileFilename(entry)];
1518
1656
  if (key.endsWith("*")) {
1519
- return this.#resolveWildcard(files, key);
1657
+ const prefix = key.slice(0, -1);
1658
+ const out = [];
1659
+ for (const [k, entry] of Object.entries(files)) {
1660
+ if (k.startsWith(prefix)) out.push(...filenamesOf(entry));
1661
+ }
1662
+ return out.filter((v) => v !== void 0);
1520
1663
  }
1521
- const value = files[key];
1522
- return value !== void 0 ? [value] : [];
1664
+ if (!Object.prototype.hasOwnProperty.call(files, key)) return [];
1665
+ return filenamesOf(files[key]).filter((v) => v !== void 0);
1523
1666
  }
1524
1667
  #resolveRaw() {
1525
1668
  if (typeof this.#req._rawBody === "string") {
@@ -1550,6 +1693,9 @@ var RequestResolver = class {
1550
1693
  if (body) {
1551
1694
  parts.push(typeof body === "string" ? body : JSON.stringify(body));
1552
1695
  }
1696
+ if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
1697
+ parts.push(this.#req._rawBody);
1698
+ }
1553
1699
  const headers = this.#req.headers ?? {};
1554
1700
  const excludedHeaders = /* @__PURE__ */ new Set([
1555
1701
  "host",
@@ -1742,8 +1888,6 @@ function removeSqlComments(value) {
1742
1888
  let result = value;
1743
1889
  result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
1744
1890
  result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
1745
- result = result.replace(/--[^\r\n]*/g, "");
1746
- result = result.replace(/#[^\r\n]*/g, "");
1747
1891
  return result;
1748
1892
  }
1749
1893
  function removeNullBytes(value) {
@@ -1798,17 +1942,21 @@ function normalizeRequest(req, options = {}) {
1798
1942
  _rawBody: rawBody
1799
1943
  };
1800
1944
  }
1801
- function normalizeObject(value, options = {}) {
1945
+ var MAX_NORMALIZE_DEPTH = 200;
1946
+ function normalizeObject(value, options = {}, depth = 0) {
1802
1947
  if (typeof value === "string") {
1803
1948
  return normalize(value, options);
1804
1949
  }
1950
+ if (depth >= MAX_NORMALIZE_DEPTH) {
1951
+ return value;
1952
+ }
1805
1953
  if (Array.isArray(value)) {
1806
- return value.map((item) => normalizeObject(item, options));
1954
+ return value.map((item) => normalizeObject(item, options, depth + 1));
1807
1955
  }
1808
1956
  if (typeof value === "object" && value !== null) {
1809
1957
  const result = {};
1810
1958
  for (const [key, val] of Object.entries(value)) {
1811
- result[key] = normalizeObject(val, options);
1959
+ result[key] = normalizeObject(val, options, depth + 1);
1812
1960
  }
1813
1961
  return result;
1814
1962
  }
@@ -1816,14 +1964,32 @@ function normalizeObject(value, options = {}) {
1816
1964
  }
1817
1965
 
1818
1966
  // src/protect/engine/engine.js
1967
+ var GRP = "(?:[^()]|\\([^()]*\\))*";
1819
1968
  var REDOS_PATTERNS = [
1820
1969
  /\([^)]*[+*}][^)]*\)\s*[+*]/,
1821
- /\([^)]*\|[^)]*\)\s*[+*]/
1970
+ /\([^)]*\|[^)]*\)\s*[+*]/,
1971
+ new RegExp("\\(" + GRP + "[+*}]" + GRP + "\\)\\s*[+*]"),
1972
+ // nested quantified subgroup
1973
+ new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
1974
+ // nested alternation under a quantifier
1822
1975
  ];
1976
+ var warnedRejectedPatterns = /* @__PURE__ */ new Set();
1977
+ function warnRejectedPatternOnce(pattern) {
1978
+ const key = String(pattern);
1979
+ if (warnedRejectedPatterns.has(key)) return;
1980
+ warnedRejectedPatterns.add(key);
1981
+ console.warn(
1982
+ `[patchstack] rule_v2 regex pattern rejected (unsafe or invalid) \u2014 condition treated as no-match. The rule relying on it is NOT enforced: ${key}`
1983
+ );
1984
+ }
1985
+ var MAX_PATTERN_LENGTH = 1e3;
1823
1986
  function safeRegExp(pattern) {
1824
1987
  if (!pattern) {
1825
1988
  return null;
1826
1989
  }
1990
+ if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
1991
+ return null;
1992
+ }
1827
1993
  for (const dangerous of REDOS_PATTERNS) {
1828
1994
  if (dangerous.test(pattern)) {
1829
1995
  return null;
@@ -1880,6 +2046,34 @@ function arrayKeyValue(value, matchObj) {
1880
2046
  }
1881
2047
  return false;
1882
2048
  }
2049
+ var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
2050
+ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
2051
+ const out = [];
2052
+ const stack = [[root, 0]];
2053
+ let visited = 0;
2054
+ while (stack.length) {
2055
+ const [node, depth] = stack.pop();
2056
+ if (node === null || node === void 0) continue;
2057
+ if (typeof node !== "object") {
2058
+ out.push(node);
2059
+ continue;
2060
+ }
2061
+ if (depth >= maxDepth || visited >= nodeCap) continue;
2062
+ visited++;
2063
+ if (Array.isArray(node)) {
2064
+ for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
2065
+ } else {
2066
+ for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
2067
+ }
2068
+ }
2069
+ return out;
2070
+ }
2071
+ var warnedKeys = /* @__PURE__ */ new Set();
2072
+ function warnOnce2(key, message) {
2073
+ if (warnedKeys.has(key)) return;
2074
+ warnedKeys.add(key);
2075
+ console.warn(message);
2076
+ }
1883
2077
  var warnedMatchTypes = /* @__PURE__ */ new Set();
1884
2078
  function warnUnsupportedMatchType(type) {
1885
2079
  if (type == null || warnedMatchTypes.has(type)) {
@@ -1892,28 +2086,93 @@ function warnUnsupportedMatchType(type) {
1892
2086
  }
1893
2087
  function isInternalHost(hostname) {
1894
2088
  if (!hostname) return false;
1895
- const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
2089
+ let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
2090
+ host = host.replace(/%[^\]]*$/, "");
2091
+ host = host.replace(/\.$/, "");
1896
2092
  if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
1897
2093
  if (host === "metadata.google.internal") return true;
1898
- if (host === "::1" || host === "::") return true;
1899
- if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
1900
- const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
1901
- if (v4 && isPrivateV4(Number(v4[1]), Number(v4[2]))) return true;
1902
- const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
1903
- if (mapped) {
1904
- const g1 = parseInt(mapped[1], 16);
1905
- const g2 = parseInt(mapped[2], 16);
1906
- if (isPrivateV4(g1 >> 8 & 255, g1 & 255)) return true;
2094
+ if (host.includes(":")) {
2095
+ const g = expandIPv6(host);
2096
+ if (!g) return false;
2097
+ const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
2098
+ if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
2099
+ if ((g[0] & 65472) === 65152) return true;
2100
+ if ((g[0] & 65024) === 64512) return true;
2101
+ if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
2102
+ return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
2103
+ }
2104
+ return false;
1907
2105
  }
2106
+ const v4 = parseIPv4ToInt(host);
2107
+ if (v4 !== null) return isPrivateV4Int(v4);
1908
2108
  return false;
1909
2109
  }
1910
- function isPrivateV4(a, b) {
2110
+ function isPrivateV4Int(n) {
2111
+ const a = n >>> 24 & 255;
2112
+ const b = n >>> 16 & 255;
1911
2113
  if (a === 127 || a === 10 || a === 0) return true;
1912
2114
  if (a === 169 && b === 254) return true;
1913
2115
  if (a === 172 && b >= 16 && b <= 31) return true;
1914
2116
  if (a === 192 && b === 168) return true;
2117
+ if (a === 100 && b >= 64 && b <= 127) return true;
1915
2118
  return false;
1916
2119
  }
2120
+ function parseIPv4ToInt(host) {
2121
+ if (!/^[0-9a-fx]+(\.[0-9a-fx]+)*$/i.test(host)) return null;
2122
+ const parts = host.split(".");
2123
+ if (parts.length > 4) return null;
2124
+ const nums = [];
2125
+ for (const p of parts) {
2126
+ let n;
2127
+ if (/^0x[0-9a-f]+$/i.test(p)) n = parseInt(p, 16);
2128
+ else if (/^0[0-7]+$/.test(p)) n = parseInt(p, 8);
2129
+ else if (/^[0-9]+$/.test(p)) n = parseInt(p, 10);
2130
+ else return null;
2131
+ if (!Number.isInteger(n) || n < 0) return null;
2132
+ nums.push(n);
2133
+ }
2134
+ const last = nums.length - 1;
2135
+ let value = 0;
2136
+ for (let i = 0; i < last; i++) {
2137
+ if (nums[i] > 255) return null;
2138
+ value += nums[i] * 2 ** (8 * (3 - i));
2139
+ }
2140
+ const maxLast = 2 ** (8 * (5 - nums.length)) - 1;
2141
+ if (nums[last] > maxLast) return null;
2142
+ value += nums[last];
2143
+ if (value < 0 || value > 4294967295) return null;
2144
+ return value >>> 0;
2145
+ }
2146
+ function expandIPv6(host) {
2147
+ if (!host.includes(":")) return null;
2148
+ let s = host;
2149
+ const lastColon = s.lastIndexOf(":");
2150
+ const tail = s.slice(lastColon + 1);
2151
+ if (tail.includes(".")) {
2152
+ const v4 = parseIPv4ToInt(tail);
2153
+ if (v4 === null) return null;
2154
+ s = s.slice(0, lastColon + 1) + (v4 >>> 16 & 65535).toString(16) + ":" + (v4 & 65535).toString(16);
2155
+ }
2156
+ const halves = s.split("::");
2157
+ if (halves.length > 2) return null;
2158
+ const head = halves[0] ? halves[0].split(":") : [];
2159
+ const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
2160
+ let groups;
2161
+ if (back === null) {
2162
+ groups = head;
2163
+ } else {
2164
+ const fill = 8 - head.length - back.length;
2165
+ if (fill < 0) return null;
2166
+ groups = [...head, ...Array(fill).fill("0"), ...back];
2167
+ }
2168
+ if (groups.length !== 8) return null;
2169
+ const out = [];
2170
+ for (const g of groups) {
2171
+ if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
2172
+ out.push(parseInt(g, 16));
2173
+ }
2174
+ return out;
2175
+ }
1917
2176
  function ruleAppliesTo(when, resolver) {
1918
2177
  try {
1919
2178
  if (when.method) {
@@ -1941,14 +2200,22 @@ function pathMatches(pattern, path7) {
1941
2200
  }
1942
2201
  return path7 === pattern;
1943
2202
  }
2203
+ function normalizeDefaultPort(host) {
2204
+ return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
2205
+ }
1944
2206
  function isCrossOrigin(resolver) {
1945
2207
  try {
1946
- const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
2208
+ const host = normalizeDefaultPort(String(resolver.resolve("server.HTTP_HOST")[0] ?? ""));
1947
2209
  if (!host) return false;
1948
- const src = resolver.resolve("server.HTTP_ORIGIN")[0] ?? resolver.resolve("server.HTTP_REFERER")[0];
1949
- if (!src) return false;
1950
- const srcHost = hostFromUrl(String(src));
1951
- return srcHost !== null && srcHost !== host;
2210
+ const originRaw = resolver.resolve("server.HTTP_ORIGIN")[0];
2211
+ const hasOrigin = originRaw !== void 0 && originRaw !== null;
2212
+ const src = hasOrigin ? originRaw : resolver.resolve("server.HTTP_REFERER")[0];
2213
+ if (src === void 0 || src === null) return false;
2214
+ const s = String(src).trim();
2215
+ if (hasOrigin && (s === "" || s.toLowerCase() === "null")) return true;
2216
+ const srcHost = hostFromUrl(s);
2217
+ if (srcHost === null) return hasOrigin;
2218
+ return normalizeDefaultPort(srcHost) !== host;
1952
2219
  } catch {
1953
2220
  return false;
1954
2221
  }
@@ -1960,6 +2227,41 @@ function hostFromUrl(value) {
1960
2227
  return null;
1961
2228
  }
1962
2229
  }
2230
+ function isOffOriginRedirect(resolver) {
2231
+ try {
2232
+ const status = Number(resolver.resolve("response.status")[0] ?? 0);
2233
+ if (status < 300 || status >= 400) return false;
2234
+ const location = resolver.resolve("response.header.location")[0];
2235
+ if (!location) return false;
2236
+ const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
2237
+ if (!host) return false;
2238
+ const loc = String(location).replace(/[\t\r\n]/g, "").replace(/\\/g, "/");
2239
+ let target;
2240
+ try {
2241
+ target = new URL(loc, "http://" + host).host.toLowerCase();
2242
+ } catch {
2243
+ return false;
2244
+ }
2245
+ return normalizeDefaultPort(target) !== normalizeDefaultPort(host);
2246
+ } catch {
2247
+ return false;
2248
+ }
2249
+ }
2250
+ function isReflectedCorsWithCredentials(resolver) {
2251
+ try {
2252
+ const acac = String(resolver.resolve("response.header.access-control-allow-credentials")[0] ?? "").toLowerCase();
2253
+ if (acac !== "true") return false;
2254
+ const acao = String(resolver.resolve("response.header.access-control-allow-origin")[0] ?? "");
2255
+ if (!acao) return false;
2256
+ if (acao === "*") return true;
2257
+ if (acao.toLowerCase() === "null") return true;
2258
+ const origin = String(resolver.resolve("server.HTTP_ORIGIN")[0] ?? "");
2259
+ if (!origin) return false;
2260
+ return acao === origin;
2261
+ } catch {
2262
+ return false;
2263
+ }
2264
+ }
1963
2265
  function matchValue(type, value, matchVal, matchObj) {
1964
2266
  if (value === null || value === void 0) {
1965
2267
  if (type === "isset") {
@@ -1967,7 +2269,16 @@ function matchValue(type, value, matchVal, matchObj) {
1967
2269
  }
1968
2270
  return false;
1969
2271
  }
1970
- const strValue = typeof value === "string" ? value : String(value);
2272
+ let strValue;
2273
+ if (typeof value === "string") {
2274
+ strValue = value;
2275
+ } else {
2276
+ try {
2277
+ strValue = String(value);
2278
+ } catch {
2279
+ strValue = "";
2280
+ }
2281
+ }
1971
2282
  switch (type) {
1972
2283
  case "equals":
1973
2284
  return strValue == matchVal;
@@ -1988,6 +2299,7 @@ function matchValue(type, value, matchVal, matchObj) {
1988
2299
  case "regex": {
1989
2300
  const regex = safeRegExp(matchVal);
1990
2301
  if (!regex) {
2302
+ warnRejectedPatternOnce(matchVal);
1991
2303
  return false;
1992
2304
  }
1993
2305
  return regex.test(strValue);
@@ -2066,6 +2378,15 @@ var RuleEngine = class {
2066
2378
  this.#whitelists = whitelists;
2067
2379
  this.#whitelistKeys = whitelist_keys;
2068
2380
  this.#onError = onError;
2381
+ if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
2382
+ warnOnce2(
2383
+ "whitelist-no-rule-id",
2384
+ "[patchstack] a whitelist has no `rule_id` \u2014 it suppresses ALL rules when it matches. Scope each whitelist to a specific rule_id, and key it only on values an attacker cannot set."
2385
+ );
2386
+ }
2387
+ if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
2388
+ warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
2389
+ }
2069
2390
  }
2070
2391
  // A mitigation engine must never take down the app it protects: any error while
2071
2392
  // evaluating a rule is reported and the request is allowed through (fail open). A
@@ -2146,6 +2467,12 @@ var RuleEngine = class {
2146
2467
  if (match && match.type === "cross_origin") {
2147
2468
  return isCrossOrigin(resolver);
2148
2469
  }
2470
+ if (match && match.type === "off_origin") {
2471
+ return isOffOriginRedirect(resolver);
2472
+ }
2473
+ if (match && match.type === "cors_reflected") {
2474
+ return isReflectedCorsWithCredentials(resolver);
2475
+ }
2149
2476
  const params = Array.isArray(parameter) ? parameter : [parameter];
2150
2477
  const values = params.flatMap((p) => resolver.resolve(p));
2151
2478
  if (values.length === 0) {
@@ -2161,10 +2488,12 @@ var RuleEngine = class {
2161
2488
  }
2162
2489
  continue;
2163
2490
  }
2164
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
2165
- for (const v of Object.values(value)) {
2166
- if (matchValue(match.type, v, match.value, match)) {
2167
- return true;
2491
+ if (typeof value === "object" && value !== null) {
2492
+ if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
2493
+ if (matchValue(match.type, value, match.value, match)) return true;
2494
+ } else {
2495
+ for (const leaf of collectLeafValues(value)) {
2496
+ if (matchValue(match.type, leaf, match.value, match)) return true;
2168
2497
  }
2169
2498
  }
2170
2499
  continue;
@@ -2196,22 +2525,27 @@ var RuleEngine = class {
2196
2525
  };
2197
2526
 
2198
2527
  // src/protect/engine/pulse-client.js
2528
+ init_pulse_token();
2199
2529
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2200
2530
  var DEFAULT_CACHE_TTL2 = 3e5;
2201
2531
  var JITTER_FRACTION2 = 0.1;
2202
2532
  var PulseRuleClient = class {
2203
2533
  #siteUuid;
2534
+ #timeoutMs;
2204
2535
  #baseUrl;
2205
2536
  #cacheTtl;
2206
2537
  #cache = null;
2207
2538
  #cacheTime = null;
2208
2539
  #ttlEffective = 0;
2209
2540
  #etag;
2210
- constructor({ siteUuid, baseUrl, cacheTtl, etag } = {}) {
2541
+ #pulseAuth;
2542
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
2543
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2211
2544
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2212
- this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL2;
2545
+ this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
2213
2546
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
2214
2547
  this.#etag = etag ?? null;
2548
+ this.#pulseAuth = pulseAuth ?? null;
2215
2549
  if (!this.#siteUuid) {
2216
2550
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
2217
2551
  }
@@ -2223,9 +2557,15 @@ var PulseRuleClient = class {
2223
2557
  }
2224
2558
  const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
2225
2559
  try {
2226
- const headers = { Accept: "application/json" };
2560
+ const headers = {
2561
+ Accept: "application/json",
2562
+ ...await pulseAuthHeader(
2563
+ { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
2564
+ fetch
2565
+ )
2566
+ };
2227
2567
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2228
- const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(3e4) });
2568
+ const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2229
2569
  if (response.status === 304) {
2230
2570
  this.#touch(now);
2231
2571
  return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -2302,25 +2642,9 @@ async function fromFetchRequest(request, options = {}) {
2302
2642
  let body = {};
2303
2643
  let files;
2304
2644
  if (rawBody) {
2305
- if (contentType.includes("application/json")) {
2306
- try {
2307
- body = JSON.parse(rawBody);
2308
- } catch {
2309
- body = {};
2310
- }
2311
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
2312
- body = {};
2313
- for (const [k, v] of new URLSearchParams(rawBody)) {
2314
- body[k] = k in body ? [].concat(body[k], v) : v;
2315
- }
2316
- } else if (contentType.includes("multipart/form-data")) {
2317
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
2318
- if (boundary) {
2319
- const parsed = parseMultipart(rawBody, boundary);
2320
- body = parsed.body;
2321
- files = parsed.files;
2322
- }
2323
- }
2645
+ const parsed = parseBody(rawBody, contentType);
2646
+ body = parsed.body;
2647
+ files = parsed.files;
2324
2648
  }
2325
2649
  const uri = url.pathname + url.search;
2326
2650
  const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
@@ -2340,9 +2664,6 @@ async function fromFetchRequest(request, options = {}) {
2340
2664
  };
2341
2665
  }
2342
2666
  async function readCappedText(request, max) {
2343
- const ceiling = Math.max(max, max * 4);
2344
- const declared = Number(request.headers?.get?.("content-length") || 0);
2345
- if (declared && declared > ceiling) return "";
2346
2667
  let clone;
2347
2668
  try {
2348
2669
  clone = request.clone();
@@ -2388,6 +2709,31 @@ function concatChunks(chunks, total) {
2388
2709
  }
2389
2710
  return out;
2390
2711
  }
2712
+ function parseBody(rawBody, contentType) {
2713
+ const ct = String(contentType || "").toLowerCase();
2714
+ const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
2715
+ const isForm = ct.includes("application/x-www-form-urlencoded");
2716
+ const isMultipart = ct.includes("multipart/form-data");
2717
+ const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
2718
+ if (isMultipart) {
2719
+ const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
2720
+ if (boundary) return parseMultipart(rawBody, boundary);
2721
+ return { body: {}, files: void 0 };
2722
+ }
2723
+ if (isForm) {
2724
+ const body = {};
2725
+ for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
2726
+ return { body, files: void 0 };
2727
+ }
2728
+ if (isJson || isAmbiguous) {
2729
+ try {
2730
+ const parsed = JSON.parse(rawBody);
2731
+ if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
2732
+ } catch {
2733
+ }
2734
+ }
2735
+ return { body: {}, files: void 0 };
2736
+ }
2391
2737
  function parseMultipart(rawBody, boundary) {
2392
2738
  const body = {};
2393
2739
  const files = {};
@@ -2402,7 +2748,9 @@ function parseMultipart(rawBody, boundary) {
2402
2748
  const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
2403
2749
  const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
2404
2750
  if (filename !== void 0) {
2405
- files[name] = name in files ? [].concat(files[name], filename) : filename;
2751
+ const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2752
+ const file = { filename, type: partType, content };
2753
+ files[name] = name in files ? [].concat(files[name], file) : file;
2406
2754
  } else {
2407
2755
  body[name] = name in body ? [].concat(body[name], content) : content;
2408
2756
  }
@@ -2454,25 +2802,9 @@ function fromNodeRequest(req, rawBody = "") {
2454
2802
  let body = {};
2455
2803
  let files;
2456
2804
  if (rawBody) {
2457
- if (contentType.includes("application/json")) {
2458
- try {
2459
- body = JSON.parse(rawBody);
2460
- } catch {
2461
- body = {};
2462
- }
2463
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
2464
- body = {};
2465
- for (const [k, v] of new URLSearchParams(rawBody)) {
2466
- body[k] = k in body ? [].concat(body[k], v) : v;
2467
- }
2468
- } else if (contentType.includes("multipart/form-data")) {
2469
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
2470
- if (boundary) {
2471
- const parsed = parseMultipart(rawBody, boundary);
2472
- body = parsed.body;
2473
- files = parsed.files;
2474
- }
2475
- }
2805
+ const parsed = parseBody(rawBody, contentType);
2806
+ body = parsed.body;
2807
+ files = parsed.files;
2476
2808
  }
2477
2809
  const uri = url.pathname + url.search;
2478
2810
  const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
@@ -2506,7 +2838,7 @@ function parseCookies2(header) {
2506
2838
  }
2507
2839
 
2508
2840
  // src/protect/egress.js
2509
- async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
2841
+ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
2510
2842
  const restores = [];
2511
2843
  if (typeof shouldBlock !== "function") return () => {
2512
2844
  };
@@ -2528,11 +2860,24 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
2528
2860
  screen = null;
2529
2861
  }
2530
2862
  }
2863
+ const skip = (reason, detail) => {
2864
+ try {
2865
+ onSkip?.({ phase: "egress", reason, detail });
2866
+ } catch {
2867
+ }
2868
+ };
2531
2869
  const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
2532
- if (!screen || !host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
2870
+ if (!screen) {
2871
+ if (host && dnsScreen) skip("resolver-unavailable", { host });
2872
+ return resolve(false);
2873
+ }
2874
+ if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
2533
2875
  try {
2534
2876
  screen.lookup(host, { all: true }, (err, addresses) => {
2535
- if (err || !Array.isArray(addresses)) return resolve(false);
2877
+ if (err || !Array.isArray(addresses)) {
2878
+ skip("resolver-failed", { host });
2879
+ return resolve(false);
2880
+ }
2536
2881
  for (const a of addresses) {
2537
2882
  const ip = a && typeof a === "object" ? a.address : a;
2538
2883
  if (ip && block(url, ip, method)) return resolve(true);
@@ -2540,24 +2885,61 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
2540
2885
  resolve(false);
2541
2886
  });
2542
2887
  } catch {
2888
+ skip("resolver-failed", { host });
2543
2889
  resolve(false);
2544
2890
  }
2545
2891
  });
2546
2892
  const originalFetch = globalThis.fetch;
2547
2893
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2548
- const guarded = async (input, init) => {
2549
- const url = typeof input === "string" ? input : input && input.url || String(input);
2894
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
2895
+ const MAX_REDIRECTS = 20;
2896
+ const screenUrl = async (u, method) => {
2550
2897
  let host = null;
2551
2898
  try {
2552
- host = new URL(url).hostname;
2899
+ host = new URL(u).hostname;
2553
2900
  } catch {
2554
2901
  host = null;
2555
2902
  }
2556
- const method = init && init.method || input && input.method || "GET";
2557
- if (block(url, host, method) || await resolvesToDisallowed(url, host, method)) {
2558
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
2903
+ if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
2904
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
2905
+ }
2906
+ };
2907
+ const guarded = async (input, init) => {
2908
+ let cur;
2909
+ try {
2910
+ cur = new Request(input, { ...init || {}, redirect: "manual" });
2911
+ } catch {
2912
+ return originalFetch(input, init);
2913
+ }
2914
+ const callerRedirect = init && init.redirect || input && input.redirect || "follow";
2915
+ let url = cur.url;
2916
+ let method = cur.method;
2917
+ await screenUrl(url, method);
2918
+ if (callerRedirect !== "follow") return originalFetch(input, init);
2919
+ const headers = new Headers(cur.headers);
2920
+ const signal = cur.signal;
2921
+ let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
2922
+ for (let hop = 0; ; hop++) {
2923
+ const resp = await originalFetch(
2924
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
2925
+ );
2926
+ const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
2927
+ if (!location) return resp;
2928
+ if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
2929
+ const next = new URL(location, url).href;
2930
+ if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
2931
+ method = "GET";
2932
+ body = void 0;
2933
+ headers.delete("content-type");
2934
+ headers.delete("content-length");
2935
+ }
2936
+ if (new URL(next).origin !== new URL(url).origin) {
2937
+ headers.delete("authorization");
2938
+ headers.delete("cookie");
2939
+ }
2940
+ await screenUrl(next, method);
2941
+ url = next;
2559
2942
  }
2560
- return originalFetch(input, init);
2561
2943
  };
2562
2944
  guarded.__patchstackGuarded = true;
2563
2945
  globalThis.fetch = guarded;
@@ -2573,30 +2955,6 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
2573
2955
  } catch {
2574
2956
  }
2575
2957
  }
2576
- const OriginalWS = globalThis.WebSocket;
2577
- if (typeof OriginalWS === "function" && !OriginalWS.__patchstackGuarded) {
2578
- const GuardedWS = new Proxy(OriginalWS, {
2579
- construct(target, args, newTarget) {
2580
- const url = String(args?.[0] ?? "");
2581
- let host = null;
2582
- try {
2583
- host = new URL(url).hostname;
2584
- } catch {
2585
- host = null;
2586
- }
2587
- if (block(url, host, "WEBSOCKET")) {
2588
- throw new Error(`Patchstack blocked an outbound WebSocket to a disallowed address: ${host ?? url}`);
2589
- }
2590
- return Reflect.construct(target, args, newTarget);
2591
- }
2592
- });
2593
- OriginalWS.__patchstackGuarded = true;
2594
- globalThis.WebSocket = GuardedWS;
2595
- restores.push(() => {
2596
- if (globalThis.WebSocket === GuardedWS) globalThis.WebSocket = OriginalWS;
2597
- delete OriginalWS.__patchstackGuarded;
2598
- });
2599
- }
2600
2958
  return () => {
2601
2959
  for (const restore of restores) {
2602
2960
  try {
@@ -2727,6 +3085,7 @@ var DEFAULT_RESPONSE_RULES = [
2727
3085
  phase: "response",
2728
3086
  category: "secret-exposure",
2729
3087
  action: "redact",
3088
+ prefilter: ["PRIVATE KEY"],
2730
3089
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
2731
3090
  },
2732
3091
  {
@@ -2735,6 +3094,7 @@ var DEFAULT_RESPONSE_RULES = [
2735
3094
  phase: "response",
2736
3095
  category: "secret-exposure",
2737
3096
  action: "redact",
3097
+ prefilter: ["AKIA", "ASIA"],
2738
3098
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
2739
3099
  },
2740
3100
  {
@@ -2743,14 +3103,29 @@ var DEFAULT_RESPONSE_RULES = [
2743
3103
  phase: "response",
2744
3104
  category: "secret-exposure",
2745
3105
  action: "redact",
3106
+ prefilter: ["AIza"],
2746
3107
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
2747
3108
  },
3109
+ {
3110
+ id: "resp-vendor-api-key",
3111
+ title: "Vendor API key / token in response body",
3112
+ phase: "response",
3113
+ category: "secret-exposure",
3114
+ action: "redact",
3115
+ // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
3116
+ // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
3117
+ // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
3118
+ // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
3119
+ prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
3120
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
3121
+ },
2748
3122
  {
2749
3123
  id: "resp-jwt",
2750
3124
  title: "JWT in response body",
2751
3125
  phase: "response",
2752
3126
  category: "secret-exposure",
2753
3127
  action: "redact",
3128
+ prefilter: ["eyJ"],
2754
3129
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
2755
3130
  },
2756
3131
  {
@@ -2759,6 +3134,7 @@ var DEFAULT_RESPONSE_RULES = [
2759
3134
  phase: "response",
2760
3135
  category: "secret-exposure",
2761
3136
  action: "redact",
3137
+ prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
2762
3138
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
2763
3139
  },
2764
3140
  {
@@ -2767,6 +3143,8 @@ var DEFAULT_RESPONSE_RULES = [
2767
3143
  phase: "response",
2768
3144
  category: "info-exposure",
2769
3145
  action: "redact",
3146
+ // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
3147
+ // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
2770
3148
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
2771
3149
  },
2772
3150
  {
@@ -2775,6 +3153,7 @@ var DEFAULT_RESPONSE_RULES = [
2775
3153
  phase: "response",
2776
3154
  category: "info-exposure",
2777
3155
  action: "redact",
3156
+ prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
2778
3157
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
2779
3158
  },
2780
3159
  {
@@ -2786,6 +3165,7 @@ var DEFAULT_RESPONSE_RULES = [
2786
3165
  // Multi-language exception/traceback signatures a normal API response never carries:
2787
3166
  // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
2788
3167
  // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
3168
+ prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
2789
3169
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: '/(Traceback \\(most recent call last\\)|Exception in thread "|System\\.[A-Za-z.]+Exception|\\bat [\\w.$]+\\([\\w]+\\.(?:java|kt|scala|rb|py|cs):\\d+\\)|goroutine \\d+ \\[)/' } }]
2790
3170
  }
2791
3171
  ];
@@ -2883,8 +3263,22 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
2883
3263
  }
2884
3264
 
2885
3265
  // src/protect/rules/store.js
2886
- var import_node_fs = require("fs");
2887
- var import_node_path = require("path");
3266
+ var fsMod;
3267
+ async function loadFs() {
3268
+ if (fsMod !== void 0) return fsMod;
3269
+ try {
3270
+ const [fs, path7] = await Promise.all([import("fs"), import("path")]);
3271
+ fsMod = {
3272
+ readFileSync: fs.readFileSync,
3273
+ writeFileSync: fs.writeFileSync,
3274
+ mkdirSync: fs.mkdirSync,
3275
+ join: path7.join
3276
+ };
3277
+ } catch {
3278
+ fsMod = null;
3279
+ }
3280
+ return fsMod;
3281
+ }
2888
3282
  function makeStore(options = {}) {
2889
3283
  let mem = null;
2890
3284
  const durable = durableTier(options);
@@ -2926,21 +3320,22 @@ function durableTier(options) {
2926
3320
  write: async (env) => cacheWrite(dir, env)
2927
3321
  };
2928
3322
  }
2929
- function cachePath(dir) {
2930
- return (0, import_node_path.join)(dir, "patchstack-rules.json");
2931
- }
2932
- function cacheWrite(dir, env) {
3323
+ async function cacheWrite(dir, env) {
2933
3324
  if (!dir) return;
3325
+ const fs = await loadFs();
3326
+ if (!fs) return;
2934
3327
  try {
2935
- (0, import_node_fs.mkdirSync)(dir, { recursive: true });
2936
- (0, import_node_fs.writeFileSync)(cachePath(dir), JSON.stringify(env));
3328
+ fs.mkdirSync(dir, { recursive: true });
3329
+ fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
2937
3330
  } catch {
2938
3331
  }
2939
3332
  }
2940
- function cacheRead(dir) {
3333
+ async function cacheRead(dir) {
2941
3334
  if (!dir) return null;
3335
+ const fs = await loadFs();
3336
+ if (!fs) return null;
2942
3337
  try {
2943
- return toEnvelope(JSON.parse((0, import_node_fs.readFileSync)(cachePath(dir), "utf8")));
3338
+ return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
2944
3339
  } catch {
2945
3340
  return null;
2946
3341
  }
@@ -2956,56 +3351,204 @@ function toEnvelope(value) {
2956
3351
  return null;
2957
3352
  }
2958
3353
 
3354
+ // src/protect/rules/validate.js
3355
+ var LIMITS = {
3356
+ maxRules: 5e3,
3357
+ maxWhitelists: 2e3,
3358
+ maxConditionsPerRule: 250,
3359
+ maxNestingDepth: 12,
3360
+ maxRegexLength: 1e3,
3361
+ maxValueLength: 8192
3362
+ };
3363
+ var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
3364
+ var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
3365
+ function validateBundle(bundle, opts = {}) {
3366
+ const rejected = [];
3367
+ const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
3368
+ const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
3369
+ const firewall = [];
3370
+ for (const rule of inFirewall) {
3371
+ if (firewall.length >= LIMITS.maxRules) {
3372
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
3373
+ continue;
3374
+ }
3375
+ const reason = ruleProblem(rule);
3376
+ if (reason) rejected.push({ id: idOf(rule), reason });
3377
+ else firewall.push(rule);
3378
+ }
3379
+ const whitelists = [];
3380
+ for (const wl of inWhitelists) {
3381
+ if (whitelists.length >= LIMITS.maxWhitelists) {
3382
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
3383
+ continue;
3384
+ }
3385
+ if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
3386
+ rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
3387
+ continue;
3388
+ }
3389
+ const reason = conditionsProblem(wl?.rule_v2);
3390
+ if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
3391
+ else whitelists.push(wl);
3392
+ }
3393
+ return {
3394
+ bundle: { ...bundle, firewall, whitelists },
3395
+ rejected
3396
+ };
3397
+ }
3398
+ function idOf(rule) {
3399
+ const id = rule?.id ?? rule?.rule_id;
3400
+ return id === void 0 || id === null ? "(unidentified)" : String(id);
3401
+ }
3402
+ function ruleProblem(rule) {
3403
+ if (!rule || typeof rule !== "object") return "not an object";
3404
+ if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
3405
+ if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
3406
+ const capOverride = rule.max_bytes;
3407
+ if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
3408
+ return conditionsProblem(rule.rule_v2);
3409
+ }
3410
+ function conditionsProblem(conditions, depth = 0) {
3411
+ if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
3412
+ if (conditions.length === 0) return "rule_v2 is empty (would never match)";
3413
+ if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
3414
+ if (conditions.length > LIMITS.maxConditionsPerRule) {
3415
+ return `more than ${LIMITS.maxConditionsPerRule} conditions`;
3416
+ }
3417
+ for (const c of conditions) {
3418
+ if (!c || typeof c !== "object") return "condition is not an object";
3419
+ if (Array.isArray(c.rules)) {
3420
+ const nested = conditionsProblem(c.rules, depth + 1);
3421
+ if (nested) return nested;
3422
+ continue;
3423
+ }
3424
+ const m = c.match;
3425
+ if (!m || typeof m !== "object") return "condition has no match object";
3426
+ if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
3427
+ if (m.type === "regex") {
3428
+ if (typeof m.value !== "string") return "regex match.value must be a string";
3429
+ if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
3430
+ } else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
3431
+ return `match.value longer than ${LIMITS.maxValueLength} chars`;
3432
+ }
3433
+ if (m.match) {
3434
+ const nested = conditionsProblem([{ match: m.match }], depth + 1);
3435
+ if (nested) return nested;
3436
+ }
3437
+ }
3438
+ return null;
3439
+ }
3440
+
2959
3441
  // src/protect/rules/source.js
2960
- async function resolveRules(options, store) {
3442
+ function liveUpdateRejections(res, options) {
3443
+ if (options.acceptPartialBundle) return [];
3444
+ const { rejected } = validateBundle(
3445
+ { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
3446
+ { allowGlobalWhitelists: options.allowGlobalWhitelists }
3447
+ );
3448
+ return rejected;
3449
+ }
3450
+ function reportRejections(rejected, options, label) {
3451
+ const report = options.onRuleRejected;
3452
+ for (const r of rejected) {
3453
+ if (typeof report === "function") {
3454
+ try {
3455
+ report({ ...r, accepted: false });
3456
+ } catch {
3457
+ }
3458
+ }
3459
+ }
3460
+ const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
3461
+ options.onError?.(new Error(
3462
+ `${label}: rejected the entire update because ${rejected.length} rule(s) failed validation \u2014 keeping the previous ruleset and NOT caching this response: ${sample}${rejected.length > 3 ? ", \u2026" : ""}`
3463
+ ));
3464
+ }
3465
+ async function resolveRules(options, store, ctx = {}) {
3466
+ const timeoutMs = ctx.timeoutMs;
2961
3467
  if (options.siteUuid) {
2962
3468
  const prior = await store.read();
2963
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
3469
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
2964
3470
  const res = await client.getRules();
2965
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
3471
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2966
3472
  if (res.success && !res.notModified) {
2967
- const bundle = normalizeBundle(res);
3473
+ const rejected = liveUpdateRejections(res, options);
3474
+ if (rejected.length > 0) {
3475
+ reportRejections(rejected, options, "rule update rejected");
3476
+ if (prior?.bundle) return normalizeBundle(prior.bundle, options);
3477
+ if (options.rules) return normalizeBundle(options.rules, options);
3478
+ return emptyBundle();
3479
+ }
3480
+ const bundle = normalizeBundle(res, options);
2968
3481
  await store.write({ bundle, etag: res.etag ?? null });
2969
3482
  return bundle;
2970
3483
  }
2971
3484
  if (prior?.bundle) {
2972
3485
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
2973
- return normalizeBundle(prior.bundle);
3486
+ return normalizeBundle(prior.bundle, options);
2974
3487
  }
2975
3488
  if (options.rules) {
2976
3489
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
2977
- return normalizeBundle(options.rules);
3490
+ return normalizeBundle(options.rules, options);
2978
3491
  }
2979
3492
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
2980
3493
  return emptyBundle();
2981
3494
  }
2982
3495
  if (options.token) {
2983
3496
  const prior = await store.read();
2984
- const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
3497
+ const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
2985
3498
  const res = await client.getRules();
2986
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
3499
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2987
3500
  if (res.success && !res.notModified) {
2988
- const bundle = normalizeBundle(res);
3501
+ const rejected = liveUpdateRejections(res, options);
3502
+ if (rejected.length > 0) {
3503
+ reportRejections(rejected, options, "rule update rejected");
3504
+ if (prior?.bundle) return normalizeBundle(prior.bundle, options);
3505
+ if (options.rules) return normalizeBundle(options.rules, options);
3506
+ return emptyBundle();
3507
+ }
3508
+ const bundle = normalizeBundle(res, options);
2989
3509
  await store.write({ bundle, etag: res.etag ?? null });
2990
3510
  return bundle;
2991
3511
  }
2992
3512
  if (prior?.bundle) {
2993
3513
  options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
2994
- return normalizeBundle(prior.bundle);
3514
+ return normalizeBundle(prior.bundle, options);
2995
3515
  }
2996
3516
  options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
2997
3517
  return emptyBundle();
2998
3518
  }
2999
3519
  if (options.rules) {
3000
- return normalizeBundle(options.rules);
3520
+ return normalizeBundle(options.rules, options);
3001
3521
  }
3002
3522
  return emptyBundle();
3003
3523
  }
3004
- function normalizeBundle(b) {
3524
+ function normalizeBundle(b, options = {}) {
3005
3525
  const enforcement = b?.enforcement ?? b?.mode;
3526
+ const { bundle: checked, rejected } = validateBundle(
3527
+ {
3528
+ firewall: Array.isArray(b.firewall) ? b.firewall : [],
3529
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
3530
+ },
3531
+ { allowGlobalWhitelists: options.allowGlobalWhitelists }
3532
+ );
3533
+ if (rejected.length > 0) {
3534
+ const report = options.onRuleRejected;
3535
+ if (typeof report === "function") {
3536
+ for (const r of rejected) {
3537
+ try {
3538
+ report(r);
3539
+ } catch {
3540
+ }
3541
+ }
3542
+ } else {
3543
+ const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
3544
+ console.warn(
3545
+ `[patchstack] ${rejected.length} delivered rule(s) rejected as invalid/oversized and are NOT enforced: ${sample}` + (rejected.length > 3 ? ", \u2026" : "")
3546
+ );
3547
+ }
3548
+ }
3006
3549
  return {
3007
- firewall: Array.isArray(b.firewall) ? b.firewall : [],
3008
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
3550
+ firewall: checked.firewall,
3551
+ whitelists: checked.whitelists,
3009
3552
  whitelist_keys: b.whitelist_keys ?? {},
3010
3553
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
3011
3554
  };
@@ -3068,6 +3611,169 @@ function makeRefreshHandler(tick, secret) {
3068
3611
  };
3069
3612
  }
3070
3613
 
3614
+ // src/protect/firewall-log.js
3615
+ var DEFAULT_API_BASE = "https://api.patchstack.com";
3616
+ var DEFAULT_FLUSH_MS = 1e3;
3617
+ var MAX_BATCH = 50;
3618
+ var TOKEN_SKEW_MS2 = 6e4;
3619
+ function parseApiKey(apiKey) {
3620
+ if (typeof apiKey !== "string" || apiKey.length === 0) return null;
3621
+ const idx = apiKey.lastIndexOf("-");
3622
+ if (idx <= 0 || idx === apiKey.length - 1) return null;
3623
+ const clientSecret = apiKey.slice(0, idx);
3624
+ const clientId = apiKey.slice(idx + 1);
3625
+ if (!/^\d+$/.test(clientId) || clientSecret.length === 0) return null;
3626
+ return { clientId, clientSecret };
3627
+ }
3628
+ function resolveApiBase(pulseOrManifestUrl) {
3629
+ const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
3630
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
3631
+ const candidate = fromEnv.replace(/\/$/, "");
3632
+ if (isSafeOrigin(candidate)) return candidate;
3633
+ console.warn(
3634
+ "[patchstack] ignoring PATCHSTACK_API_BASE: block-log reporting requires an https origin (or localhost). Falling back to the default API origin."
3635
+ );
3636
+ }
3637
+ if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
3638
+ try {
3639
+ return new URL(pulseOrManifestUrl).origin;
3640
+ } catch {
3641
+ }
3642
+ }
3643
+ const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
3644
+ if (typeof endpoint === "string" && endpoint.length > 0) {
3645
+ try {
3646
+ return new URL(endpoint).origin;
3647
+ } catch {
3648
+ }
3649
+ }
3650
+ return DEFAULT_API_BASE;
3651
+ }
3652
+ function createFirewallLogReporter(opts) {
3653
+ const creds = parseApiKey(opts.apiKey);
3654
+ if (!creds) {
3655
+ return { record() {
3656
+ }, flush() {
3657
+ }, stop() {
3658
+ } };
3659
+ }
3660
+ const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
3661
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
3662
+ const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
3663
+ const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
3664
+ let queue = [];
3665
+ let timer = null;
3666
+ let stopped = false;
3667
+ let cachedToken = null;
3668
+ let tokenInflight = null;
3669
+ const fetchAccessToken = async () => {
3670
+ if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
3671
+ return cachedToken.token;
3672
+ }
3673
+ if (tokenInflight) return tokenInflight;
3674
+ tokenInflight = (async () => {
3675
+ try {
3676
+ const res = await fetchImpl(`${apiBase}/oauth/token`, {
3677
+ method: "POST",
3678
+ headers: {
3679
+ "Content-Type": "application/json",
3680
+ Accept: "application/json",
3681
+ "User-Agent": "@patchstack/connect"
3682
+ },
3683
+ body: JSON.stringify({
3684
+ grant_type: "client_credentials",
3685
+ client_id: creds.clientId,
3686
+ client_secret: creds.clientSecret
3687
+ })
3688
+ });
3689
+ if (!res || !res.ok) return null;
3690
+ const body = await res.json();
3691
+ const token = body?.access_token;
3692
+ if (typeof token !== "string" || token.length === 0) return null;
3693
+ const expiresIn = Number(body?.expires_in);
3694
+ const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
3695
+ cachedToken = { token, expiresAt: Date.now() + ttlMs };
3696
+ return token;
3697
+ } catch {
3698
+ return null;
3699
+ } finally {
3700
+ tokenInflight = null;
3701
+ }
3702
+ })();
3703
+ return tokenInflight;
3704
+ };
3705
+ const flush = () => {
3706
+ if (timer) {
3707
+ clearTimeout(timer);
3708
+ timer = null;
3709
+ }
3710
+ if (queue.length === 0 || typeof fetchImpl !== "function") return;
3711
+ const batch = queue.splice(0, MAX_BATCH);
3712
+ void (async () => {
3713
+ const token = await fetchAccessToken();
3714
+ if (!token) return;
3715
+ const body = new URLSearchParams();
3716
+ body.set("type", "firewall");
3717
+ body.set("logs", JSON.stringify(batch));
3718
+ try {
3719
+ const p = fetchImpl(`${apiBase}/api/logs/log`, {
3720
+ method: "POST",
3721
+ headers: {
3722
+ Authorization: `Bearer ${token}`,
3723
+ Accept: "application/json",
3724
+ "Content-Type": "application/x-www-form-urlencoded",
3725
+ "User-Agent": "@patchstack/connect",
3726
+ ...sourceHost ? { "Source-Host": sourceHost } : {}
3727
+ },
3728
+ body
3729
+ });
3730
+ if (p && typeof p.then === "function") p.catch(() => {
3731
+ });
3732
+ } catch {
3733
+ }
3734
+ })();
3735
+ };
3736
+ return {
3737
+ /**
3738
+ * @param {{
3739
+ * rule?: { id?: string | number },
3740
+ * method?: string | null,
3741
+ * path?: string | null,
3742
+ * ip?: string | null,
3743
+ * userAgent?: string | null,
3744
+ * }} event
3745
+ */
3746
+ record(event) {
3747
+ if (stopped) return;
3748
+ const fid = event?.rule?.id;
3749
+ if (fid === void 0 || fid === null || fid === "") return;
3750
+ queue.push({
3751
+ fid,
3752
+ method: event.method ?? null,
3753
+ request_uri: event.path ?? null,
3754
+ ip: event.ip ?? null,
3755
+ user_agent: event.userAgent ?? null,
3756
+ log_date: (/* @__PURE__ */ new Date()).toISOString()
3757
+ });
3758
+ if (queue.length >= MAX_BATCH) {
3759
+ flush();
3760
+ return;
3761
+ }
3762
+ if (!timer) timer = setTimeout(flush, flushMs);
3763
+ },
3764
+ flush,
3765
+ stop() {
3766
+ stopped = true;
3767
+ flush();
3768
+ }
3769
+ };
3770
+ }
3771
+ function telemetryEnabled() {
3772
+ const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
3773
+ if (v === void 0 || v === "") return true;
3774
+ return !/^(0|false|off|no)$/i.test(String(v));
3775
+ }
3776
+
3071
3777
  // src/protect/supabase-guard.js
3072
3778
  var GUARD_PATH = "/_patchstack/guard";
3073
3779
  var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
@@ -3140,9 +3846,30 @@ function createServerFnGuard({ protection }) {
3140
3846
  }
3141
3847
  async function createProtection(options = {}) {
3142
3848
  const onError = options.onError;
3143
- const onDetect = options.onDetect ?? defaultOnDetect;
3849
+ const userOnDetect = options.onDetect ?? defaultOnDetect;
3850
+ const apiKey = await resolveApiKey(options);
3851
+ const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
3852
+ apiKey,
3853
+ apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
3854
+ sourceHost: options.sourceHost,
3855
+ fetchImpl: options.fetchImpl
3856
+ }) : null;
3857
+ const onDetect = (detection) => {
3858
+ userOnDetect(detection);
3859
+ if (firewallLog && detection?.mode === "block") {
3860
+ firewallLog.record({
3861
+ rule: detection.rule,
3862
+ method: detection.method,
3863
+ path: detection.path,
3864
+ ip: detection.ip,
3865
+ userAgent: detection.userAgent
3866
+ });
3867
+ }
3868
+ };
3144
3869
  const store = makeStore(options);
3145
- const bundle = await resolveRules(options, store);
3870
+ const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
3871
+ const pulseAuth = await resolvePulseAuth(options);
3872
+ const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
3146
3873
  let mode = resolveMode(options, bundle);
3147
3874
  let requestRules;
3148
3875
  let responseRules;
@@ -3166,39 +3893,84 @@ async function createProtection(options = {}) {
3166
3893
  responseRuleSet = responseRules.map((rule) => ({
3167
3894
  rule,
3168
3895
  engine: new RuleEngine({ firewall: [rule], onError }),
3169
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null
3896
+ redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
3897
+ // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
3898
+ // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
3899
+ // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
3900
+ // fails such a rule CLOSED (block) instead of serving a no-op redaction.
3901
+ mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
3902
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
3903
+ // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
3904
+ // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
3905
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
3170
3906
  }));
3171
3907
  egressEngine = new RuleEngine({ firewall: egressRules, onError });
3172
3908
  };
3173
3909
  applyBundle(bundle);
3910
+ const skipCounts = /* @__PURE__ */ Object.create(null);
3911
+ const onSkip = typeof options.onSkip === "function" ? options.onSkip : null;
3912
+ const recordSkip = (phase, reason, detail) => {
3913
+ const key = `${phase}:${reason}`;
3914
+ skipCounts[key] = (skipCounts[key] ?? 0) + 1;
3915
+ if (onSkip) {
3916
+ try {
3917
+ onSkip({ phase, reason, detail, count: skipCounts[key] });
3918
+ } catch {
3919
+ }
3920
+ }
3921
+ };
3174
3922
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3175
- const decide = (phase, result, block, allow2) => {
3923
+ const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
3924
+ const decide = (phase, result, block, allow2, ctx = {}) => {
3176
3925
  if (!result || !result.blocked) return allow2();
3177
- onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
3178
- return mode === "block" ? block() : allow2();
3179
- };
3180
- const isTextCT = (ct) => {
3181
- ct = (ct || "").toLowerCase();
3182
- return ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
3926
+ const effectiveMode = ruleMode(result.rule);
3927
+ onDetect({
3928
+ phase,
3929
+ // The mode this detection was actually handled under, not the site's: a consumer counting blocks
3930
+ // would otherwise over-report, and the whole point of a dry-run rule is that it did not block.
3931
+ mode: effectiveMode,
3932
+ category: result.rule?.category,
3933
+ rule: result.rule,
3934
+ message: result.message,
3935
+ method: ctx.method,
3936
+ path: ctx.path,
3937
+ ip: ctx.ip,
3938
+ userAgent: ctx.userAgent
3939
+ });
3940
+ return effectiveMode === "block" ? block() : allow2();
3183
3941
  };
3184
- const screenText = (text, meta) => {
3942
+ const screenText = (text, meta, reqCtx) => {
3185
3943
  let blockRule = null;
3186
3944
  const redactions = [];
3187
- for (const { rule, engine: re, redactors } of responseRuleSet) {
3945
+ const headerMutations = [];
3946
+ let lowerText = null;
3947
+ for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
3948
+ if (prefilter) {
3949
+ if (lowerText === null) lowerText = text.toLowerCase();
3950
+ if (!prefilter.some((p) => lowerText.includes(p))) continue;
3951
+ }
3188
3952
  let result;
3189
3953
  try {
3190
- result = re.evaluate({ _response: { ...meta, body: text } });
3954
+ result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
3191
3955
  } catch (err) {
3192
3956
  onError?.(err);
3193
3957
  continue;
3194
3958
  }
3195
3959
  if (!result.blocked) continue;
3196
- onDetect({ phase: "response", mode, category: rule.category, rule, message: result.message });
3197
- if (mode !== "block") continue;
3198
- if (redactors && redactors.length) redactions.push({ rule, redactors });
3960
+ const responseMode = ruleMode(rule);
3961
+ onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
3962
+ if (responseMode !== "block") continue;
3963
+ if (redactors && redactors.length) {
3964
+ const spanRedactors = redactors.filter((r) => !r.jsonPath);
3965
+ if (mutatedSpan && spanRedactors.length) {
3966
+ if (!blockRule) blockRule = rule;
3967
+ } else {
3968
+ redactions.push({ rule, redactors });
3969
+ }
3970
+ } else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
3199
3971
  else if (!blockRule) blockRule = rule;
3200
3972
  }
3201
- if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
3973
+ if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
3202
3974
  if (blockRule) return { verdict: "block" };
3203
3975
  let body = text;
3204
3976
  const headers = { ...meta.headers || {} };
@@ -3220,17 +3992,33 @@ async function createProtection(options = {}) {
3220
3992
  }
3221
3993
  }
3222
3994
  }
3995
+ for (const rule of headerMutations) applyHeaderMutation(headers, rule);
3223
3996
  return { verdict: "redact", body, headers };
3224
3997
  };
3225
- const screenResp = async (response) => {
3226
- const text = await readTextResponse(response, screenCap);
3227
- if (text == null) return response;
3228
- const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
3998
+ const reqContextFromFetch = (request) => {
3999
+ try {
4000
+ const u = new URL(request.url);
4001
+ const headers = headerObject(request.headers);
4002
+ if (!headers.host) headers.host = u.host;
4003
+ return { method: request.method, originalUrl: u.pathname + u.search, headers };
4004
+ } catch {
4005
+ return void 0;
4006
+ }
4007
+ };
4008
+ const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
4009
+ const screenResp = async (response, reqCtx) => {
4010
+ const read = await readTextResponse(response, screenCap);
4011
+ if (read.skip) {
4012
+ if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
4013
+ return response;
4014
+ }
4015
+ const text = read.text;
4016
+ const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
3229
4017
  if (r.verdict === "block") return leakResponse();
3230
4018
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
3231
4019
  return response;
3232
4020
  };
3233
- const wrapNodeResponse = (res) => {
4021
+ const wrapNodeResponse = (res, reqCtx) => {
3234
4022
  const origWrite = res.write.bind(res);
3235
4023
  const origEnd = res.end.bind(res);
3236
4024
  const chunks = [];
@@ -3246,6 +4034,7 @@ async function createProtection(options = {}) {
3246
4034
  chunks.length = 0;
3247
4035
  origWrite(buf);
3248
4036
  overflow = true;
4037
+ recordSkip("response", "body-cap", { bytes: size });
3249
4038
  return;
3250
4039
  }
3251
4040
  chunks.push(buf);
@@ -3272,16 +4061,19 @@ async function createProtection(options = {}) {
3272
4061
  }
3273
4062
  collect(chunk, enc);
3274
4063
  if (overflow) return origEnd(cb);
3275
- const text = Buffer.concat(chunks).toString("utf8");
4064
+ const buffer = Buffer.concat(chunks);
3276
4065
  let ct = res.getHeader ? res.getHeader("content-type") : void 0;
3277
4066
  if (Array.isArray(ct)) ct = ct[0];
3278
- if (!isTextCT(ct)) {
4067
+ const kind = screenableContentType(ct);
4068
+ if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
4069
+ recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
3279
4070
  for (const c of chunks) origWrite(c);
3280
4071
  return origEnd(cb);
3281
4072
  }
4073
+ const text = buffer.toString("utf8");
3282
4074
  let r;
3283
4075
  try {
3284
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
4076
+ r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
3285
4077
  } catch (err) {
3286
4078
  onError?.(err);
3287
4079
  for (const c of chunks) origWrite(c);
@@ -3304,7 +4096,12 @@ async function createProtection(options = {}) {
3304
4096
  const current = res.getHeaders ? res.getHeaders() : {};
3305
4097
  for (const [name, value] of Object.entries(r.headers)) {
3306
4098
  if (name.toLowerCase() === "content-length") continue;
3307
- if (Array.isArray(value)) {
4099
+ if (value === null || value === void 0) {
4100
+ try {
4101
+ res.removeHeader && res.removeHeader(name);
4102
+ } catch {
4103
+ }
4104
+ } else if (Array.isArray(value)) {
3308
4105
  try {
3309
4106
  res.setHeader(name, value);
3310
4107
  } catch {
@@ -3334,8 +4131,9 @@ async function createProtection(options = {}) {
3334
4131
  return false;
3335
4132
  }
3336
4133
  if (!result.blocked) return false;
3337
- onDetect({ phase: "egress", mode, category: result.rule?.category, rule: result.rule, message: result.message });
3338
- return mode === "block";
4134
+ const egressMode = ruleMode(result.rule);
4135
+ onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
4136
+ return egressMode === "block";
3339
4137
  };
3340
4138
  const protection = {
3341
4139
  get mode() {
@@ -3344,9 +4142,18 @@ async function createProtection(options = {}) {
3344
4142
  get rules() {
3345
4143
  return { request: requestRules, response: responseRules, egress: egressRules };
3346
4144
  },
4145
+ /**
4146
+ * Enforcement coverage: how often the guard FAILED OPEN rather than inspecting, keyed
4147
+ * `<phase>:<reason>` (e.g. `response:body-cap`, `request:body-cap`, `response:live-stream`,
4148
+ * `egress:resolver-failed`). "Always-on" is not "always inspected" — surface this (or pass
4149
+ * `onSkip`) so an unscreened path is visible and alertable rather than silent.
4150
+ */
4151
+ coverage() {
4152
+ return { skipped: { ...skipCounts } };
4153
+ },
3347
4154
  // Screen a fetch Response through the response-phase rules (redact/block). Used by
3348
4155
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
3349
- screenResponse: (response) => screenResp(response),
4156
+ screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
3350
4157
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
3351
4158
  fetchGuard() {
3352
4159
  return async (request) => {
@@ -3357,7 +4164,7 @@ async function createProtection(options = {}) {
3357
4164
  onError?.(err);
3358
4165
  return null;
3359
4166
  }
3360
- return decide("request", result, () => blockResponse(result, request), () => null);
4167
+ return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
3361
4168
  };
3362
4169
  },
3363
4170
  // Wrap a fetch handler: screens the request, then the response (redact/block).
@@ -3367,7 +4174,7 @@ async function createProtection(options = {}) {
3367
4174
  const blocked = await guard(request);
3368
4175
  if (blocked) return blocked;
3369
4176
  const response = await handler(request, ...rest);
3370
- return screenResp(response);
4177
+ return screenResp(response, reqContextFromFetch(request));
3371
4178
  };
3372
4179
  },
3373
4180
  // Express middleware (request phase; expects express-parsed req.query/req.body).
@@ -3379,7 +4186,7 @@ async function createProtection(options = {}) {
3379
4186
  result = engine.evaluate(req);
3380
4187
  } catch (err) {
3381
4188
  onError?.(err);
3382
- if (exprOptions.screenResponses) wrapNodeResponse(res);
4189
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3383
4190
  return next();
3384
4191
  }
3385
4192
  decide(
@@ -3393,9 +4200,10 @@ async function createProtection(options = {}) {
3393
4200
  }
3394
4201
  },
3395
4202
  () => {
3396
- if (exprOptions.screenResponses) wrapNodeResponse(res);
4203
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3397
4204
  next();
3398
- }
4205
+ },
4206
+ nodeRequestMeta(req)
3399
4207
  );
3400
4208
  };
3401
4209
  },
@@ -3420,6 +4228,7 @@ async function createProtection(options = {}) {
3420
4228
  next();
3421
4229
  });
3422
4230
  req.on("end", () => {
4231
+ if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
3423
4232
  const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
3424
4233
  let shaped;
3425
4234
  let result;
@@ -3445,9 +4254,10 @@ async function createProtection(options = {}) {
3445
4254
  },
3446
4255
  () => {
3447
4256
  if (req.body === void 0) req.body = shaped.body;
3448
- if (nodeOptions.screenResponses) wrapNodeResponse(res);
4257
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3449
4258
  next();
3450
- }
4259
+ },
4260
+ nodeRequestMeta(req)
3451
4261
  );
3452
4262
  });
3453
4263
  };
@@ -3457,6 +4267,9 @@ async function createProtection(options = {}) {
3457
4267
  protection.uninstallEgress = await installEgressGuard({
3458
4268
  shouldBlock: egressShouldBlock,
3459
4269
  onBlock: options.onEgressBlock,
4270
+ // Route egress coverage gaps (a DNS resolver failure / no resolver on this runtime) into the
4271
+ // same skip accounting as the request/response phases.
4272
+ onSkip: ({ reason, detail }) => recordSkip("egress", reason, detail),
3460
4273
  dnsScreen: options.screenDns !== false,
3461
4274
  allowHosts: options.allowHosts
3462
4275
  });
@@ -3481,7 +4294,7 @@ async function createProtection(options = {}) {
3481
4294
  onError?.(err);
3482
4295
  }
3483
4296
  }
3484
- const next = await resolveRules(options, store);
4297
+ const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
3485
4298
  mode = resolveMode(options, next);
3486
4299
  applyBundle(next);
3487
4300
  };
@@ -3491,7 +4304,12 @@ async function createProtection(options = {}) {
3491
4304
  }
3492
4305
  if (options.refreshMs > 0 && live) {
3493
4306
  const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
3494
- protection.stopRefresh = loop.stop;
4307
+ protection.stopRefresh = () => {
4308
+ loop.stop();
4309
+ firewallLog?.stop();
4310
+ };
4311
+ } else if (firewallLog) {
4312
+ protection.stopRefresh = () => firewallLog.stop();
3495
4313
  }
3496
4314
  return protection;
3497
4315
  }
@@ -3503,21 +4321,77 @@ function resolveMode(options, bundle) {
3503
4321
  if (options?.mode === "dry-run") return "dry-run";
3504
4322
  return "dry-run";
3505
4323
  }
4324
+ async function resolveApiKey(options) {
4325
+ if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
4326
+ if (typeof process !== "undefined") {
4327
+ const fromEnv = process.env?.PATCHSTACK_API_KEY;
4328
+ if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
4329
+ }
4330
+ try {
4331
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
4332
+ const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
4333
+ const cwd = options?.cwd ?? process.cwd();
4334
+ const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
4335
+ const key = JSON.parse(raw)?.apiKey;
4336
+ if (typeof key === "string" && key.length > 0) return key;
4337
+ } catch {
4338
+ }
4339
+ return void 0;
4340
+ }
4341
+ async function resolvePulseAuth(options) {
4342
+ if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
4343
+ if (typeof process !== "undefined") {
4344
+ const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
4345
+ if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
4346
+ }
4347
+ try {
4348
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
4349
+ const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
4350
+ const cwd = options?.cwd ?? process.cwd();
4351
+ const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
4352
+ const key = JSON.parse(raw)?.pulseAuth;
4353
+ if (typeof key === "string" && key.length > 0) return key;
4354
+ } catch {
4355
+ }
4356
+ return resolveApiKey(options);
4357
+ }
3506
4358
  function byPhase(rules, phase) {
3507
4359
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
3508
4360
  }
4361
+ function baseContentType(ct) {
4362
+ return String(ct || "").toLowerCase().split(";")[0].trim();
4363
+ }
4364
+ function screenableContentType(ct) {
4365
+ const base = baseContentType(ct);
4366
+ if (base === "text/event-stream") return "skip";
4367
+ if (base === "") return "text";
4368
+ if (/(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(base)) return "text";
4369
+ if (base === "application/octet-stream") return "sniff";
4370
+ return "skip";
4371
+ }
4372
+ function looksBinary(bytes) {
4373
+ const n = Math.min(bytes.length, 512);
4374
+ let ctrl = 0;
4375
+ for (let i = 0; i < n; i++) {
4376
+ const b = bytes[i];
4377
+ if (b === 0) return true;
4378
+ if (b < 9 || b > 13 && b < 32) ctrl++;
4379
+ }
4380
+ return n > 0 && ctrl / n > 0.1;
4381
+ }
3509
4382
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
3510
- if (!response || typeof response.clone !== "function") return null;
3511
- const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
3512
- const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
3513
- if (!isText) return null;
4383
+ if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
4384
+ const ct = response.headers?.get?.("content-type") || "";
4385
+ const kind = screenableContentType(ct);
4386
+ if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
4387
+ const sniff = kind === "sniff";
3514
4388
  const len = Number(response.headers?.get?.("content-length") || 0);
3515
- if (len && len > cap) return null;
4389
+ if (len && len > cap) return { skip: "body-cap" };
3516
4390
  let clone;
3517
4391
  try {
3518
4392
  clone = response.clone();
3519
4393
  } catch {
3520
- return null;
4394
+ return { skip: "clone-failed" };
3521
4395
  }
3522
4396
  const body = clone.body;
3523
4397
  if (body && typeof body.getReader === "function") {
@@ -3525,11 +4399,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
3525
4399
  const chunks = [];
3526
4400
  let size = 0;
3527
4401
  let over = false;
4402
+ let sniffed = !sniff;
3528
4403
  try {
3529
4404
  for (; ; ) {
3530
4405
  const { done, value } = await reader.read();
3531
4406
  if (done) break;
3532
4407
  if (!value) continue;
4408
+ if (!sniffed) {
4409
+ sniffed = true;
4410
+ if (looksBinary(value)) return { skip: "binary-body" };
4411
+ }
3533
4412
  size += value.byteLength;
3534
4413
  if (over) continue;
3535
4414
  if (size > cap) {
@@ -3539,20 +4418,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
3539
4418
  chunks.push(value);
3540
4419
  }
3541
4420
  } catch {
3542
- return null;
4421
+ return { skip: "read-failed" };
3543
4422
  }
3544
- if (over) return null;
4423
+ if (over) return { skip: "body-cap" };
3545
4424
  try {
3546
- return new TextDecoder().decode(concatBytes(chunks, size));
4425
+ return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
3547
4426
  } catch {
3548
- return null;
4427
+ return { skip: "decode-failed" };
3549
4428
  }
3550
4429
  }
3551
4430
  try {
3552
4431
  const text = await clone.text();
3553
- return text.length > cap ? null : text;
4432
+ if (text.length > cap) return { skip: "body-cap" };
4433
+ if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
4434
+ return { text };
3554
4435
  } catch {
3555
- return null;
4436
+ return { skip: "read-failed" };
3556
4437
  }
3557
4438
  }
3558
4439
  function concatBytes(chunks, total) {
@@ -3573,6 +4454,19 @@ function headerObject(headers) {
3573
4454
  if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
3574
4455
  return out;
3575
4456
  }
4457
+ function hasSpanMutations(rule) {
4458
+ let found = false;
4459
+ const walk2 = (conds) => {
4460
+ for (const c of conds ?? []) {
4461
+ if (found) return;
4462
+ if (Array.isArray(c.rules)) walk2(c.rules);
4463
+ const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
4464
+ if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
4465
+ }
4466
+ };
4467
+ walk2(rule.rule_v2);
4468
+ return found;
4469
+ }
3576
4470
  function extractRedactors(rule) {
3577
4471
  const out = [];
3578
4472
  const walk2 = (conds) => {
@@ -3609,7 +4503,10 @@ function applyRedactors(body, redactors, mask, transform) {
3609
4503
  let out = body;
3610
4504
  for (const r of redactors) {
3611
4505
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
3612
- else if (r.literal) out = out.split(r.literal).join(transform ? transform(r.literal) : mask);
4506
+ else if (r.literal) {
4507
+ const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
4508
+ out = out.replace(re, (m) => transform ? transform(m) : mask);
4509
+ }
3613
4510
  }
3614
4511
  return out;
3615
4512
  }
@@ -3712,12 +4609,52 @@ function restoreBigInts(text) {
3712
4609
  if (!text.includes(BIGINT_OPEN)) return text;
3713
4610
  return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
3714
4611
  }
4612
+ function isHeaderMutation(action) {
4613
+ return action === "set-header" || action === "remove-header" || action === "harden-cookie";
4614
+ }
4615
+ function applyHeaderMutation(headers, rule) {
4616
+ if (rule.action === "remove-header") {
4617
+ for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
4618
+ return;
4619
+ }
4620
+ if (rule.action === "set-header") {
4621
+ const ensure = rule.ensure === true;
4622
+ for (const [name, value] of Object.entries(rule.set_headers ?? {})) {
4623
+ const key = String(name).toLowerCase();
4624
+ const present = headers[key] != null && headers[key] !== "";
4625
+ if (ensure && present) continue;
4626
+ headers[key] = String(value);
4627
+ }
4628
+ return;
4629
+ }
4630
+ if (rule.action === "harden-cookie") {
4631
+ const cookie = headers["set-cookie"];
4632
+ const flags = rule.cookie_flags ?? {};
4633
+ if (Array.isArray(cookie)) {
4634
+ headers["set-cookie"] = cookie.map((c) => typeof c === "string" ? hardenCookie(c, flags) : c);
4635
+ } else if (typeof cookie === "string") {
4636
+ headers["set-cookie"] = hardenCookie(cookie, flags);
4637
+ }
4638
+ }
4639
+ }
4640
+ function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
4641
+ let out = String(cookie);
4642
+ if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
4643
+ if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
4644
+ if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
4645
+ return out;
4646
+ }
3715
4647
  function rebuildResponse(response, body, redactedHeaders) {
3716
4648
  const headers = new Headers(response.headers);
3717
4649
  headers.delete("content-length");
3718
4650
  if (redactedHeaders) {
3719
4651
  for (const [name, value] of Object.entries(redactedHeaders)) {
3720
- if (typeof value === "string") {
4652
+ if (value === null || value === void 0) {
4653
+ try {
4654
+ headers.delete(name);
4655
+ } catch {
4656
+ }
4657
+ } else if (typeof value === "string") {
3721
4658
  if (headers.get(name) !== value) {
3722
4659
  try {
3723
4660
  headers.set(name, value);
@@ -3733,7 +4670,8 @@ function rebuildResponse(response, body, redactedHeaders) {
3733
4670
  }
3734
4671
  }
3735
4672
  }
3736
- return new Response(body, { status: response.status, statusText: response.statusText, headers });
4673
+ const nullBody = response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304;
4674
+ return new Response(nullBody ? null : body, { status: response.status, statusText: response.statusText, headers });
3737
4675
  }
3738
4676
  function leakResponse() {
3739
4677
  return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
@@ -3766,6 +4704,33 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
3766
4704
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
3767
4705
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
3768
4706
  }
4707
+ function fetchRequestMeta(request) {
4708
+ if (!request) return {};
4709
+ let path7 = null;
4710
+ try {
4711
+ path7 = new URL(request.url).pathname;
4712
+ } catch {
4713
+ path7 = typeof request.url === "string" ? request.url : null;
4714
+ }
4715
+ return {
4716
+ method: request.method ?? null,
4717
+ path: path7,
4718
+ ip: request.headers?.get?.("x-forwarded-for") ?? null,
4719
+ userAgent: request.headers?.get?.("user-agent") ?? null
4720
+ };
4721
+ }
4722
+ function nodeRequestMeta(req) {
4723
+ if (!req) return {};
4724
+ const headers = req.headers ?? {};
4725
+ const ua = headers["user-agent"] ?? headers["User-Agent"];
4726
+ const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
4727
+ return {
4728
+ method: req.method ?? null,
4729
+ path: req.originalUrl || req.url || null,
4730
+ ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
4731
+ userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
4732
+ };
4733
+ }
3769
4734
  // Annotate the CommonJS export names for ESM import in node:
3770
4735
  0 && (module.exports = {
3771
4736
  GUARD_PATH,