@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.js CHANGED
@@ -1,3 +1,30 @@
1
+ import {
2
+ pulseAuthHeader
3
+ } from "./chunk-MJOTFUDE.js";
4
+
5
+ // src/protect/safe-origin.js
6
+ function isSafeOrigin(value) {
7
+ try {
8
+ const u = new URL(value);
9
+ if (u.protocol === "https:") return true;
10
+ return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+ function safeBaseUrl(candidate, fallback, label) {
16
+ if (typeof candidate !== "string" || candidate === "") return fallback;
17
+ if (isSafeOrigin(candidate)) return candidate;
18
+ warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
19
+ return fallback;
20
+ }
21
+ var warned = /* @__PURE__ */ new Set();
22
+ function warnOnce(key, message) {
23
+ if (warned.has(key)) return;
24
+ warned.add(key);
25
+ console.warn(message);
26
+ }
27
+
1
28
  // src/protect/engine/client.js
2
29
  var DEFAULT_BASE_URL = "https://api.patchstack.com";
3
30
  var DEFAULT_CACHE_TTL = 3e5;
@@ -10,9 +37,11 @@ var PatchstackRuleClient = class {
10
37
  #cacheTime = null;
11
38
  #ttlEffective = 0;
12
39
  #etag;
13
- constructor({ token, baseUrl, cacheTtl, etag } = {}) {
40
+ #timeoutMs;
41
+ constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
42
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
14
43
  this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
15
- this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL ?? DEFAULT_BASE_URL;
44
+ this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_WAF_API_URL, DEFAULT_BASE_URL, "rule endpoint");
16
45
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
17
46
  this.#etag = etag ?? null;
18
47
  if (!this.#token) {
@@ -37,7 +66,7 @@ var PatchstackRuleClient = class {
37
66
  method: "POST",
38
67
  headers,
39
68
  body: JSON.stringify({}),
40
- signal: AbortSignal.timeout(3e4)
69
+ signal: AbortSignal.timeout(this.#timeoutMs)
41
70
  });
42
71
  if (response.status === 304) {
43
72
  this.#touch(now);
@@ -90,6 +119,9 @@ var PatchstackRuleClient = class {
90
119
  };
91
120
 
92
121
  // src/protect/engine/request.js
122
+ var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
123
+ var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
124
+ var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
93
125
  function base64DecodeUtf8(value) {
94
126
  const str = String(value);
95
127
  if (typeof Buffer !== "undefined") {
@@ -293,7 +325,9 @@ var RequestResolver = class {
293
325
  default: {
294
326
  if (key.startsWith("HTTP_")) {
295
327
  const headerName = key.substring(5).toLowerCase().replace(/_/g, "-");
296
- return req.headers?.[headerName] ? [req.headers[headerName]] : [];
328
+ const headers = req.headers;
329
+ if (headers === null || typeof headers !== "object") return [];
330
+ return Object.prototype.hasOwnProperty.call(headers, headerName) ? [headers[headerName]] : [];
297
331
  }
298
332
  return [];
299
333
  }
@@ -301,14 +335,32 @@ var RequestResolver = class {
301
335
  }
302
336
  #resolveFiles(key) {
303
337
  const files = this.#req.files;
304
- if (!files) {
338
+ if (!files || typeof files !== "object") {
305
339
  return [];
306
340
  }
341
+ const dot = key.lastIndexOf(".");
342
+ if (dot !== -1 && FILE_ATTRS.has(key.slice(dot + 1)) && Object.prototype.hasOwnProperty.call(files, key.slice(0, dot))) {
343
+ const attr = key.slice(dot + 1);
344
+ const entry = files[key.slice(0, dot)];
345
+ const list = Array.isArray(entry) ? entry : [entry];
346
+ const out = [];
347
+ for (const f of list) {
348
+ const v = fileAttribute(f, attr);
349
+ if (v !== void 0 && v !== "") out.push(v);
350
+ }
351
+ return out;
352
+ }
353
+ const filenamesOf = (entry) => Array.isArray(entry) ? entry.map(fileFilename) : [fileFilename(entry)];
307
354
  if (key.endsWith("*")) {
308
- return this.#resolveWildcard(files, key);
355
+ const prefix = key.slice(0, -1);
356
+ const out = [];
357
+ for (const [k, entry] of Object.entries(files)) {
358
+ if (k.startsWith(prefix)) out.push(...filenamesOf(entry));
359
+ }
360
+ return out.filter((v) => v !== void 0);
309
361
  }
310
- const value = files[key];
311
- return value !== void 0 ? [value] : [];
362
+ if (!Object.prototype.hasOwnProperty.call(files, key)) return [];
363
+ return filenamesOf(files[key]).filter((v) => v !== void 0);
312
364
  }
313
365
  #resolveRaw() {
314
366
  if (typeof this.#req._rawBody === "string") {
@@ -339,6 +391,9 @@ var RequestResolver = class {
339
391
  if (body) {
340
392
  parts.push(typeof body === "string" ? body : JSON.stringify(body));
341
393
  }
394
+ if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
395
+ parts.push(this.#req._rawBody);
396
+ }
342
397
  const headers = this.#req.headers ?? {};
343
398
  const excludedHeaders = /* @__PURE__ */ new Set([
344
399
  "host",
@@ -531,8 +586,6 @@ function removeSqlComments(value) {
531
586
  let result = value;
532
587
  result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
533
588
  result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
534
- result = result.replace(/--[^\r\n]*/g, "");
535
- result = result.replace(/#[^\r\n]*/g, "");
536
589
  return result;
537
590
  }
538
591
  function removeNullBytes(value) {
@@ -587,17 +640,21 @@ function normalizeRequest(req, options = {}) {
587
640
  _rawBody: rawBody
588
641
  };
589
642
  }
590
- function normalizeObject(value, options = {}) {
643
+ var MAX_NORMALIZE_DEPTH = 200;
644
+ function normalizeObject(value, options = {}, depth = 0) {
591
645
  if (typeof value === "string") {
592
646
  return normalize(value, options);
593
647
  }
648
+ if (depth >= MAX_NORMALIZE_DEPTH) {
649
+ return value;
650
+ }
594
651
  if (Array.isArray(value)) {
595
- return value.map((item) => normalizeObject(item, options));
652
+ return value.map((item) => normalizeObject(item, options, depth + 1));
596
653
  }
597
654
  if (typeof value === "object" && value !== null) {
598
655
  const result = {};
599
656
  for (const [key, val] of Object.entries(value)) {
600
- result[key] = normalizeObject(val, options);
657
+ result[key] = normalizeObject(val, options, depth + 1);
601
658
  }
602
659
  return result;
603
660
  }
@@ -605,14 +662,32 @@ function normalizeObject(value, options = {}) {
605
662
  }
606
663
 
607
664
  // src/protect/engine/engine.js
665
+ var GRP = "(?:[^()]|\\([^()]*\\))*";
608
666
  var REDOS_PATTERNS = [
609
667
  /\([^)]*[+*}][^)]*\)\s*[+*]/,
610
- /\([^)]*\|[^)]*\)\s*[+*]/
668
+ /\([^)]*\|[^)]*\)\s*[+*]/,
669
+ new RegExp("\\(" + GRP + "[+*}]" + GRP + "\\)\\s*[+*]"),
670
+ // nested quantified subgroup
671
+ new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
672
+ // nested alternation under a quantifier
611
673
  ];
674
+ var warnedRejectedPatterns = /* @__PURE__ */ new Set();
675
+ function warnRejectedPatternOnce(pattern) {
676
+ const key = String(pattern);
677
+ if (warnedRejectedPatterns.has(key)) return;
678
+ warnedRejectedPatterns.add(key);
679
+ console.warn(
680
+ `[patchstack] rule_v2 regex pattern rejected (unsafe or invalid) \u2014 condition treated as no-match. The rule relying on it is NOT enforced: ${key}`
681
+ );
682
+ }
683
+ var MAX_PATTERN_LENGTH = 1e3;
612
684
  function safeRegExp(pattern) {
613
685
  if (!pattern) {
614
686
  return null;
615
687
  }
688
+ if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
689
+ return null;
690
+ }
616
691
  for (const dangerous of REDOS_PATTERNS) {
617
692
  if (dangerous.test(pattern)) {
618
693
  return null;
@@ -669,6 +744,34 @@ function arrayKeyValue(value, matchObj) {
669
744
  }
670
745
  return false;
671
746
  }
747
+ var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
748
+ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
749
+ const out = [];
750
+ const stack = [[root, 0]];
751
+ let visited = 0;
752
+ while (stack.length) {
753
+ const [node, depth] = stack.pop();
754
+ if (node === null || node === void 0) continue;
755
+ if (typeof node !== "object") {
756
+ out.push(node);
757
+ continue;
758
+ }
759
+ if (depth >= maxDepth || visited >= nodeCap) continue;
760
+ visited++;
761
+ if (Array.isArray(node)) {
762
+ for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
763
+ } else {
764
+ for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
765
+ }
766
+ }
767
+ return out;
768
+ }
769
+ var warnedKeys = /* @__PURE__ */ new Set();
770
+ function warnOnce2(key, message) {
771
+ if (warnedKeys.has(key)) return;
772
+ warnedKeys.add(key);
773
+ console.warn(message);
774
+ }
672
775
  var warnedMatchTypes = /* @__PURE__ */ new Set();
673
776
  function warnUnsupportedMatchType(type) {
674
777
  if (type == null || warnedMatchTypes.has(type)) {
@@ -681,28 +784,93 @@ function warnUnsupportedMatchType(type) {
681
784
  }
682
785
  function isInternalHost(hostname) {
683
786
  if (!hostname) return false;
684
- const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
787
+ let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
788
+ host = host.replace(/%[^\]]*$/, "");
789
+ host = host.replace(/\.$/, "");
685
790
  if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
686
791
  if (host === "metadata.google.internal") return true;
687
- if (host === "::1" || host === "::") return true;
688
- if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
689
- const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
690
- if (v4 && isPrivateV4(Number(v4[1]), Number(v4[2]))) return true;
691
- const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
692
- if (mapped) {
693
- const g1 = parseInt(mapped[1], 16);
694
- const g2 = parseInt(mapped[2], 16);
695
- if (isPrivateV4(g1 >> 8 & 255, g1 & 255)) return true;
792
+ if (host.includes(":")) {
793
+ const g = expandIPv6(host);
794
+ if (!g) return false;
795
+ const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
796
+ if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
797
+ if ((g[0] & 65472) === 65152) return true;
798
+ if ((g[0] & 65024) === 64512) return true;
799
+ if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
800
+ return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
801
+ }
802
+ return false;
696
803
  }
804
+ const v4 = parseIPv4ToInt(host);
805
+ if (v4 !== null) return isPrivateV4Int(v4);
697
806
  return false;
698
807
  }
699
- function isPrivateV4(a, b) {
808
+ function isPrivateV4Int(n) {
809
+ const a = n >>> 24 & 255;
810
+ const b = n >>> 16 & 255;
700
811
  if (a === 127 || a === 10 || a === 0) return true;
701
812
  if (a === 169 && b === 254) return true;
702
813
  if (a === 172 && b >= 16 && b <= 31) return true;
703
814
  if (a === 192 && b === 168) return true;
815
+ if (a === 100 && b >= 64 && b <= 127) return true;
704
816
  return false;
705
817
  }
818
+ function parseIPv4ToInt(host) {
819
+ if (!/^[0-9a-fx]+(\.[0-9a-fx]+)*$/i.test(host)) return null;
820
+ const parts = host.split(".");
821
+ if (parts.length > 4) return null;
822
+ const nums = [];
823
+ for (const p of parts) {
824
+ let n;
825
+ if (/^0x[0-9a-f]+$/i.test(p)) n = parseInt(p, 16);
826
+ else if (/^0[0-7]+$/.test(p)) n = parseInt(p, 8);
827
+ else if (/^[0-9]+$/.test(p)) n = parseInt(p, 10);
828
+ else return null;
829
+ if (!Number.isInteger(n) || n < 0) return null;
830
+ nums.push(n);
831
+ }
832
+ const last = nums.length - 1;
833
+ let value = 0;
834
+ for (let i = 0; i < last; i++) {
835
+ if (nums[i] > 255) return null;
836
+ value += nums[i] * 2 ** (8 * (3 - i));
837
+ }
838
+ const maxLast = 2 ** (8 * (5 - nums.length)) - 1;
839
+ if (nums[last] > maxLast) return null;
840
+ value += nums[last];
841
+ if (value < 0 || value > 4294967295) return null;
842
+ return value >>> 0;
843
+ }
844
+ function expandIPv6(host) {
845
+ if (!host.includes(":")) return null;
846
+ let s = host;
847
+ const lastColon = s.lastIndexOf(":");
848
+ const tail = s.slice(lastColon + 1);
849
+ if (tail.includes(".")) {
850
+ const v4 = parseIPv4ToInt(tail);
851
+ if (v4 === null) return null;
852
+ s = s.slice(0, lastColon + 1) + (v4 >>> 16 & 65535).toString(16) + ":" + (v4 & 65535).toString(16);
853
+ }
854
+ const halves = s.split("::");
855
+ if (halves.length > 2) return null;
856
+ const head = halves[0] ? halves[0].split(":") : [];
857
+ const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
858
+ let groups;
859
+ if (back === null) {
860
+ groups = head;
861
+ } else {
862
+ const fill = 8 - head.length - back.length;
863
+ if (fill < 0) return null;
864
+ groups = [...head, ...Array(fill).fill("0"), ...back];
865
+ }
866
+ if (groups.length !== 8) return null;
867
+ const out = [];
868
+ for (const g of groups) {
869
+ if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
870
+ out.push(parseInt(g, 16));
871
+ }
872
+ return out;
873
+ }
706
874
  function ruleAppliesTo(when, resolver) {
707
875
  try {
708
876
  if (when.method) {
@@ -730,14 +898,22 @@ function pathMatches(pattern, path) {
730
898
  }
731
899
  return path === pattern;
732
900
  }
901
+ function normalizeDefaultPort(host) {
902
+ return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
903
+ }
733
904
  function isCrossOrigin(resolver) {
734
905
  try {
735
- const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
906
+ const host = normalizeDefaultPort(String(resolver.resolve("server.HTTP_HOST")[0] ?? ""));
736
907
  if (!host) return false;
737
- const src = resolver.resolve("server.HTTP_ORIGIN")[0] ?? resolver.resolve("server.HTTP_REFERER")[0];
738
- if (!src) return false;
739
- const srcHost = hostFromUrl(String(src));
740
- return srcHost !== null && srcHost !== host;
908
+ const originRaw = resolver.resolve("server.HTTP_ORIGIN")[0];
909
+ const hasOrigin = originRaw !== void 0 && originRaw !== null;
910
+ const src = hasOrigin ? originRaw : resolver.resolve("server.HTTP_REFERER")[0];
911
+ if (src === void 0 || src === null) return false;
912
+ const s = String(src).trim();
913
+ if (hasOrigin && (s === "" || s.toLowerCase() === "null")) return true;
914
+ const srcHost = hostFromUrl(s);
915
+ if (srcHost === null) return hasOrigin;
916
+ return normalizeDefaultPort(srcHost) !== host;
741
917
  } catch {
742
918
  return false;
743
919
  }
@@ -749,6 +925,41 @@ function hostFromUrl(value) {
749
925
  return null;
750
926
  }
751
927
  }
928
+ function isOffOriginRedirect(resolver) {
929
+ try {
930
+ const status = Number(resolver.resolve("response.status")[0] ?? 0);
931
+ if (status < 300 || status >= 400) return false;
932
+ const location = resolver.resolve("response.header.location")[0];
933
+ if (!location) return false;
934
+ const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
935
+ if (!host) return false;
936
+ const loc = String(location).replace(/[\t\r\n]/g, "").replace(/\\/g, "/");
937
+ let target;
938
+ try {
939
+ target = new URL(loc, "http://" + host).host.toLowerCase();
940
+ } catch {
941
+ return false;
942
+ }
943
+ return normalizeDefaultPort(target) !== normalizeDefaultPort(host);
944
+ } catch {
945
+ return false;
946
+ }
947
+ }
948
+ function isReflectedCorsWithCredentials(resolver) {
949
+ try {
950
+ const acac = String(resolver.resolve("response.header.access-control-allow-credentials")[0] ?? "").toLowerCase();
951
+ if (acac !== "true") return false;
952
+ const acao = String(resolver.resolve("response.header.access-control-allow-origin")[0] ?? "");
953
+ if (!acao) return false;
954
+ if (acao === "*") return true;
955
+ if (acao.toLowerCase() === "null") return true;
956
+ const origin = String(resolver.resolve("server.HTTP_ORIGIN")[0] ?? "");
957
+ if (!origin) return false;
958
+ return acao === origin;
959
+ } catch {
960
+ return false;
961
+ }
962
+ }
752
963
  function matchValue(type, value, matchVal, matchObj) {
753
964
  if (value === null || value === void 0) {
754
965
  if (type === "isset") {
@@ -756,7 +967,16 @@ function matchValue(type, value, matchVal, matchObj) {
756
967
  }
757
968
  return false;
758
969
  }
759
- const strValue = typeof value === "string" ? value : String(value);
970
+ let strValue;
971
+ if (typeof value === "string") {
972
+ strValue = value;
973
+ } else {
974
+ try {
975
+ strValue = String(value);
976
+ } catch {
977
+ strValue = "";
978
+ }
979
+ }
760
980
  switch (type) {
761
981
  case "equals":
762
982
  return strValue == matchVal;
@@ -777,6 +997,7 @@ function matchValue(type, value, matchVal, matchObj) {
777
997
  case "regex": {
778
998
  const regex = safeRegExp(matchVal);
779
999
  if (!regex) {
1000
+ warnRejectedPatternOnce(matchVal);
780
1001
  return false;
781
1002
  }
782
1003
  return regex.test(strValue);
@@ -855,6 +1076,15 @@ var RuleEngine = class {
855
1076
  this.#whitelists = whitelists;
856
1077
  this.#whitelistKeys = whitelist_keys;
857
1078
  this.#onError = onError;
1079
+ if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
1080
+ warnOnce2(
1081
+ "whitelist-no-rule-id",
1082
+ "[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."
1083
+ );
1084
+ }
1085
+ if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
1086
+ warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
1087
+ }
858
1088
  }
859
1089
  // A mitigation engine must never take down the app it protects: any error while
860
1090
  // evaluating a rule is reported and the request is allowed through (fail open). A
@@ -935,6 +1165,12 @@ var RuleEngine = class {
935
1165
  if (match && match.type === "cross_origin") {
936
1166
  return isCrossOrigin(resolver);
937
1167
  }
1168
+ if (match && match.type === "off_origin") {
1169
+ return isOffOriginRedirect(resolver);
1170
+ }
1171
+ if (match && match.type === "cors_reflected") {
1172
+ return isReflectedCorsWithCredentials(resolver);
1173
+ }
938
1174
  const params = Array.isArray(parameter) ? parameter : [parameter];
939
1175
  const values = params.flatMap((p) => resolver.resolve(p));
940
1176
  if (values.length === 0) {
@@ -950,10 +1186,12 @@ var RuleEngine = class {
950
1186
  }
951
1187
  continue;
952
1188
  }
953
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
954
- for (const v of Object.values(value)) {
955
- if (matchValue(match.type, v, match.value, match)) {
956
- return true;
1189
+ if (typeof value === "object" && value !== null) {
1190
+ if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
1191
+ if (matchValue(match.type, value, match.value, match)) return true;
1192
+ } else {
1193
+ for (const leaf of collectLeafValues(value)) {
1194
+ if (matchValue(match.type, leaf, match.value, match)) return true;
957
1195
  }
958
1196
  }
959
1197
  continue;
@@ -990,17 +1228,21 @@ var DEFAULT_CACHE_TTL2 = 3e5;
990
1228
  var JITTER_FRACTION2 = 0.1;
991
1229
  var PulseRuleClient = class {
992
1230
  #siteUuid;
1231
+ #timeoutMs;
993
1232
  #baseUrl;
994
1233
  #cacheTtl;
995
1234
  #cache = null;
996
1235
  #cacheTime = null;
997
1236
  #ttlEffective = 0;
998
1237
  #etag;
999
- constructor({ siteUuid, baseUrl, cacheTtl, etag } = {}) {
1238
+ #pulseAuth;
1239
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
1240
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
1000
1241
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
1001
- this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL2;
1242
+ this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
1002
1243
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
1003
1244
  this.#etag = etag ?? null;
1245
+ this.#pulseAuth = pulseAuth ?? null;
1004
1246
  if (!this.#siteUuid) {
1005
1247
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
1006
1248
  }
@@ -1012,9 +1254,15 @@ var PulseRuleClient = class {
1012
1254
  }
1013
1255
  const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
1014
1256
  try {
1015
- const headers = { Accept: "application/json" };
1257
+ const headers = {
1258
+ Accept: "application/json",
1259
+ ...await pulseAuthHeader(
1260
+ { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
1261
+ fetch
1262
+ )
1263
+ };
1016
1264
  if (this.#etag) headers["If-None-Match"] = this.#etag;
1017
- const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(3e4) });
1265
+ const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
1018
1266
  if (response.status === 304) {
1019
1267
  this.#touch(now);
1020
1268
  return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -1091,25 +1339,9 @@ async function fromFetchRequest(request, options = {}) {
1091
1339
  let body = {};
1092
1340
  let files;
1093
1341
  if (rawBody) {
1094
- if (contentType.includes("application/json")) {
1095
- try {
1096
- body = JSON.parse(rawBody);
1097
- } catch {
1098
- body = {};
1099
- }
1100
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
1101
- body = {};
1102
- for (const [k, v] of new URLSearchParams(rawBody)) {
1103
- body[k] = k in body ? [].concat(body[k], v) : v;
1104
- }
1105
- } else if (contentType.includes("multipart/form-data")) {
1106
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
1107
- if (boundary) {
1108
- const parsed = parseMultipart(rawBody, boundary);
1109
- body = parsed.body;
1110
- files = parsed.files;
1111
- }
1112
- }
1342
+ const parsed = parseBody(rawBody, contentType);
1343
+ body = parsed.body;
1344
+ files = parsed.files;
1113
1345
  }
1114
1346
  const uri = url.pathname + url.search;
1115
1347
  const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
@@ -1129,9 +1361,6 @@ async function fromFetchRequest(request, options = {}) {
1129
1361
  };
1130
1362
  }
1131
1363
  async function readCappedText(request, max) {
1132
- const ceiling = Math.max(max, max * 4);
1133
- const declared = Number(request.headers?.get?.("content-length") || 0);
1134
- if (declared && declared > ceiling) return "";
1135
1364
  let clone;
1136
1365
  try {
1137
1366
  clone = request.clone();
@@ -1177,6 +1406,31 @@ function concatChunks(chunks, total) {
1177
1406
  }
1178
1407
  return out;
1179
1408
  }
1409
+ function parseBody(rawBody, contentType) {
1410
+ const ct = String(contentType || "").toLowerCase();
1411
+ const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
1412
+ const isForm = ct.includes("application/x-www-form-urlencoded");
1413
+ const isMultipart = ct.includes("multipart/form-data");
1414
+ const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
1415
+ if (isMultipart) {
1416
+ const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
1417
+ if (boundary) return parseMultipart(rawBody, boundary);
1418
+ return { body: {}, files: void 0 };
1419
+ }
1420
+ if (isForm) {
1421
+ const body = {};
1422
+ for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
1423
+ return { body, files: void 0 };
1424
+ }
1425
+ if (isJson || isAmbiguous) {
1426
+ try {
1427
+ const parsed = JSON.parse(rawBody);
1428
+ if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
1429
+ } catch {
1430
+ }
1431
+ }
1432
+ return { body: {}, files: void 0 };
1433
+ }
1180
1434
  function parseMultipart(rawBody, boundary) {
1181
1435
  const body = {};
1182
1436
  const files = {};
@@ -1191,7 +1445,9 @@ function parseMultipart(rawBody, boundary) {
1191
1445
  const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
1192
1446
  const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
1193
1447
  if (filename !== void 0) {
1194
- files[name] = name in files ? [].concat(files[name], filename) : filename;
1448
+ const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
1449
+ const file = { filename, type: partType, content };
1450
+ files[name] = name in files ? [].concat(files[name], file) : file;
1195
1451
  } else {
1196
1452
  body[name] = name in body ? [].concat(body[name], content) : content;
1197
1453
  }
@@ -1243,25 +1499,9 @@ function fromNodeRequest(req, rawBody = "") {
1243
1499
  let body = {};
1244
1500
  let files;
1245
1501
  if (rawBody) {
1246
- if (contentType.includes("application/json")) {
1247
- try {
1248
- body = JSON.parse(rawBody);
1249
- } catch {
1250
- body = {};
1251
- }
1252
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
1253
- body = {};
1254
- for (const [k, v] of new URLSearchParams(rawBody)) {
1255
- body[k] = k in body ? [].concat(body[k], v) : v;
1256
- }
1257
- } else if (contentType.includes("multipart/form-data")) {
1258
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
1259
- if (boundary) {
1260
- const parsed = parseMultipart(rawBody, boundary);
1261
- body = parsed.body;
1262
- files = parsed.files;
1263
- }
1264
- }
1502
+ const parsed = parseBody(rawBody, contentType);
1503
+ body = parsed.body;
1504
+ files = parsed.files;
1265
1505
  }
1266
1506
  const uri = url.pathname + url.search;
1267
1507
  const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
@@ -1295,7 +1535,7 @@ function parseCookies2(header) {
1295
1535
  }
1296
1536
 
1297
1537
  // src/protect/egress.js
1298
- async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
1538
+ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
1299
1539
  const restores = [];
1300
1540
  if (typeof shouldBlock !== "function") return () => {
1301
1541
  };
@@ -1317,11 +1557,24 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
1317
1557
  screen = null;
1318
1558
  }
1319
1559
  }
1560
+ const skip = (reason, detail) => {
1561
+ try {
1562
+ onSkip?.({ phase: "egress", reason, detail });
1563
+ } catch {
1564
+ }
1565
+ };
1320
1566
  const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
1321
- if (!screen || !host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
1567
+ if (!screen) {
1568
+ if (host && dnsScreen) skip("resolver-unavailable", { host });
1569
+ return resolve(false);
1570
+ }
1571
+ if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
1322
1572
  try {
1323
1573
  screen.lookup(host, { all: true }, (err, addresses) => {
1324
- if (err || !Array.isArray(addresses)) return resolve(false);
1574
+ if (err || !Array.isArray(addresses)) {
1575
+ skip("resolver-failed", { host });
1576
+ return resolve(false);
1577
+ }
1325
1578
  for (const a of addresses) {
1326
1579
  const ip = a && typeof a === "object" ? a.address : a;
1327
1580
  if (ip && block(url, ip, method)) return resolve(true);
@@ -1329,24 +1582,61 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
1329
1582
  resolve(false);
1330
1583
  });
1331
1584
  } catch {
1585
+ skip("resolver-failed", { host });
1332
1586
  resolve(false);
1333
1587
  }
1334
1588
  });
1335
1589
  const originalFetch = globalThis.fetch;
1336
1590
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
1337
- const guarded = async (input, init) => {
1338
- const url = typeof input === "string" ? input : input && input.url || String(input);
1591
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
1592
+ const MAX_REDIRECTS = 20;
1593
+ const screenUrl = async (u, method) => {
1339
1594
  let host = null;
1340
1595
  try {
1341
- host = new URL(url).hostname;
1596
+ host = new URL(u).hostname;
1342
1597
  } catch {
1343
1598
  host = null;
1344
1599
  }
1345
- const method = init && init.method || input && input.method || "GET";
1346
- if (block(url, host, method) || await resolvesToDisallowed(url, host, method)) {
1347
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
1600
+ if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
1601
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
1602
+ }
1603
+ };
1604
+ const guarded = async (input, init) => {
1605
+ let cur;
1606
+ try {
1607
+ cur = new Request(input, { ...init || {}, redirect: "manual" });
1608
+ } catch {
1609
+ return originalFetch(input, init);
1610
+ }
1611
+ const callerRedirect = init && init.redirect || input && input.redirect || "follow";
1612
+ let url = cur.url;
1613
+ let method = cur.method;
1614
+ await screenUrl(url, method);
1615
+ if (callerRedirect !== "follow") return originalFetch(input, init);
1616
+ const headers = new Headers(cur.headers);
1617
+ const signal = cur.signal;
1618
+ let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
1619
+ for (let hop = 0; ; hop++) {
1620
+ const resp = await originalFetch(
1621
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
1622
+ );
1623
+ const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
1624
+ if (!location) return resp;
1625
+ if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
1626
+ const next = new URL(location, url).href;
1627
+ if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
1628
+ method = "GET";
1629
+ body = void 0;
1630
+ headers.delete("content-type");
1631
+ headers.delete("content-length");
1632
+ }
1633
+ if (new URL(next).origin !== new URL(url).origin) {
1634
+ headers.delete("authorization");
1635
+ headers.delete("cookie");
1636
+ }
1637
+ await screenUrl(next, method);
1638
+ url = next;
1348
1639
  }
1349
- return originalFetch(input, init);
1350
1640
  };
1351
1641
  guarded.__patchstackGuarded = true;
1352
1642
  globalThis.fetch = guarded;
@@ -1362,30 +1652,6 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
1362
1652
  } catch {
1363
1653
  }
1364
1654
  }
1365
- const OriginalWS = globalThis.WebSocket;
1366
- if (typeof OriginalWS === "function" && !OriginalWS.__patchstackGuarded) {
1367
- const GuardedWS = new Proxy(OriginalWS, {
1368
- construct(target, args, newTarget) {
1369
- const url = String(args?.[0] ?? "");
1370
- let host = null;
1371
- try {
1372
- host = new URL(url).hostname;
1373
- } catch {
1374
- host = null;
1375
- }
1376
- if (block(url, host, "WEBSOCKET")) {
1377
- throw new Error(`Patchstack blocked an outbound WebSocket to a disallowed address: ${host ?? url}`);
1378
- }
1379
- return Reflect.construct(target, args, newTarget);
1380
- }
1381
- });
1382
- OriginalWS.__patchstackGuarded = true;
1383
- globalThis.WebSocket = GuardedWS;
1384
- restores.push(() => {
1385
- if (globalThis.WebSocket === GuardedWS) globalThis.WebSocket = OriginalWS;
1386
- delete OriginalWS.__patchstackGuarded;
1387
- });
1388
- }
1389
1655
  return () => {
1390
1656
  for (const restore of restores) {
1391
1657
  try {
@@ -1516,6 +1782,7 @@ var DEFAULT_RESPONSE_RULES = [
1516
1782
  phase: "response",
1517
1783
  category: "secret-exposure",
1518
1784
  action: "redact",
1785
+ prefilter: ["PRIVATE KEY"],
1519
1786
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
1520
1787
  },
1521
1788
  {
@@ -1524,6 +1791,7 @@ var DEFAULT_RESPONSE_RULES = [
1524
1791
  phase: "response",
1525
1792
  category: "secret-exposure",
1526
1793
  action: "redact",
1794
+ prefilter: ["AKIA", "ASIA"],
1527
1795
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
1528
1796
  },
1529
1797
  {
@@ -1532,14 +1800,29 @@ var DEFAULT_RESPONSE_RULES = [
1532
1800
  phase: "response",
1533
1801
  category: "secret-exposure",
1534
1802
  action: "redact",
1803
+ prefilter: ["AIza"],
1535
1804
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
1536
1805
  },
1806
+ {
1807
+ id: "resp-vendor-api-key",
1808
+ title: "Vendor API key / token in response body",
1809
+ phase: "response",
1810
+ category: "secret-exposure",
1811
+ action: "redact",
1812
+ // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
1813
+ // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
1814
+ // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
1815
+ // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
1816
+ prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
1817
+ 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])/" } }]
1818
+ },
1537
1819
  {
1538
1820
  id: "resp-jwt",
1539
1821
  title: "JWT in response body",
1540
1822
  phase: "response",
1541
1823
  category: "secret-exposure",
1542
1824
  action: "redact",
1825
+ prefilter: ["eyJ"],
1543
1826
  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/" } }]
1544
1827
  },
1545
1828
  {
@@ -1548,6 +1831,7 @@ var DEFAULT_RESPONSE_RULES = [
1548
1831
  phase: "response",
1549
1832
  category: "secret-exposure",
1550
1833
  action: "redact",
1834
+ prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
1551
1835
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
1552
1836
  },
1553
1837
  {
@@ -1556,6 +1840,8 @@ var DEFAULT_RESPONSE_RULES = [
1556
1840
  phase: "response",
1557
1841
  category: "info-exposure",
1558
1842
  action: "redact",
1843
+ // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
1844
+ // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
1559
1845
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
1560
1846
  },
1561
1847
  {
@@ -1564,6 +1850,7 @@ var DEFAULT_RESPONSE_RULES = [
1564
1850
  phase: "response",
1565
1851
  category: "info-exposure",
1566
1852
  action: "redact",
1853
+ prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
1567
1854
  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" } }]
1568
1855
  },
1569
1856
  {
@@ -1575,6 +1862,7 @@ var DEFAULT_RESPONSE_RULES = [
1575
1862
  // Multi-language exception/traceback signatures a normal API response never carries:
1576
1863
  // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
1577
1864
  // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
1865
+ prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
1578
1866
  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+ \\[)/' } }]
1579
1867
  }
1580
1868
  ];
@@ -1672,8 +1960,22 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
1672
1960
  }
1673
1961
 
1674
1962
  // src/protect/rules/store.js
1675
- import { readFileSync, writeFileSync, mkdirSync } from "fs";
1676
- import { join } from "path";
1963
+ var fsMod;
1964
+ async function loadFs() {
1965
+ if (fsMod !== void 0) return fsMod;
1966
+ try {
1967
+ const [fs, path] = await Promise.all([import("fs"), import("path")]);
1968
+ fsMod = {
1969
+ readFileSync: fs.readFileSync,
1970
+ writeFileSync: fs.writeFileSync,
1971
+ mkdirSync: fs.mkdirSync,
1972
+ join: path.join
1973
+ };
1974
+ } catch {
1975
+ fsMod = null;
1976
+ }
1977
+ return fsMod;
1978
+ }
1677
1979
  function makeStore(options = {}) {
1678
1980
  let mem = null;
1679
1981
  const durable = durableTier(options);
@@ -1715,21 +2017,22 @@ function durableTier(options) {
1715
2017
  write: async (env) => cacheWrite(dir, env)
1716
2018
  };
1717
2019
  }
1718
- function cachePath(dir) {
1719
- return join(dir, "patchstack-rules.json");
1720
- }
1721
- function cacheWrite(dir, env) {
2020
+ async function cacheWrite(dir, env) {
1722
2021
  if (!dir) return;
2022
+ const fs = await loadFs();
2023
+ if (!fs) return;
1723
2024
  try {
1724
- mkdirSync(dir, { recursive: true });
1725
- writeFileSync(cachePath(dir), JSON.stringify(env));
2025
+ fs.mkdirSync(dir, { recursive: true });
2026
+ fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
1726
2027
  } catch {
1727
2028
  }
1728
2029
  }
1729
- function cacheRead(dir) {
2030
+ async function cacheRead(dir) {
1730
2031
  if (!dir) return null;
2032
+ const fs = await loadFs();
2033
+ if (!fs) return null;
1731
2034
  try {
1732
- return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
2035
+ return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
1733
2036
  } catch {
1734
2037
  return null;
1735
2038
  }
@@ -1745,56 +2048,204 @@ function toEnvelope(value) {
1745
2048
  return null;
1746
2049
  }
1747
2050
 
2051
+ // src/protect/rules/validate.js
2052
+ var LIMITS = {
2053
+ maxRules: 5e3,
2054
+ maxWhitelists: 2e3,
2055
+ maxConditionsPerRule: 250,
2056
+ maxNestingDepth: 12,
2057
+ maxRegexLength: 1e3,
2058
+ maxValueLength: 8192
2059
+ };
2060
+ var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
2061
+ var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
2062
+ function validateBundle(bundle, opts = {}) {
2063
+ const rejected = [];
2064
+ const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2065
+ const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2066
+ const firewall = [];
2067
+ for (const rule of inFirewall) {
2068
+ if (firewall.length >= LIMITS.maxRules) {
2069
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
2070
+ continue;
2071
+ }
2072
+ const reason = ruleProblem(rule);
2073
+ if (reason) rejected.push({ id: idOf(rule), reason });
2074
+ else firewall.push(rule);
2075
+ }
2076
+ const whitelists = [];
2077
+ for (const wl of inWhitelists) {
2078
+ if (whitelists.length >= LIMITS.maxWhitelists) {
2079
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
2080
+ continue;
2081
+ }
2082
+ if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
2083
+ rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2084
+ continue;
2085
+ }
2086
+ const reason = conditionsProblem(wl?.rule_v2);
2087
+ if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2088
+ else whitelists.push(wl);
2089
+ }
2090
+ return {
2091
+ bundle: { ...bundle, firewall, whitelists },
2092
+ rejected
2093
+ };
2094
+ }
2095
+ function idOf(rule) {
2096
+ const id = rule?.id ?? rule?.rule_id;
2097
+ return id === void 0 || id === null ? "(unidentified)" : String(id);
2098
+ }
2099
+ function ruleProblem(rule) {
2100
+ if (!rule || typeof rule !== "object") return "not an object";
2101
+ if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2102
+ if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
2103
+ const capOverride = rule.max_bytes;
2104
+ if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2105
+ return conditionsProblem(rule.rule_v2);
2106
+ }
2107
+ function conditionsProblem(conditions, depth = 0) {
2108
+ if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2109
+ if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2110
+ if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
2111
+ if (conditions.length > LIMITS.maxConditionsPerRule) {
2112
+ return `more than ${LIMITS.maxConditionsPerRule} conditions`;
2113
+ }
2114
+ for (const c of conditions) {
2115
+ if (!c || typeof c !== "object") return "condition is not an object";
2116
+ if (Array.isArray(c.rules)) {
2117
+ const nested = conditionsProblem(c.rules, depth + 1);
2118
+ if (nested) return nested;
2119
+ continue;
2120
+ }
2121
+ const m = c.match;
2122
+ if (!m || typeof m !== "object") return "condition has no match object";
2123
+ if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
2124
+ if (m.type === "regex") {
2125
+ if (typeof m.value !== "string") return "regex match.value must be a string";
2126
+ if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
2127
+ } else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
2128
+ return `match.value longer than ${LIMITS.maxValueLength} chars`;
2129
+ }
2130
+ if (m.match) {
2131
+ const nested = conditionsProblem([{ match: m.match }], depth + 1);
2132
+ if (nested) return nested;
2133
+ }
2134
+ }
2135
+ return null;
2136
+ }
2137
+
1748
2138
  // src/protect/rules/source.js
1749
- async function resolveRules(options, store) {
2139
+ function liveUpdateRejections(res, options) {
2140
+ if (options.acceptPartialBundle) return [];
2141
+ const { rejected } = validateBundle(
2142
+ { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
2143
+ { allowGlobalWhitelists: options.allowGlobalWhitelists }
2144
+ );
2145
+ return rejected;
2146
+ }
2147
+ function reportRejections(rejected, options, label) {
2148
+ const report = options.onRuleRejected;
2149
+ for (const r of rejected) {
2150
+ if (typeof report === "function") {
2151
+ try {
2152
+ report({ ...r, accepted: false });
2153
+ } catch {
2154
+ }
2155
+ }
2156
+ }
2157
+ const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
2158
+ options.onError?.(new Error(
2159
+ `${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" : ""}`
2160
+ ));
2161
+ }
2162
+ async function resolveRules(options, store, ctx = {}) {
2163
+ const timeoutMs = ctx.timeoutMs;
1750
2164
  if (options.siteUuid) {
1751
2165
  const prior = await store.read();
1752
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
2166
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
1753
2167
  const res = await client.getRules();
1754
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
2168
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
1755
2169
  if (res.success && !res.notModified) {
1756
- const bundle = normalizeBundle(res);
2170
+ const rejected = liveUpdateRejections(res, options);
2171
+ if (rejected.length > 0) {
2172
+ reportRejections(rejected, options, "rule update rejected");
2173
+ if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2174
+ if (options.rules) return normalizeBundle(options.rules, options);
2175
+ return emptyBundle();
2176
+ }
2177
+ const bundle = normalizeBundle(res, options);
1757
2178
  await store.write({ bundle, etag: res.etag ?? null });
1758
2179
  return bundle;
1759
2180
  }
1760
2181
  if (prior?.bundle) {
1761
2182
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
1762
- return normalizeBundle(prior.bundle);
2183
+ return normalizeBundle(prior.bundle, options);
1763
2184
  }
1764
2185
  if (options.rules) {
1765
2186
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
1766
- return normalizeBundle(options.rules);
2187
+ return normalizeBundle(options.rules, options);
1767
2188
  }
1768
2189
  options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
1769
2190
  return emptyBundle();
1770
2191
  }
1771
2192
  if (options.token) {
1772
2193
  const prior = await store.read();
1773
- const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
2194
+ const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
1774
2195
  const res = await client.getRules();
1775
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
2196
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
1776
2197
  if (res.success && !res.notModified) {
1777
- const bundle = normalizeBundle(res);
2198
+ const rejected = liveUpdateRejections(res, options);
2199
+ if (rejected.length > 0) {
2200
+ reportRejections(rejected, options, "rule update rejected");
2201
+ if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2202
+ if (options.rules) return normalizeBundle(options.rules, options);
2203
+ return emptyBundle();
2204
+ }
2205
+ const bundle = normalizeBundle(res, options);
1778
2206
  await store.write({ bundle, etag: res.etag ?? null });
1779
2207
  return bundle;
1780
2208
  }
1781
2209
  if (prior?.bundle) {
1782
2210
  options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
1783
- return normalizeBundle(prior.bundle);
2211
+ return normalizeBundle(prior.bundle, options);
1784
2212
  }
1785
2213
  options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
1786
2214
  return emptyBundle();
1787
2215
  }
1788
2216
  if (options.rules) {
1789
- return normalizeBundle(options.rules);
2217
+ return normalizeBundle(options.rules, options);
1790
2218
  }
1791
2219
  return emptyBundle();
1792
2220
  }
1793
- function normalizeBundle(b) {
2221
+ function normalizeBundle(b, options = {}) {
1794
2222
  const enforcement = b?.enforcement ?? b?.mode;
2223
+ const { bundle: checked, rejected } = validateBundle(
2224
+ {
2225
+ firewall: Array.isArray(b.firewall) ? b.firewall : [],
2226
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
2227
+ },
2228
+ { allowGlobalWhitelists: options.allowGlobalWhitelists }
2229
+ );
2230
+ if (rejected.length > 0) {
2231
+ const report = options.onRuleRejected;
2232
+ if (typeof report === "function") {
2233
+ for (const r of rejected) {
2234
+ try {
2235
+ report(r);
2236
+ } catch {
2237
+ }
2238
+ }
2239
+ } else {
2240
+ const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
2241
+ console.warn(
2242
+ `[patchstack] ${rejected.length} delivered rule(s) rejected as invalid/oversized and are NOT enforced: ${sample}` + (rejected.length > 3 ? ", \u2026" : "")
2243
+ );
2244
+ }
2245
+ }
1795
2246
  return {
1796
- firewall: Array.isArray(b.firewall) ? b.firewall : [],
1797
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
2247
+ firewall: checked.firewall,
2248
+ whitelists: checked.whitelists,
1798
2249
  whitelist_keys: b.whitelist_keys ?? {},
1799
2250
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
1800
2251
  };
@@ -1874,7 +2325,11 @@ function parseApiKey(apiKey) {
1874
2325
  function resolveApiBase(pulseOrManifestUrl) {
1875
2326
  const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
1876
2327
  if (typeof fromEnv === "string" && fromEnv.length > 0) {
1877
- return fromEnv.replace(/\/$/, "");
2328
+ const candidate = fromEnv.replace(/\/$/, "");
2329
+ if (isSafeOrigin(candidate)) return candidate;
2330
+ console.warn(
2331
+ "[patchstack] ignoring PATCHSTACK_API_BASE: block-log reporting requires an https origin (or localhost). Falling back to the default API origin."
2332
+ );
1878
2333
  }
1879
2334
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
1880
2335
  try {
@@ -2016,10 +2471,6 @@ function telemetryEnabled() {
2016
2471
  return !/^(0|false|off|no)$/i.test(String(v));
2017
2472
  }
2018
2473
 
2019
- // src/protect/runtime.js
2020
- import { readFileSync as readFileSync2 } from "fs";
2021
- import { join as join2 } from "path";
2022
-
2023
2474
  // src/protect/supabase-guard.js
2024
2475
  var GUARD_PATH = "/_patchstack/guard";
2025
2476
  var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
@@ -2093,7 +2544,7 @@ function createServerFnGuard({ protection }) {
2093
2544
  async function createProtection(options = {}) {
2094
2545
  const onError = options.onError;
2095
2546
  const userOnDetect = options.onDetect ?? defaultOnDetect;
2096
- const apiKey = resolveApiKey(options);
2547
+ const apiKey = await resolveApiKey(options);
2097
2548
  const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
2098
2549
  apiKey,
2099
2550
  apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
@@ -2113,7 +2564,9 @@ async function createProtection(options = {}) {
2113
2564
  }
2114
2565
  };
2115
2566
  const store = makeStore(options);
2116
- const bundle = await resolveRules(options, store);
2567
+ const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
2568
+ const pulseAuth = await resolvePulseAuth(options);
2569
+ const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
2117
2570
  let mode = resolveMode(options, bundle);
2118
2571
  let requestRules;
2119
2572
  let responseRules;
@@ -2137,17 +2590,42 @@ async function createProtection(options = {}) {
2137
2590
  responseRuleSet = responseRules.map((rule) => ({
2138
2591
  rule,
2139
2592
  engine: new RuleEngine({ firewall: [rule], onError }),
2140
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null
2593
+ redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
2594
+ // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
2595
+ // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
2596
+ // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
2597
+ // fails such a rule CLOSED (block) instead of serving a no-op redaction.
2598
+ mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
2599
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
2600
+ // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
2601
+ // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
2602
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
2141
2603
  }));
2142
2604
  egressEngine = new RuleEngine({ firewall: egressRules, onError });
2143
2605
  };
2144
2606
  applyBundle(bundle);
2607
+ const skipCounts = /* @__PURE__ */ Object.create(null);
2608
+ const onSkip = typeof options.onSkip === "function" ? options.onSkip : null;
2609
+ const recordSkip = (phase, reason, detail) => {
2610
+ const key = `${phase}:${reason}`;
2611
+ skipCounts[key] = (skipCounts[key] ?? 0) + 1;
2612
+ if (onSkip) {
2613
+ try {
2614
+ onSkip({ phase, reason, detail, count: skipCounts[key] });
2615
+ } catch {
2616
+ }
2617
+ }
2618
+ };
2145
2619
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
2620
+ const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
2146
2621
  const decide = (phase, result, block, allow2, ctx = {}) => {
2147
2622
  if (!result || !result.blocked) return allow2();
2623
+ const effectiveMode = ruleMode(result.rule);
2148
2624
  onDetect({
2149
2625
  phase,
2150
- mode,
2626
+ // The mode this detection was actually handled under, not the site's: a consumer counting blocks
2627
+ // would otherwise over-report, and the whole point of a dry-run rule is that it did not block.
2628
+ mode: effectiveMode,
2151
2629
  category: result.rule?.category,
2152
2630
  rule: result.rule,
2153
2631
  message: result.message,
@@ -2156,30 +2634,40 @@ async function createProtection(options = {}) {
2156
2634
  ip: ctx.ip,
2157
2635
  userAgent: ctx.userAgent
2158
2636
  });
2159
- return mode === "block" ? block() : allow2();
2637
+ return effectiveMode === "block" ? block() : allow2();
2160
2638
  };
2161
- const isTextCT = (ct) => {
2162
- ct = (ct || "").toLowerCase();
2163
- return ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
2164
- };
2165
- const screenText = (text, meta) => {
2639
+ const screenText = (text, meta, reqCtx) => {
2166
2640
  let blockRule = null;
2167
2641
  const redactions = [];
2168
- for (const { rule, engine: re, redactors } of responseRuleSet) {
2642
+ const headerMutations = [];
2643
+ let lowerText = null;
2644
+ for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
2645
+ if (prefilter) {
2646
+ if (lowerText === null) lowerText = text.toLowerCase();
2647
+ if (!prefilter.some((p) => lowerText.includes(p))) continue;
2648
+ }
2169
2649
  let result;
2170
2650
  try {
2171
- result = re.evaluate({ _response: { ...meta, body: text } });
2651
+ result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
2172
2652
  } catch (err) {
2173
2653
  onError?.(err);
2174
2654
  continue;
2175
2655
  }
2176
2656
  if (!result.blocked) continue;
2177
- onDetect({ phase: "response", mode, category: rule.category, rule, message: result.message });
2178
- if (mode !== "block") continue;
2179
- if (redactors && redactors.length) redactions.push({ rule, redactors });
2657
+ const responseMode = ruleMode(rule);
2658
+ onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
2659
+ if (responseMode !== "block") continue;
2660
+ if (redactors && redactors.length) {
2661
+ const spanRedactors = redactors.filter((r) => !r.jsonPath);
2662
+ if (mutatedSpan && spanRedactors.length) {
2663
+ if (!blockRule) blockRule = rule;
2664
+ } else {
2665
+ redactions.push({ rule, redactors });
2666
+ }
2667
+ } else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
2180
2668
  else if (!blockRule) blockRule = rule;
2181
2669
  }
2182
- if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
2670
+ if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
2183
2671
  if (blockRule) return { verdict: "block" };
2184
2672
  let body = text;
2185
2673
  const headers = { ...meta.headers || {} };
@@ -2201,17 +2689,33 @@ async function createProtection(options = {}) {
2201
2689
  }
2202
2690
  }
2203
2691
  }
2692
+ for (const rule of headerMutations) applyHeaderMutation(headers, rule);
2204
2693
  return { verdict: "redact", body, headers };
2205
2694
  };
2206
- const screenResp = async (response) => {
2207
- const text = await readTextResponse(response, screenCap);
2208
- if (text == null) return response;
2209
- const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
2695
+ const reqContextFromFetch = (request) => {
2696
+ try {
2697
+ const u = new URL(request.url);
2698
+ const headers = headerObject(request.headers);
2699
+ if (!headers.host) headers.host = u.host;
2700
+ return { method: request.method, originalUrl: u.pathname + u.search, headers };
2701
+ } catch {
2702
+ return void 0;
2703
+ }
2704
+ };
2705
+ const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
2706
+ const screenResp = async (response, reqCtx) => {
2707
+ const read = await readTextResponse(response, screenCap);
2708
+ if (read.skip) {
2709
+ if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
2710
+ return response;
2711
+ }
2712
+ const text = read.text;
2713
+ const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
2210
2714
  if (r.verdict === "block") return leakResponse();
2211
2715
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
2212
2716
  return response;
2213
2717
  };
2214
- const wrapNodeResponse = (res) => {
2718
+ const wrapNodeResponse = (res, reqCtx) => {
2215
2719
  const origWrite = res.write.bind(res);
2216
2720
  const origEnd = res.end.bind(res);
2217
2721
  const chunks = [];
@@ -2227,6 +2731,7 @@ async function createProtection(options = {}) {
2227
2731
  chunks.length = 0;
2228
2732
  origWrite(buf);
2229
2733
  overflow = true;
2734
+ recordSkip("response", "body-cap", { bytes: size });
2230
2735
  return;
2231
2736
  }
2232
2737
  chunks.push(buf);
@@ -2253,16 +2758,19 @@ async function createProtection(options = {}) {
2253
2758
  }
2254
2759
  collect(chunk, enc);
2255
2760
  if (overflow) return origEnd(cb);
2256
- const text = Buffer.concat(chunks).toString("utf8");
2761
+ const buffer = Buffer.concat(chunks);
2257
2762
  let ct = res.getHeader ? res.getHeader("content-type") : void 0;
2258
2763
  if (Array.isArray(ct)) ct = ct[0];
2259
- if (!isTextCT(ct)) {
2764
+ const kind = screenableContentType(ct);
2765
+ if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
2766
+ recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
2260
2767
  for (const c of chunks) origWrite(c);
2261
2768
  return origEnd(cb);
2262
2769
  }
2770
+ const text = buffer.toString("utf8");
2263
2771
  let r;
2264
2772
  try {
2265
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
2773
+ r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
2266
2774
  } catch (err) {
2267
2775
  onError?.(err);
2268
2776
  for (const c of chunks) origWrite(c);
@@ -2285,7 +2793,12 @@ async function createProtection(options = {}) {
2285
2793
  const current = res.getHeaders ? res.getHeaders() : {};
2286
2794
  for (const [name, value] of Object.entries(r.headers)) {
2287
2795
  if (name.toLowerCase() === "content-length") continue;
2288
- if (Array.isArray(value)) {
2796
+ if (value === null || value === void 0) {
2797
+ try {
2798
+ res.removeHeader && res.removeHeader(name);
2799
+ } catch {
2800
+ }
2801
+ } else if (Array.isArray(value)) {
2289
2802
  try {
2290
2803
  res.setHeader(name, value);
2291
2804
  } catch {
@@ -2315,8 +2828,9 @@ async function createProtection(options = {}) {
2315
2828
  return false;
2316
2829
  }
2317
2830
  if (!result.blocked) return false;
2318
- onDetect({ phase: "egress", mode, category: result.rule?.category, rule: result.rule, message: result.message });
2319
- return mode === "block";
2831
+ const egressMode = ruleMode(result.rule);
2832
+ onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
2833
+ return egressMode === "block";
2320
2834
  };
2321
2835
  const protection = {
2322
2836
  get mode() {
@@ -2325,9 +2839,18 @@ async function createProtection(options = {}) {
2325
2839
  get rules() {
2326
2840
  return { request: requestRules, response: responseRules, egress: egressRules };
2327
2841
  },
2842
+ /**
2843
+ * Enforcement coverage: how often the guard FAILED OPEN rather than inspecting, keyed
2844
+ * `<phase>:<reason>` (e.g. `response:body-cap`, `request:body-cap`, `response:live-stream`,
2845
+ * `egress:resolver-failed`). "Always-on" is not "always inspected" — surface this (or pass
2846
+ * `onSkip`) so an unscreened path is visible and alertable rather than silent.
2847
+ */
2848
+ coverage() {
2849
+ return { skipped: { ...skipCounts } };
2850
+ },
2328
2851
  // Screen a fetch Response through the response-phase rules (redact/block). Used by
2329
2852
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
2330
- screenResponse: (response) => screenResp(response),
2853
+ screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
2331
2854
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
2332
2855
  fetchGuard() {
2333
2856
  return async (request) => {
@@ -2348,7 +2871,7 @@ async function createProtection(options = {}) {
2348
2871
  const blocked = await guard(request);
2349
2872
  if (blocked) return blocked;
2350
2873
  const response = await handler(request, ...rest);
2351
- return screenResp(response);
2874
+ return screenResp(response, reqContextFromFetch(request));
2352
2875
  };
2353
2876
  },
2354
2877
  // Express middleware (request phase; expects express-parsed req.query/req.body).
@@ -2360,7 +2883,7 @@ async function createProtection(options = {}) {
2360
2883
  result = engine.evaluate(req);
2361
2884
  } catch (err) {
2362
2885
  onError?.(err);
2363
- if (exprOptions.screenResponses) wrapNodeResponse(res);
2886
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
2364
2887
  return next();
2365
2888
  }
2366
2889
  decide(
@@ -2374,7 +2897,7 @@ async function createProtection(options = {}) {
2374
2897
  }
2375
2898
  },
2376
2899
  () => {
2377
- if (exprOptions.screenResponses) wrapNodeResponse(res);
2900
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
2378
2901
  next();
2379
2902
  },
2380
2903
  nodeRequestMeta(req)
@@ -2402,6 +2925,7 @@ async function createProtection(options = {}) {
2402
2925
  next();
2403
2926
  });
2404
2927
  req.on("end", () => {
2928
+ if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
2405
2929
  const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
2406
2930
  let shaped;
2407
2931
  let result;
@@ -2427,7 +2951,7 @@ async function createProtection(options = {}) {
2427
2951
  },
2428
2952
  () => {
2429
2953
  if (req.body === void 0) req.body = shaped.body;
2430
- if (nodeOptions.screenResponses) wrapNodeResponse(res);
2954
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
2431
2955
  next();
2432
2956
  },
2433
2957
  nodeRequestMeta(req)
@@ -2440,6 +2964,9 @@ async function createProtection(options = {}) {
2440
2964
  protection.uninstallEgress = await installEgressGuard({
2441
2965
  shouldBlock: egressShouldBlock,
2442
2966
  onBlock: options.onEgressBlock,
2967
+ // Route egress coverage gaps (a DNS resolver failure / no resolver on this runtime) into the
2968
+ // same skip accounting as the request/response phases.
2969
+ onSkip: ({ reason, detail }) => recordSkip("egress", reason, detail),
2443
2970
  dnsScreen: options.screenDns !== false,
2444
2971
  allowHosts: options.allowHosts
2445
2972
  });
@@ -2451,7 +2978,7 @@ async function createProtection(options = {}) {
2451
2978
  let reporter = null;
2452
2979
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
2453
2980
  try {
2454
- ({ reportManifest: reporter } = await import("./refresh-manifest-ZIFRZF25.js"));
2981
+ ({ reportManifest: reporter } = await import("./refresh-manifest-MUWVIPFR.js"));
2455
2982
  } catch (err) {
2456
2983
  onError?.(err);
2457
2984
  }
@@ -2464,7 +2991,7 @@ async function createProtection(options = {}) {
2464
2991
  onError?.(err);
2465
2992
  }
2466
2993
  }
2467
- const next = await resolveRules(options, store);
2994
+ const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
2468
2995
  mode = resolveMode(options, next);
2469
2996
  applyBundle(next);
2470
2997
  };
@@ -2491,7 +3018,7 @@ function resolveMode(options, bundle) {
2491
3018
  if (options?.mode === "dry-run") return "dry-run";
2492
3019
  return "dry-run";
2493
3020
  }
2494
- function resolveApiKey(options) {
3021
+ async function resolveApiKey(options) {
2495
3022
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
2496
3023
  if (typeof process !== "undefined") {
2497
3024
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
@@ -2499,29 +3026,69 @@ function resolveApiKey(options) {
2499
3026
  }
2500
3027
  try {
2501
3028
  if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3029
+ const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
2502
3030
  const cwd = options?.cwd ?? process.cwd();
2503
- const raw = readFileSync2(join2(cwd, ".patchstackrc.json"), "utf8");
3031
+ const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
2504
3032
  const key = JSON.parse(raw)?.apiKey;
2505
3033
  if (typeof key === "string" && key.length > 0) return key;
2506
3034
  } catch {
2507
3035
  }
2508
3036
  return void 0;
2509
3037
  }
3038
+ async function resolvePulseAuth(options) {
3039
+ if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
3040
+ if (typeof process !== "undefined") {
3041
+ const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
3042
+ if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3043
+ }
3044
+ try {
3045
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
3046
+ const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
3047
+ const cwd = options?.cwd ?? process.cwd();
3048
+ const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3049
+ const key = JSON.parse(raw)?.pulseAuth;
3050
+ if (typeof key === "string" && key.length > 0) return key;
3051
+ } catch {
3052
+ }
3053
+ return resolveApiKey(options);
3054
+ }
2510
3055
  function byPhase(rules, phase) {
2511
3056
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
2512
3057
  }
3058
+ function baseContentType(ct) {
3059
+ return String(ct || "").toLowerCase().split(";")[0].trim();
3060
+ }
3061
+ function screenableContentType(ct) {
3062
+ const base = baseContentType(ct);
3063
+ if (base === "text/event-stream") return "skip";
3064
+ if (base === "") return "text";
3065
+ if (/(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(base)) return "text";
3066
+ if (base === "application/octet-stream") return "sniff";
3067
+ return "skip";
3068
+ }
3069
+ function looksBinary(bytes) {
3070
+ const n = Math.min(bytes.length, 512);
3071
+ let ctrl = 0;
3072
+ for (let i = 0; i < n; i++) {
3073
+ const b = bytes[i];
3074
+ if (b === 0) return true;
3075
+ if (b < 9 || b > 13 && b < 32) ctrl++;
3076
+ }
3077
+ return n > 0 && ctrl / n > 0.1;
3078
+ }
2513
3079
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
2514
- if (!response || typeof response.clone !== "function") return null;
2515
- const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
2516
- const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
2517
- if (!isText) return null;
3080
+ if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
3081
+ const ct = response.headers?.get?.("content-type") || "";
3082
+ const kind = screenableContentType(ct);
3083
+ if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
3084
+ const sniff = kind === "sniff";
2518
3085
  const len = Number(response.headers?.get?.("content-length") || 0);
2519
- if (len && len > cap) return null;
3086
+ if (len && len > cap) return { skip: "body-cap" };
2520
3087
  let clone;
2521
3088
  try {
2522
3089
  clone = response.clone();
2523
3090
  } catch {
2524
- return null;
3091
+ return { skip: "clone-failed" };
2525
3092
  }
2526
3093
  const body = clone.body;
2527
3094
  if (body && typeof body.getReader === "function") {
@@ -2529,11 +3096,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
2529
3096
  const chunks = [];
2530
3097
  let size = 0;
2531
3098
  let over = false;
3099
+ let sniffed = !sniff;
2532
3100
  try {
2533
3101
  for (; ; ) {
2534
3102
  const { done, value } = await reader.read();
2535
3103
  if (done) break;
2536
3104
  if (!value) continue;
3105
+ if (!sniffed) {
3106
+ sniffed = true;
3107
+ if (looksBinary(value)) return { skip: "binary-body" };
3108
+ }
2537
3109
  size += value.byteLength;
2538
3110
  if (over) continue;
2539
3111
  if (size > cap) {
@@ -2543,20 +3115,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
2543
3115
  chunks.push(value);
2544
3116
  }
2545
3117
  } catch {
2546
- return null;
3118
+ return { skip: "read-failed" };
2547
3119
  }
2548
- if (over) return null;
3120
+ if (over) return { skip: "body-cap" };
2549
3121
  try {
2550
- return new TextDecoder().decode(concatBytes(chunks, size));
3122
+ return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
2551
3123
  } catch {
2552
- return null;
3124
+ return { skip: "decode-failed" };
2553
3125
  }
2554
3126
  }
2555
3127
  try {
2556
3128
  const text = await clone.text();
2557
- return text.length > cap ? null : text;
3129
+ if (text.length > cap) return { skip: "body-cap" };
3130
+ if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
3131
+ return { text };
2558
3132
  } catch {
2559
- return null;
3133
+ return { skip: "read-failed" };
2560
3134
  }
2561
3135
  }
2562
3136
  function concatBytes(chunks, total) {
@@ -2577,6 +3151,19 @@ function headerObject(headers) {
2577
3151
  if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
2578
3152
  return out;
2579
3153
  }
3154
+ function hasSpanMutations(rule) {
3155
+ let found = false;
3156
+ const walk = (conds) => {
3157
+ for (const c of conds ?? []) {
3158
+ if (found) return;
3159
+ if (Array.isArray(c.rules)) walk(c.rules);
3160
+ const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
3161
+ if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
3162
+ }
3163
+ };
3164
+ walk(rule.rule_v2);
3165
+ return found;
3166
+ }
2580
3167
  function extractRedactors(rule) {
2581
3168
  const out = [];
2582
3169
  const walk = (conds) => {
@@ -2613,7 +3200,10 @@ function applyRedactors(body, redactors, mask, transform) {
2613
3200
  let out = body;
2614
3201
  for (const r of redactors) {
2615
3202
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
2616
- else if (r.literal) out = out.split(r.literal).join(transform ? transform(r.literal) : mask);
3203
+ else if (r.literal) {
3204
+ const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
3205
+ out = out.replace(re, (m) => transform ? transform(m) : mask);
3206
+ }
2617
3207
  }
2618
3208
  return out;
2619
3209
  }
@@ -2716,12 +3306,52 @@ function restoreBigInts(text) {
2716
3306
  if (!text.includes(BIGINT_OPEN)) return text;
2717
3307
  return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
2718
3308
  }
3309
+ function isHeaderMutation(action) {
3310
+ return action === "set-header" || action === "remove-header" || action === "harden-cookie";
3311
+ }
3312
+ function applyHeaderMutation(headers, rule) {
3313
+ if (rule.action === "remove-header") {
3314
+ for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
3315
+ return;
3316
+ }
3317
+ if (rule.action === "set-header") {
3318
+ const ensure = rule.ensure === true;
3319
+ for (const [name, value] of Object.entries(rule.set_headers ?? {})) {
3320
+ const key = String(name).toLowerCase();
3321
+ const present = headers[key] != null && headers[key] !== "";
3322
+ if (ensure && present) continue;
3323
+ headers[key] = String(value);
3324
+ }
3325
+ return;
3326
+ }
3327
+ if (rule.action === "harden-cookie") {
3328
+ const cookie = headers["set-cookie"];
3329
+ const flags = rule.cookie_flags ?? {};
3330
+ if (Array.isArray(cookie)) {
3331
+ headers["set-cookie"] = cookie.map((c) => typeof c === "string" ? hardenCookie(c, flags) : c);
3332
+ } else if (typeof cookie === "string") {
3333
+ headers["set-cookie"] = hardenCookie(cookie, flags);
3334
+ }
3335
+ }
3336
+ }
3337
+ function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
3338
+ let out = String(cookie);
3339
+ if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
3340
+ if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
3341
+ if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
3342
+ return out;
3343
+ }
2719
3344
  function rebuildResponse(response, body, redactedHeaders) {
2720
3345
  const headers = new Headers(response.headers);
2721
3346
  headers.delete("content-length");
2722
3347
  if (redactedHeaders) {
2723
3348
  for (const [name, value] of Object.entries(redactedHeaders)) {
2724
- if (typeof value === "string") {
3349
+ if (value === null || value === void 0) {
3350
+ try {
3351
+ headers.delete(name);
3352
+ } catch {
3353
+ }
3354
+ } else if (typeof value === "string") {
2725
3355
  if (headers.get(name) !== value) {
2726
3356
  try {
2727
3357
  headers.set(name, value);
@@ -2737,7 +3367,8 @@ function rebuildResponse(response, body, redactedHeaders) {
2737
3367
  }
2738
3368
  }
2739
3369
  }
2740
- return new Response(body, { status: response.status, statusText: response.statusText, headers });
3370
+ const nullBody = response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304;
3371
+ return new Response(nullBody ? null : body, { status: response.status, statusText: response.statusText, headers });
2741
3372
  }
2742
3373
  function leakResponse() {
2743
3374
  return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {