@patchstack/connect 0.3.23 → 0.3.25

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
  }
@@ -156,9 +245,11 @@ async function resolveConfig(options) {
156
245
  );
157
246
  }
158
247
  const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
248
+ const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
159
249
  return {
160
250
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
161
251
  apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
252
+ pulseAuth: pulseAuthRaw === null || pulseAuthRaw.length === 0 ? null : pulseAuthRaw,
162
253
  endpoint,
163
254
  timeoutMs,
164
255
  environment,
@@ -166,7 +257,7 @@ async function resolveConfig(options) {
166
257
  };
167
258
  }
168
259
  async function readConfigFile(cwd) {
169
- const target = import_node_path2.default.join(cwd, CONFIG_FILENAME);
260
+ const target = import_node_path.default.join(cwd, CONFIG_FILENAME);
170
261
  let raw;
171
262
  try {
172
263
  raw = await (0, import_promises.readFile)(target, "utf8");
@@ -207,6 +298,7 @@ function readEnv() {
207
298
  return {
208
299
  siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
209
300
  apiKey: process.env.PATCHSTACK_API_KEY ?? void 0,
301
+ pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? void 0,
210
302
  endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
211
303
  timeoutMs,
212
304
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
@@ -218,12 +310,12 @@ function isUuid(value) {
218
310
  function isEnvironment(value) {
219
311
  return value === "production" || value === "sandbox";
220
312
  }
221
- var import_promises, import_node_path2, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
313
+ var import_promises, import_node_path, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
222
314
  var init_config = __esm({
223
315
  "src/config.ts"() {
224
316
  "use strict";
225
317
  import_promises = require("fs/promises");
226
- import_node_path2 = __toESM(require("path"), 1);
318
+ import_node_path = __toESM(require("path"), 1);
227
319
  init_types();
228
320
  init_client();
229
321
  CONFIG_FILENAME = ".patchstackrc.json";
@@ -235,7 +327,7 @@ var init_config = __esm({
235
327
  async function readDeclaredDependencyNames(cwd) {
236
328
  let raw;
237
329
  try {
238
- 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");
239
331
  } catch {
240
332
  return [];
241
333
  }
@@ -267,12 +359,12 @@ function missingDependencies(declared, packages) {
267
359
  const present = new Set(packages.map((entry) => entry.name));
268
360
  return declared.filter((name) => !present.has(name));
269
361
  }
270
- var import_promises2, import_node_path3;
362
+ var import_promises2, import_node_path2;
271
363
  var init_consistency = __esm({
272
364
  "src/parsers/consistency.ts"() {
273
365
  "use strict";
274
366
  import_promises2 = require("fs/promises");
275
- import_node_path3 = __toESM(require("path"), 1);
367
+ import_node_path2 = __toESM(require("path"), 1);
276
368
  }
277
369
  });
278
370
 
@@ -338,7 +430,7 @@ function extractFromV1(deps, acc = [], depth = 0) {
338
430
  return acc;
339
431
  }
340
432
  function extractNameFromPath(pkgPath) {
341
- 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 : "/");
342
434
  const parts = pkgPath.split("/");
343
435
  const nmIndex = parts.lastIndexOf("node_modules");
344
436
  if (nmIndex === -1 || nmIndex >= parts.length - 1) {
@@ -359,19 +451,19 @@ function isDirectV2(pkgPath) {
359
451
  const nmCount = parts.filter((p) => p === "node_modules").length;
360
452
  return nmCount === 1;
361
453
  }
362
- var import_promises3, import_node_path4;
454
+ var import_promises3, import_node_path3;
363
455
  var init_npm = __esm({
364
456
  "src/parsers/npm.ts"() {
365
457
  "use strict";
366
458
  import_promises3 = require("fs/promises");
367
- import_node_path4 = __toESM(require("path"), 1);
459
+ import_node_path3 = __toESM(require("path"), 1);
368
460
  init_types();
369
461
  }
370
462
  });
371
463
 
372
464
  // src/parsers/node_modules.ts
373
465
  async function walkNodeModules(cwd) {
374
- const root = import_node_path5.default.join(cwd, "node_modules");
466
+ const root = import_node_path4.default.join(cwd, "node_modules");
375
467
  try {
376
468
  const info = await (0, import_promises4.stat)(root);
377
469
  if (!info.isDirectory()) {
@@ -405,7 +497,7 @@ async function walk(dir, acc, depth) {
405
497
  if (name.startsWith(".")) {
406
498
  continue;
407
499
  }
408
- const fullPath = import_node_path5.default.join(dir, name);
500
+ const fullPath = import_node_path4.default.join(dir, name);
409
501
  if (!await isPlainDirectory(fullPath)) {
410
502
  continue;
411
503
  }
@@ -420,7 +512,7 @@ async function walk(dir, acc, depth) {
420
512
  if (sub.startsWith(".")) {
421
513
  continue;
422
514
  }
423
- const scopedDir = import_node_path5.default.join(fullPath, sub);
515
+ const scopedDir = import_node_path4.default.join(fullPath, sub);
424
516
  if (!await isPlainDirectory(scopedDir)) {
425
517
  continue;
426
518
  }
@@ -436,7 +528,7 @@ async function walk(dir, acc, depth) {
436
528
  async function readPackage(pkgDir, depth, acc) {
437
529
  let raw;
438
530
  try {
439
- 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");
440
532
  } catch {
441
533
  return;
442
534
  }
@@ -459,7 +551,7 @@ async function readPackage(pkgDir, depth, acc) {
459
551
  });
460
552
  }
461
553
  async function walkNested(pkgDir, acc, depth) {
462
- const nested = import_node_path5.default.join(pkgDir, "node_modules");
554
+ const nested = import_node_path4.default.join(pkgDir, "node_modules");
463
555
  if (!await isPlainDirectory(nested)) {
464
556
  return;
465
557
  }
@@ -473,12 +565,12 @@ async function isPlainDirectory(dir) {
473
565
  return false;
474
566
  }
475
567
  }
476
- var import_promises4, import_node_path5;
568
+ var import_promises4, import_node_path4;
477
569
  var init_node_modules = __esm({
478
570
  "src/parsers/node_modules.ts"() {
479
571
  "use strict";
480
572
  import_promises4 = require("fs/promises");
481
- import_node_path5 = __toESM(require("path"), 1);
573
+ import_node_path4 = __toESM(require("path"), 1);
482
574
  init_types();
483
575
  }
484
576
  });
@@ -761,7 +853,7 @@ async function parseYarnLockfile(lockfilePath) {
761
853
  "LOCKFILE_PARSE_ERROR"
762
854
  );
763
855
  }
764
- const directNames = await readDirectDepNames(import_node_path6.default.dirname(lockfilePath));
856
+ const directNames = await readDirectDepNames(import_node_path5.default.dirname(lockfilePath));
765
857
  const entries = [];
766
858
  const seen = /* @__PURE__ */ new Set();
767
859
  for (const block of blocks) {
@@ -905,7 +997,7 @@ async function readDirectDepNames(cwd) {
905
997
  const names = /* @__PURE__ */ new Set();
906
998
  let raw;
907
999
  try {
908
- 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");
909
1001
  } catch {
910
1002
  return names;
911
1003
  }
@@ -930,12 +1022,12 @@ async function readDirectDepNames(cwd) {
930
1022
  }
931
1023
  return names;
932
1024
  }
933
- var import_promises6, import_node_path6;
1025
+ var import_promises6, import_node_path5;
934
1026
  var init_yarn = __esm({
935
1027
  "src/parsers/yarn.ts"() {
936
1028
  "use strict";
937
1029
  import_promises6 = require("fs/promises");
938
- import_node_path6 = __toESM(require("path"), 1);
1030
+ import_node_path5 = __toESM(require("path"), 1);
939
1031
  init_types();
940
1032
  }
941
1033
  });
@@ -944,7 +1036,7 @@ var init_yarn = __esm({
944
1036
  async function detectLockfile(cwd) {
945
1037
  const probed = await Promise.all(
946
1038
  LOCKFILE_CANDIDATES.map(async (candidate) => {
947
- const filePath = import_node_path7.default.join(cwd, candidate.filename);
1039
+ const filePath = import_node_path6.default.join(cwd, candidate.filename);
948
1040
  return { ...candidate, filePath, present: await exists(filePath) };
949
1041
  })
950
1042
  );
@@ -1022,7 +1114,7 @@ function staleWarning(source, missing) {
1022
1114
  async function presentLockfiles(cwd) {
1023
1115
  const probed = await Promise.all(
1024
1116
  LOCKFILE_CANDIDATES.map(async (candidate) => {
1025
- const filePath = import_node_path7.default.join(cwd, candidate.filename);
1117
+ const filePath = import_node_path6.default.join(cwd, candidate.filename);
1026
1118
  return { ...candidate, filePath, present: await exists(filePath) };
1027
1119
  })
1028
1120
  );
@@ -1048,12 +1140,12 @@ async function exists(filePath) {
1048
1140
  return false;
1049
1141
  }
1050
1142
  }
1051
- var import_promises7, import_node_path7, LOCKFILE_CANDIDATES;
1143
+ var import_promises7, import_node_path6, LOCKFILE_CANDIDATES;
1052
1144
  var init_parsers = __esm({
1053
1145
  "src/parsers/index.ts"() {
1054
1146
  "use strict";
1055
1147
  import_promises7 = require("fs/promises");
1056
- import_node_path7 = __toESM(require("path"), 1);
1148
+ import_node_path6 = __toESM(require("path"), 1);
1057
1149
  init_types();
1058
1150
  init_consistency();
1059
1151
  init_npm();
@@ -1212,6 +1304,29 @@ __export(runtime_exports, {
1212
1304
  });
1213
1305
  module.exports = __toCommonJS(runtime_exports);
1214
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
+
1215
1330
  // src/protect/engine/client.js
1216
1331
  var DEFAULT_BASE_URL = "https://api.patchstack.com";
1217
1332
  var DEFAULT_CACHE_TTL = 3e5;
@@ -1224,9 +1339,11 @@ var PatchstackRuleClient = class {
1224
1339
  #cacheTime = null;
1225
1340
  #ttlEffective = 0;
1226
1341
  #etag;
1227
- constructor({ token, baseUrl, cacheTtl, etag } = {}) {
1342
+ #timeoutMs;
1343
+ constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
1344
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
1228
1345
  this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
1229
- 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");
1230
1347
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
1231
1348
  this.#etag = etag ?? null;
1232
1349
  if (!this.#token) {
@@ -1251,7 +1368,7 @@ var PatchstackRuleClient = class {
1251
1368
  method: "POST",
1252
1369
  headers,
1253
1370
  body: JSON.stringify({}),
1254
- signal: AbortSignal.timeout(3e4)
1371
+ signal: AbortSignal.timeout(this.#timeoutMs)
1255
1372
  });
1256
1373
  if (response.status === 304) {
1257
1374
  this.#touch(now);
@@ -1304,6 +1421,9 @@ var PatchstackRuleClient = class {
1304
1421
  };
1305
1422
 
1306
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;
1307
1427
  function base64DecodeUtf8(value) {
1308
1428
  const str = String(value);
1309
1429
  if (typeof Buffer !== "undefined") {
@@ -1507,7 +1627,9 @@ var RequestResolver = class {
1507
1627
  default: {
1508
1628
  if (key.startsWith("HTTP_")) {
1509
1629
  const headerName = key.substring(5).toLowerCase().replace(/_/g, "-");
1510
- 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]] : [];
1511
1633
  }
1512
1634
  return [];
1513
1635
  }
@@ -1515,14 +1637,32 @@ var RequestResolver = class {
1515
1637
  }
1516
1638
  #resolveFiles(key) {
1517
1639
  const files = this.#req.files;
1518
- if (!files) {
1640
+ if (!files || typeof files !== "object") {
1519
1641
  return [];
1520
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)];
1521
1656
  if (key.endsWith("*")) {
1522
- 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);
1523
1663
  }
1524
- const value = files[key];
1525
- return value !== void 0 ? [value] : [];
1664
+ if (!Object.prototype.hasOwnProperty.call(files, key)) return [];
1665
+ return filenamesOf(files[key]).filter((v) => v !== void 0);
1526
1666
  }
1527
1667
  #resolveRaw() {
1528
1668
  if (typeof this.#req._rawBody === "string") {
@@ -1553,6 +1693,9 @@ var RequestResolver = class {
1553
1693
  if (body) {
1554
1694
  parts.push(typeof body === "string" ? body : JSON.stringify(body));
1555
1695
  }
1696
+ if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
1697
+ parts.push(this.#req._rawBody);
1698
+ }
1556
1699
  const headers = this.#req.headers ?? {};
1557
1700
  const excludedHeaders = /* @__PURE__ */ new Set([
1558
1701
  "host",
@@ -1745,8 +1888,6 @@ function removeSqlComments(value) {
1745
1888
  let result = value;
1746
1889
  result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
1747
1890
  result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
1748
- result = result.replace(/--[^\r\n]*/g, "");
1749
- result = result.replace(/#[^\r\n]*/g, "");
1750
1891
  return result;
1751
1892
  }
1752
1893
  function removeNullBytes(value) {
@@ -1801,17 +1942,21 @@ function normalizeRequest(req, options = {}) {
1801
1942
  _rawBody: rawBody
1802
1943
  };
1803
1944
  }
1804
- function normalizeObject(value, options = {}) {
1945
+ var MAX_NORMALIZE_DEPTH = 200;
1946
+ function normalizeObject(value, options = {}, depth = 0) {
1805
1947
  if (typeof value === "string") {
1806
1948
  return normalize(value, options);
1807
1949
  }
1950
+ if (depth >= MAX_NORMALIZE_DEPTH) {
1951
+ return value;
1952
+ }
1808
1953
  if (Array.isArray(value)) {
1809
- return value.map((item) => normalizeObject(item, options));
1954
+ return value.map((item) => normalizeObject(item, options, depth + 1));
1810
1955
  }
1811
1956
  if (typeof value === "object" && value !== null) {
1812
1957
  const result = {};
1813
1958
  for (const [key, val] of Object.entries(value)) {
1814
- result[key] = normalizeObject(val, options);
1959
+ result[key] = normalizeObject(val, options, depth + 1);
1815
1960
  }
1816
1961
  return result;
1817
1962
  }
@@ -1819,14 +1964,32 @@ function normalizeObject(value, options = {}) {
1819
1964
  }
1820
1965
 
1821
1966
  // src/protect/engine/engine.js
1967
+ var GRP = "(?:[^()]|\\([^()]*\\))*";
1822
1968
  var REDOS_PATTERNS = [
1823
1969
  /\([^)]*[+*}][^)]*\)\s*[+*]/,
1824
- /\([^)]*\|[^)]*\)\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
1825
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;
1826
1986
  function safeRegExp(pattern) {
1827
1987
  if (!pattern) {
1828
1988
  return null;
1829
1989
  }
1990
+ if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
1991
+ return null;
1992
+ }
1830
1993
  for (const dangerous of REDOS_PATTERNS) {
1831
1994
  if (dangerous.test(pattern)) {
1832
1995
  return null;
@@ -1883,6 +2046,34 @@ function arrayKeyValue(value, matchObj) {
1883
2046
  }
1884
2047
  return false;
1885
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
+ }
1886
2077
  var warnedMatchTypes = /* @__PURE__ */ new Set();
1887
2078
  function warnUnsupportedMatchType(type) {
1888
2079
  if (type == null || warnedMatchTypes.has(type)) {
@@ -1895,28 +2086,93 @@ function warnUnsupportedMatchType(type) {
1895
2086
  }
1896
2087
  function isInternalHost(hostname) {
1897
2088
  if (!hostname) return false;
1898
- const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
2089
+ let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
2090
+ host = host.replace(/%[^\]]*$/, "");
2091
+ host = host.replace(/\.$/, "");
1899
2092
  if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
1900
2093
  if (host === "metadata.google.internal") return true;
1901
- if (host === "::1" || host === "::") return true;
1902
- if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
1903
- const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
1904
- if (v4 && isPrivateV4(Number(v4[1]), Number(v4[2]))) return true;
1905
- const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
1906
- if (mapped) {
1907
- const g1 = parseInt(mapped[1], 16);
1908
- const g2 = parseInt(mapped[2], 16);
1909
- 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;
1910
2105
  }
2106
+ const v4 = parseIPv4ToInt(host);
2107
+ if (v4 !== null) return isPrivateV4Int(v4);
1911
2108
  return false;
1912
2109
  }
1913
- function isPrivateV4(a, b) {
2110
+ function isPrivateV4Int(n) {
2111
+ const a = n >>> 24 & 255;
2112
+ const b = n >>> 16 & 255;
1914
2113
  if (a === 127 || a === 10 || a === 0) return true;
1915
2114
  if (a === 169 && b === 254) return true;
1916
2115
  if (a === 172 && b >= 16 && b <= 31) return true;
1917
2116
  if (a === 192 && b === 168) return true;
2117
+ if (a === 100 && b >= 64 && b <= 127) return true;
1918
2118
  return false;
1919
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
+ }
1920
2176
  function ruleAppliesTo(when, resolver) {
1921
2177
  try {
1922
2178
  if (when.method) {
@@ -1944,14 +2200,22 @@ function pathMatches(pattern, path7) {
1944
2200
  }
1945
2201
  return path7 === pattern;
1946
2202
  }
2203
+ function normalizeDefaultPort(host) {
2204
+ return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
2205
+ }
1947
2206
  function isCrossOrigin(resolver) {
1948
2207
  try {
1949
- const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
2208
+ const host = normalizeDefaultPort(String(resolver.resolve("server.HTTP_HOST")[0] ?? ""));
1950
2209
  if (!host) return false;
1951
- const src = resolver.resolve("server.HTTP_ORIGIN")[0] ?? resolver.resolve("server.HTTP_REFERER")[0];
1952
- if (!src) return false;
1953
- const srcHost = hostFromUrl(String(src));
1954
- 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;
1955
2219
  } catch {
1956
2220
  return false;
1957
2221
  }
@@ -1963,6 +2227,41 @@ function hostFromUrl(value) {
1963
2227
  return null;
1964
2228
  }
1965
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
+ }
1966
2265
  function matchValue(type, value, matchVal, matchObj) {
1967
2266
  if (value === null || value === void 0) {
1968
2267
  if (type === "isset") {
@@ -1970,7 +2269,16 @@ function matchValue(type, value, matchVal, matchObj) {
1970
2269
  }
1971
2270
  return false;
1972
2271
  }
1973
- 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
+ }
1974
2282
  switch (type) {
1975
2283
  case "equals":
1976
2284
  return strValue == matchVal;
@@ -1991,6 +2299,7 @@ function matchValue(type, value, matchVal, matchObj) {
1991
2299
  case "regex": {
1992
2300
  const regex = safeRegExp(matchVal);
1993
2301
  if (!regex) {
2302
+ warnRejectedPatternOnce(matchVal);
1994
2303
  return false;
1995
2304
  }
1996
2305
  return regex.test(strValue);
@@ -2069,6 +2378,15 @@ var RuleEngine = class {
2069
2378
  this.#whitelists = whitelists;
2070
2379
  this.#whitelistKeys = whitelist_keys;
2071
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
+ }
2072
2390
  }
2073
2391
  // A mitigation engine must never take down the app it protects: any error while
2074
2392
  // evaluating a rule is reported and the request is allowed through (fail open). A
@@ -2149,6 +2467,12 @@ var RuleEngine = class {
2149
2467
  if (match && match.type === "cross_origin") {
2150
2468
  return isCrossOrigin(resolver);
2151
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
+ }
2152
2476
  const params = Array.isArray(parameter) ? parameter : [parameter];
2153
2477
  const values = params.flatMap((p) => resolver.resolve(p));
2154
2478
  if (values.length === 0) {
@@ -2164,10 +2488,12 @@ var RuleEngine = class {
2164
2488
  }
2165
2489
  continue;
2166
2490
  }
2167
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
2168
- for (const v of Object.values(value)) {
2169
- if (matchValue(match.type, v, match.value, match)) {
2170
- 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;
2171
2497
  }
2172
2498
  }
2173
2499
  continue;
@@ -2199,22 +2525,27 @@ var RuleEngine = class {
2199
2525
  };
2200
2526
 
2201
2527
  // src/protect/engine/pulse-client.js
2528
+ init_pulse_token();
2202
2529
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2203
2530
  var DEFAULT_CACHE_TTL2 = 3e5;
2204
2531
  var JITTER_FRACTION2 = 0.1;
2205
2532
  var PulseRuleClient = class {
2206
2533
  #siteUuid;
2534
+ #timeoutMs;
2207
2535
  #baseUrl;
2208
2536
  #cacheTtl;
2209
2537
  #cache = null;
2210
2538
  #cacheTime = null;
2211
2539
  #ttlEffective = 0;
2212
2540
  #etag;
2213
- constructor({ siteUuid, baseUrl, cacheTtl, etag } = {}) {
2541
+ #pulseAuth;
2542
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
2543
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2214
2544
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2215
- 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");
2216
2546
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
2217
2547
  this.#etag = etag ?? null;
2548
+ this.#pulseAuth = pulseAuth ?? null;
2218
2549
  if (!this.#siteUuid) {
2219
2550
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
2220
2551
  }
@@ -2226,9 +2557,15 @@ var PulseRuleClient = class {
2226
2557
  }
2227
2558
  const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
2228
2559
  try {
2229
- 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
+ };
2230
2567
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2231
- 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) });
2232
2569
  if (response.status === 304) {
2233
2570
  this.#touch(now);
2234
2571
  return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -2305,25 +2642,9 @@ async function fromFetchRequest(request, options = {}) {
2305
2642
  let body = {};
2306
2643
  let files;
2307
2644
  if (rawBody) {
2308
- if (contentType.includes("application/json")) {
2309
- try {
2310
- body = JSON.parse(rawBody);
2311
- } catch {
2312
- body = {};
2313
- }
2314
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
2315
- body = {};
2316
- for (const [k, v] of new URLSearchParams(rawBody)) {
2317
- body[k] = k in body ? [].concat(body[k], v) : v;
2318
- }
2319
- } else if (contentType.includes("multipart/form-data")) {
2320
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
2321
- if (boundary) {
2322
- const parsed = parseMultipart(rawBody, boundary);
2323
- body = parsed.body;
2324
- files = parsed.files;
2325
- }
2326
- }
2645
+ const parsed = parseBody(rawBody, contentType);
2646
+ body = parsed.body;
2647
+ files = parsed.files;
2327
2648
  }
2328
2649
  const uri = url.pathname + url.search;
2329
2650
  const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
@@ -2343,9 +2664,6 @@ async function fromFetchRequest(request, options = {}) {
2343
2664
  };
2344
2665
  }
2345
2666
  async function readCappedText(request, max) {
2346
- const ceiling = Math.max(max, max * 4);
2347
- const declared = Number(request.headers?.get?.("content-length") || 0);
2348
- if (declared && declared > ceiling) return "";
2349
2667
  let clone;
2350
2668
  try {
2351
2669
  clone = request.clone();
@@ -2391,6 +2709,31 @@ function concatChunks(chunks, total) {
2391
2709
  }
2392
2710
  return out;
2393
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
+ }
2394
2737
  function parseMultipart(rawBody, boundary) {
2395
2738
  const body = {};
2396
2739
  const files = {};
@@ -2405,7 +2748,9 @@ function parseMultipart(rawBody, boundary) {
2405
2748
  const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
2406
2749
  const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
2407
2750
  if (filename !== void 0) {
2408
- 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;
2409
2754
  } else {
2410
2755
  body[name] = name in body ? [].concat(body[name], content) : content;
2411
2756
  }
@@ -2457,25 +2802,9 @@ function fromNodeRequest(req, rawBody = "") {
2457
2802
  let body = {};
2458
2803
  let files;
2459
2804
  if (rawBody) {
2460
- if (contentType.includes("application/json")) {
2461
- try {
2462
- body = JSON.parse(rawBody);
2463
- } catch {
2464
- body = {};
2465
- }
2466
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
2467
- body = {};
2468
- for (const [k, v] of new URLSearchParams(rawBody)) {
2469
- body[k] = k in body ? [].concat(body[k], v) : v;
2470
- }
2471
- } else if (contentType.includes("multipart/form-data")) {
2472
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
2473
- if (boundary) {
2474
- const parsed = parseMultipart(rawBody, boundary);
2475
- body = parsed.body;
2476
- files = parsed.files;
2477
- }
2478
- }
2805
+ const parsed = parseBody(rawBody, contentType);
2806
+ body = parsed.body;
2807
+ files = parsed.files;
2479
2808
  }
2480
2809
  const uri = url.pathname + url.search;
2481
2810
  const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
@@ -2509,7 +2838,7 @@ function parseCookies2(header) {
2509
2838
  }
2510
2839
 
2511
2840
  // src/protect/egress.js
2512
- async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
2841
+ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
2513
2842
  const restores = [];
2514
2843
  if (typeof shouldBlock !== "function") return () => {
2515
2844
  };
@@ -2531,11 +2860,24 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
2531
2860
  screen = null;
2532
2861
  }
2533
2862
  }
2863
+ const skip = (reason, detail) => {
2864
+ try {
2865
+ onSkip?.({ phase: "egress", reason, detail });
2866
+ } catch {
2867
+ }
2868
+ };
2534
2869
  const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
2535
- 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);
2536
2875
  try {
2537
2876
  screen.lookup(host, { all: true }, (err, addresses) => {
2538
- if (err || !Array.isArray(addresses)) return resolve(false);
2877
+ if (err || !Array.isArray(addresses)) {
2878
+ skip("resolver-failed", { host });
2879
+ return resolve(false);
2880
+ }
2539
2881
  for (const a of addresses) {
2540
2882
  const ip = a && typeof a === "object" ? a.address : a;
2541
2883
  if (ip && block(url, ip, method)) return resolve(true);
@@ -2543,24 +2885,61 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
2543
2885
  resolve(false);
2544
2886
  });
2545
2887
  } catch {
2888
+ skip("resolver-failed", { host });
2546
2889
  resolve(false);
2547
2890
  }
2548
2891
  });
2549
2892
  const originalFetch = globalThis.fetch;
2550
2893
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2551
- const guarded = async (input, init) => {
2552
- 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) => {
2553
2897
  let host = null;
2554
2898
  try {
2555
- host = new URL(url).hostname;
2899
+ host = new URL(u).hostname;
2556
2900
  } catch {
2557
2901
  host = null;
2558
2902
  }
2559
- const method = init && init.method || input && input.method || "GET";
2560
- if (block(url, host, method) || await resolvesToDisallowed(url, host, method)) {
2561
- 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;
2562
2942
  }
2563
- return originalFetch(input, init);
2564
2943
  };
2565
2944
  guarded.__patchstackGuarded = true;
2566
2945
  globalThis.fetch = guarded;
@@ -2576,30 +2955,6 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
2576
2955
  } catch {
2577
2956
  }
2578
2957
  }
2579
- const OriginalWS = globalThis.WebSocket;
2580
- if (typeof OriginalWS === "function" && !OriginalWS.__patchstackGuarded) {
2581
- const GuardedWS = new Proxy(OriginalWS, {
2582
- construct(target, args, newTarget) {
2583
- const url = String(args?.[0] ?? "");
2584
- let host = null;
2585
- try {
2586
- host = new URL(url).hostname;
2587
- } catch {
2588
- host = null;
2589
- }
2590
- if (block(url, host, "WEBSOCKET")) {
2591
- throw new Error(`Patchstack blocked an outbound WebSocket to a disallowed address: ${host ?? url}`);
2592
- }
2593
- return Reflect.construct(target, args, newTarget);
2594
- }
2595
- });
2596
- OriginalWS.__patchstackGuarded = true;
2597
- globalThis.WebSocket = GuardedWS;
2598
- restores.push(() => {
2599
- if (globalThis.WebSocket === GuardedWS) globalThis.WebSocket = OriginalWS;
2600
- delete OriginalWS.__patchstackGuarded;
2601
- });
2602
- }
2603
2958
  return () => {
2604
2959
  for (const restore of restores) {
2605
2960
  try {
@@ -2730,6 +3085,7 @@ var DEFAULT_RESPONSE_RULES = [
2730
3085
  phase: "response",
2731
3086
  category: "secret-exposure",
2732
3087
  action: "redact",
3088
+ prefilter: ["PRIVATE KEY"],
2733
3089
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
2734
3090
  },
2735
3091
  {
@@ -2738,6 +3094,7 @@ var DEFAULT_RESPONSE_RULES = [
2738
3094
  phase: "response",
2739
3095
  category: "secret-exposure",
2740
3096
  action: "redact",
3097
+ prefilter: ["AKIA", "ASIA"],
2741
3098
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
2742
3099
  },
2743
3100
  {
@@ -2746,14 +3103,29 @@ var DEFAULT_RESPONSE_RULES = [
2746
3103
  phase: "response",
2747
3104
  category: "secret-exposure",
2748
3105
  action: "redact",
3106
+ prefilter: ["AIza"],
2749
3107
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
2750
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
+ },
2751
3122
  {
2752
3123
  id: "resp-jwt",
2753
3124
  title: "JWT in response body",
2754
3125
  phase: "response",
2755
3126
  category: "secret-exposure",
2756
3127
  action: "redact",
3128
+ prefilter: ["eyJ"],
2757
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/" } }]
2758
3130
  },
2759
3131
  {
@@ -2762,6 +3134,7 @@ var DEFAULT_RESPONSE_RULES = [
2762
3134
  phase: "response",
2763
3135
  category: "secret-exposure",
2764
3136
  action: "redact",
3137
+ prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
2765
3138
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
2766
3139
  },
2767
3140
  {
@@ -2770,6 +3143,8 @@ var DEFAULT_RESPONSE_RULES = [
2770
3143
  phase: "response",
2771
3144
  category: "info-exposure",
2772
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.
2773
3148
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
2774
3149
  },
2775
3150
  {
@@ -2778,6 +3153,7 @@ var DEFAULT_RESPONSE_RULES = [
2778
3153
  phase: "response",
2779
3154
  category: "info-exposure",
2780
3155
  action: "redact",
3156
+ prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
2781
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" } }]
2782
3158
  },
2783
3159
  {
@@ -2789,6 +3165,7 @@ var DEFAULT_RESPONSE_RULES = [
2789
3165
  // Multi-language exception/traceback signatures a normal API response never carries:
2790
3166
  // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
2791
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:"],
2792
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+ \\[)/' } }]
2793
3170
  }
2794
3171
  ];
@@ -2886,8 +3263,22 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
2886
3263
  }
2887
3264
 
2888
3265
  // src/protect/rules/store.js
2889
- var import_node_fs = require("fs");
2890
- 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
+ }
2891
3282
  function makeStore(options = {}) {
2892
3283
  let mem = null;
2893
3284
  const durable = durableTier(options);
@@ -2929,21 +3320,22 @@ function durableTier(options) {
2929
3320
  write: async (env) => cacheWrite(dir, env)
2930
3321
  };
2931
3322
  }
2932
- function cachePath(dir) {
2933
- return (0, import_node_path.join)(dir, "patchstack-rules.json");
2934
- }
2935
- function cacheWrite(dir, env) {
3323
+ async function cacheWrite(dir, env) {
2936
3324
  if (!dir) return;
3325
+ const fs = await loadFs();
3326
+ if (!fs) return;
2937
3327
  try {
2938
- (0, import_node_fs.mkdirSync)(dir, { recursive: true });
2939
- (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));
2940
3330
  } catch {
2941
3331
  }
2942
3332
  }
2943
- function cacheRead(dir) {
3333
+ async function cacheRead(dir) {
2944
3334
  if (!dir) return null;
3335
+ const fs = await loadFs();
3336
+ if (!fs) return null;
2945
3337
  try {
2946
- 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")));
2947
3339
  } catch {
2948
3340
  return null;
2949
3341
  }
@@ -2959,56 +3351,204 @@ function toEnvelope(value) {
2959
3351
  return null;
2960
3352
  }
2961
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
+
2962
3441
  // src/protect/rules/source.js
2963
- 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;
2964
3467
  if (options.siteUuid) {
2965
3468
  const prior = await store.read();
2966
- 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 });
2967
3470
  const res = await client.getRules();
2968
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
3471
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2969
3472
  if (res.success && !res.notModified) {
2970
- 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);
2971
3481
  await store.write({ bundle, etag: res.etag ?? null });
2972
3482
  return bundle;
2973
3483
  }
2974
3484
  if (prior?.bundle) {
2975
3485
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
2976
- return normalizeBundle(prior.bundle);
3486
+ return normalizeBundle(prior.bundle, options);
2977
3487
  }
2978
3488
  if (options.rules) {
2979
3489
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
2980
- return normalizeBundle(options.rules);
3490
+ return normalizeBundle(options.rules, options);
2981
3491
  }
2982
3492
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
2983
3493
  return emptyBundle();
2984
3494
  }
2985
3495
  if (options.token) {
2986
3496
  const prior = await store.read();
2987
- 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 });
2988
3498
  const res = await client.getRules();
2989
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
3499
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2990
3500
  if (res.success && !res.notModified) {
2991
- 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);
2992
3509
  await store.write({ bundle, etag: res.etag ?? null });
2993
3510
  return bundle;
2994
3511
  }
2995
3512
  if (prior?.bundle) {
2996
3513
  options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
2997
- return normalizeBundle(prior.bundle);
3514
+ return normalizeBundle(prior.bundle, options);
2998
3515
  }
2999
3516
  options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
3000
3517
  return emptyBundle();
3001
3518
  }
3002
3519
  if (options.rules) {
3003
- return normalizeBundle(options.rules);
3520
+ return normalizeBundle(options.rules, options);
3004
3521
  }
3005
3522
  return emptyBundle();
3006
3523
  }
3007
- function normalizeBundle(b) {
3524
+ function normalizeBundle(b, options = {}) {
3008
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
+ }
3009
3549
  return {
3010
- firewall: Array.isArray(b.firewall) ? b.firewall : [],
3011
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
3550
+ firewall: checked.firewall,
3551
+ whitelists: checked.whitelists,
3012
3552
  whitelist_keys: b.whitelist_keys ?? {},
3013
3553
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
3014
3554
  };
@@ -3075,7 +3615,7 @@ function makeRefreshHandler(tick, secret) {
3075
3615
  var DEFAULT_API_BASE = "https://api.patchstack.com";
3076
3616
  var DEFAULT_FLUSH_MS = 1e3;
3077
3617
  var MAX_BATCH = 50;
3078
- var TOKEN_SKEW_MS = 6e4;
3618
+ var TOKEN_SKEW_MS2 = 6e4;
3079
3619
  function parseApiKey(apiKey) {
3080
3620
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
3081
3621
  const idx = apiKey.lastIndexOf("-");
@@ -3088,7 +3628,11 @@ function parseApiKey(apiKey) {
3088
3628
  function resolveApiBase(pulseOrManifestUrl) {
3089
3629
  const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
3090
3630
  if (typeof fromEnv === "string" && fromEnv.length > 0) {
3091
- return fromEnv.replace(/\/$/, "");
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
+ );
3092
3636
  }
3093
3637
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
3094
3638
  try {
@@ -3123,7 +3667,7 @@ function createFirewallLogReporter(opts) {
3123
3667
  let cachedToken = null;
3124
3668
  let tokenInflight = null;
3125
3669
  const fetchAccessToken = async () => {
3126
- if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS) {
3670
+ if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
3127
3671
  return cachedToken.token;
3128
3672
  }
3129
3673
  if (tokenInflight) return tokenInflight;
@@ -3230,10 +3774,6 @@ function telemetryEnabled() {
3230
3774
  return !/^(0|false|off|no)$/i.test(String(v));
3231
3775
  }
3232
3776
 
3233
- // src/protect/runtime.js
3234
- var import_node_fs2 = require("fs");
3235
- var import_node_path8 = require("path");
3236
-
3237
3777
  // src/protect/supabase-guard.js
3238
3778
  var GUARD_PATH = "/_patchstack/guard";
3239
3779
  var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
@@ -3307,7 +3847,7 @@ function createServerFnGuard({ protection }) {
3307
3847
  async function createProtection(options = {}) {
3308
3848
  const onError = options.onError;
3309
3849
  const userOnDetect = options.onDetect ?? defaultOnDetect;
3310
- const apiKey = resolveApiKey(options);
3850
+ const apiKey = await resolveApiKey(options);
3311
3851
  const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
3312
3852
  apiKey,
3313
3853
  apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
@@ -3327,7 +3867,9 @@ async function createProtection(options = {}) {
3327
3867
  }
3328
3868
  };
3329
3869
  const store = makeStore(options);
3330
- 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 });
3331
3873
  let mode = resolveMode(options, bundle);
3332
3874
  let requestRules;
3333
3875
  let responseRules;
@@ -3351,17 +3893,42 @@ async function createProtection(options = {}) {
3351
3893
  responseRuleSet = responseRules.map((rule) => ({
3352
3894
  rule,
3353
3895
  engine: new RuleEngine({ firewall: [rule], onError }),
3354
- 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
3355
3906
  }));
3356
3907
  egressEngine = new RuleEngine({ firewall: egressRules, onError });
3357
3908
  };
3358
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
+ };
3359
3922
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3923
+ const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
3360
3924
  const decide = (phase, result, block, allow2, ctx = {}) => {
3361
3925
  if (!result || !result.blocked) return allow2();
3926
+ const effectiveMode = ruleMode(result.rule);
3362
3927
  onDetect({
3363
3928
  phase,
3364
- mode,
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,
3365
3932
  category: result.rule?.category,
3366
3933
  rule: result.rule,
3367
3934
  message: result.message,
@@ -3370,30 +3937,40 @@ async function createProtection(options = {}) {
3370
3937
  ip: ctx.ip,
3371
3938
  userAgent: ctx.userAgent
3372
3939
  });
3373
- return mode === "block" ? block() : allow2();
3374
- };
3375
- const isTextCT = (ct) => {
3376
- ct = (ct || "").toLowerCase();
3377
- return ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
3940
+ return effectiveMode === "block" ? block() : allow2();
3378
3941
  };
3379
- const screenText = (text, meta) => {
3942
+ const screenText = (text, meta, reqCtx) => {
3380
3943
  let blockRule = null;
3381
3944
  const redactions = [];
3382
- 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
+ }
3383
3952
  let result;
3384
3953
  try {
3385
- result = re.evaluate({ _response: { ...meta, body: text } });
3954
+ result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
3386
3955
  } catch (err) {
3387
3956
  onError?.(err);
3388
3957
  continue;
3389
3958
  }
3390
3959
  if (!result.blocked) continue;
3391
- onDetect({ phase: "response", mode, category: rule.category, rule, message: result.message });
3392
- if (mode !== "block") continue;
3393
- 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);
3394
3971
  else if (!blockRule) blockRule = rule;
3395
3972
  }
3396
- if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
3973
+ if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
3397
3974
  if (blockRule) return { verdict: "block" };
3398
3975
  let body = text;
3399
3976
  const headers = { ...meta.headers || {} };
@@ -3415,17 +3992,33 @@ async function createProtection(options = {}) {
3415
3992
  }
3416
3993
  }
3417
3994
  }
3995
+ for (const rule of headerMutations) applyHeaderMutation(headers, rule);
3418
3996
  return { verdict: "redact", body, headers };
3419
3997
  };
3420
- const screenResp = async (response) => {
3421
- const text = await readTextResponse(response, screenCap);
3422
- if (text == null) return response;
3423
- 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);
3424
4017
  if (r.verdict === "block") return leakResponse();
3425
4018
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
3426
4019
  return response;
3427
4020
  };
3428
- const wrapNodeResponse = (res) => {
4021
+ const wrapNodeResponse = (res, reqCtx) => {
3429
4022
  const origWrite = res.write.bind(res);
3430
4023
  const origEnd = res.end.bind(res);
3431
4024
  const chunks = [];
@@ -3441,6 +4034,7 @@ async function createProtection(options = {}) {
3441
4034
  chunks.length = 0;
3442
4035
  origWrite(buf);
3443
4036
  overflow = true;
4037
+ recordSkip("response", "body-cap", { bytes: size });
3444
4038
  return;
3445
4039
  }
3446
4040
  chunks.push(buf);
@@ -3467,16 +4061,19 @@ async function createProtection(options = {}) {
3467
4061
  }
3468
4062
  collect(chunk, enc);
3469
4063
  if (overflow) return origEnd(cb);
3470
- const text = Buffer.concat(chunks).toString("utf8");
4064
+ const buffer = Buffer.concat(chunks);
3471
4065
  let ct = res.getHeader ? res.getHeader("content-type") : void 0;
3472
4066
  if (Array.isArray(ct)) ct = ct[0];
3473
- 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");
3474
4070
  for (const c of chunks) origWrite(c);
3475
4071
  return origEnd(cb);
3476
4072
  }
4073
+ const text = buffer.toString("utf8");
3477
4074
  let r;
3478
4075
  try {
3479
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
4076
+ r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
3480
4077
  } catch (err) {
3481
4078
  onError?.(err);
3482
4079
  for (const c of chunks) origWrite(c);
@@ -3499,7 +4096,12 @@ async function createProtection(options = {}) {
3499
4096
  const current = res.getHeaders ? res.getHeaders() : {};
3500
4097
  for (const [name, value] of Object.entries(r.headers)) {
3501
4098
  if (name.toLowerCase() === "content-length") continue;
3502
- 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)) {
3503
4105
  try {
3504
4106
  res.setHeader(name, value);
3505
4107
  } catch {
@@ -3529,8 +4131,9 @@ async function createProtection(options = {}) {
3529
4131
  return false;
3530
4132
  }
3531
4133
  if (!result.blocked) return false;
3532
- onDetect({ phase: "egress", mode, category: result.rule?.category, rule: result.rule, message: result.message });
3533
- 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";
3534
4137
  };
3535
4138
  const protection = {
3536
4139
  get mode() {
@@ -3539,9 +4142,18 @@ async function createProtection(options = {}) {
3539
4142
  get rules() {
3540
4143
  return { request: requestRules, response: responseRules, egress: egressRules };
3541
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
+ },
3542
4154
  // Screen a fetch Response through the response-phase rules (redact/block). Used by
3543
4155
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
3544
- screenResponse: (response) => screenResp(response),
4156
+ screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
3545
4157
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
3546
4158
  fetchGuard() {
3547
4159
  return async (request) => {
@@ -3562,7 +4174,7 @@ async function createProtection(options = {}) {
3562
4174
  const blocked = await guard(request);
3563
4175
  if (blocked) return blocked;
3564
4176
  const response = await handler(request, ...rest);
3565
- return screenResp(response);
4177
+ return screenResp(response, reqContextFromFetch(request));
3566
4178
  };
3567
4179
  },
3568
4180
  // Express middleware (request phase; expects express-parsed req.query/req.body).
@@ -3574,7 +4186,7 @@ async function createProtection(options = {}) {
3574
4186
  result = engine.evaluate(req);
3575
4187
  } catch (err) {
3576
4188
  onError?.(err);
3577
- if (exprOptions.screenResponses) wrapNodeResponse(res);
4189
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3578
4190
  return next();
3579
4191
  }
3580
4192
  decide(
@@ -3588,7 +4200,7 @@ async function createProtection(options = {}) {
3588
4200
  }
3589
4201
  },
3590
4202
  () => {
3591
- if (exprOptions.screenResponses) wrapNodeResponse(res);
4203
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3592
4204
  next();
3593
4205
  },
3594
4206
  nodeRequestMeta(req)
@@ -3616,6 +4228,7 @@ async function createProtection(options = {}) {
3616
4228
  next();
3617
4229
  });
3618
4230
  req.on("end", () => {
4231
+ if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
3619
4232
  const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
3620
4233
  let shaped;
3621
4234
  let result;
@@ -3641,7 +4254,7 @@ async function createProtection(options = {}) {
3641
4254
  },
3642
4255
  () => {
3643
4256
  if (req.body === void 0) req.body = shaped.body;
3644
- if (nodeOptions.screenResponses) wrapNodeResponse(res);
4257
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3645
4258
  next();
3646
4259
  },
3647
4260
  nodeRequestMeta(req)
@@ -3654,6 +4267,9 @@ async function createProtection(options = {}) {
3654
4267
  protection.uninstallEgress = await installEgressGuard({
3655
4268
  shouldBlock: egressShouldBlock,
3656
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),
3657
4273
  dnsScreen: options.screenDns !== false,
3658
4274
  allowHosts: options.allowHosts
3659
4275
  });
@@ -3678,7 +4294,7 @@ async function createProtection(options = {}) {
3678
4294
  onError?.(err);
3679
4295
  }
3680
4296
  }
3681
- const next = await resolveRules(options, store);
4297
+ const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
3682
4298
  mode = resolveMode(options, next);
3683
4299
  applyBundle(next);
3684
4300
  };
@@ -3705,7 +4321,7 @@ function resolveMode(options, bundle) {
3705
4321
  if (options?.mode === "dry-run") return "dry-run";
3706
4322
  return "dry-run";
3707
4323
  }
3708
- function resolveApiKey(options) {
4324
+ async function resolveApiKey(options) {
3709
4325
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
3710
4326
  if (typeof process !== "undefined") {
3711
4327
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
@@ -3713,29 +4329,69 @@ function resolveApiKey(options) {
3713
4329
  }
3714
4330
  try {
3715
4331
  if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
4332
+ const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
3716
4333
  const cwd = options?.cwd ?? process.cwd();
3717
- const raw = (0, import_node_fs2.readFileSync)((0, import_node_path8.join)(cwd, ".patchstackrc.json"), "utf8");
4334
+ const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3718
4335
  const key = JSON.parse(raw)?.apiKey;
3719
4336
  if (typeof key === "string" && key.length > 0) return key;
3720
4337
  } catch {
3721
4338
  }
3722
4339
  return void 0;
3723
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
+ }
3724
4358
  function byPhase(rules, phase) {
3725
4359
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
3726
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
+ }
3727
4382
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
3728
- if (!response || typeof response.clone !== "function") return null;
3729
- const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
3730
- const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
3731
- 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";
3732
4388
  const len = Number(response.headers?.get?.("content-length") || 0);
3733
- if (len && len > cap) return null;
4389
+ if (len && len > cap) return { skip: "body-cap" };
3734
4390
  let clone;
3735
4391
  try {
3736
4392
  clone = response.clone();
3737
4393
  } catch {
3738
- return null;
4394
+ return { skip: "clone-failed" };
3739
4395
  }
3740
4396
  const body = clone.body;
3741
4397
  if (body && typeof body.getReader === "function") {
@@ -3743,11 +4399,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
3743
4399
  const chunks = [];
3744
4400
  let size = 0;
3745
4401
  let over = false;
4402
+ let sniffed = !sniff;
3746
4403
  try {
3747
4404
  for (; ; ) {
3748
4405
  const { done, value } = await reader.read();
3749
4406
  if (done) break;
3750
4407
  if (!value) continue;
4408
+ if (!sniffed) {
4409
+ sniffed = true;
4410
+ if (looksBinary(value)) return { skip: "binary-body" };
4411
+ }
3751
4412
  size += value.byteLength;
3752
4413
  if (over) continue;
3753
4414
  if (size > cap) {
@@ -3757,20 +4418,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
3757
4418
  chunks.push(value);
3758
4419
  }
3759
4420
  } catch {
3760
- return null;
4421
+ return { skip: "read-failed" };
3761
4422
  }
3762
- if (over) return null;
4423
+ if (over) return { skip: "body-cap" };
3763
4424
  try {
3764
- return new TextDecoder().decode(concatBytes(chunks, size));
4425
+ return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
3765
4426
  } catch {
3766
- return null;
4427
+ return { skip: "decode-failed" };
3767
4428
  }
3768
4429
  }
3769
4430
  try {
3770
4431
  const text = await clone.text();
3771
- 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 };
3772
4435
  } catch {
3773
- return null;
4436
+ return { skip: "read-failed" };
3774
4437
  }
3775
4438
  }
3776
4439
  function concatBytes(chunks, total) {
@@ -3791,6 +4454,19 @@ function headerObject(headers) {
3791
4454
  if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
3792
4455
  return out;
3793
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
+ }
3794
4470
  function extractRedactors(rule) {
3795
4471
  const out = [];
3796
4472
  const walk2 = (conds) => {
@@ -3827,7 +4503,10 @@ function applyRedactors(body, redactors, mask, transform) {
3827
4503
  let out = body;
3828
4504
  for (const r of redactors) {
3829
4505
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
3830
- 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
+ }
3831
4510
  }
3832
4511
  return out;
3833
4512
  }
@@ -3930,12 +4609,52 @@ function restoreBigInts(text) {
3930
4609
  if (!text.includes(BIGINT_OPEN)) return text;
3931
4610
  return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
3932
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
+ }
3933
4647
  function rebuildResponse(response, body, redactedHeaders) {
3934
4648
  const headers = new Headers(response.headers);
3935
4649
  headers.delete("content-length");
3936
4650
  if (redactedHeaders) {
3937
4651
  for (const [name, value] of Object.entries(redactedHeaders)) {
3938
- 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") {
3939
4658
  if (headers.get(name) !== value) {
3940
4659
  try {
3941
4660
  headers.set(name, value);
@@ -3951,7 +4670,8 @@ function rebuildResponse(response, body, redactedHeaders) {
3951
4670
  }
3952
4671
  }
3953
4672
  }
3954
- 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 });
3955
4675
  }
3956
4676
  function leakResponse() {
3957
4677
  return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {