@patchstack/connect 0.3.30 → 0.3.32

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.
@@ -5,7 +5,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __getProtoOf = Object.getPrototypeOf;
6
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
7
7
  var __commonJS = (cb, mod) => function __require() {
8
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
8
+ try {
9
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
+ } catch (e) {
11
+ throw mod = 0, e;
12
+ }
9
13
  };
10
14
  var __copyProps = (to, from, except, desc) => {
11
15
  if (from && typeof from === "object" || typeof from === "function") {
@@ -31,6 +35,220 @@ var require_ps_edge_stub = __commonJS({
31
35
  }
32
36
  });
33
37
 
38
+ // src/protect/client-ip.js
39
+ var ZONE = /^[A-Za-z0-9._-]{1,64}$/;
40
+ var POLICY_FIELDS = Object.freeze(["peers", "hops", "header", "isTrusted"]);
41
+ var IP_SOURCES = Object.freeze(["runtime", "trusted-proxy", "unavailable"]);
42
+ var DEFAULT_FORWARDED_HEADER = "x-forwarded-for";
43
+ function parseIpv4(value) {
44
+ const parts = value.split(".");
45
+ if (parts.length !== 4) return null;
46
+ const octets = [];
47
+ for (const part of parts) {
48
+ if (!/^\d{1,3}$/.test(part)) return null;
49
+ if (part.length > 1 && part.startsWith("0")) return null;
50
+ const n = Number(part);
51
+ if (n > 255) return null;
52
+ octets.push(n);
53
+ }
54
+ return octets;
55
+ }
56
+ function parseIpv6(value) {
57
+ if (value === "" || value.includes(":::")) return null;
58
+ let head = value;
59
+ let embedded = null;
60
+ const lastColon = head.lastIndexOf(":");
61
+ const tail = lastColon === -1 ? "" : head.slice(lastColon + 1);
62
+ if (tail.includes(".")) {
63
+ embedded = parseIpv4(tail);
64
+ if (embedded === null) return null;
65
+ head = head.slice(0, lastColon + 1) + "0:0";
66
+ }
67
+ const halves = head.split("::");
68
+ if (halves.length > 2) return null;
69
+ const readGroups = (text) => {
70
+ if (text === "") return [];
71
+ const groups2 = [];
72
+ for (const g of text.split(":")) {
73
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
74
+ groups2.push(Number.parseInt(g, 16));
75
+ }
76
+ return groups2;
77
+ };
78
+ const left = readGroups(halves[0]);
79
+ const right = halves.length === 2 ? readGroups(halves[1]) : [];
80
+ if (left === null || right === null) return null;
81
+ let groups;
82
+ if (halves.length === 2) {
83
+ const fill = 8 - (left.length + right.length);
84
+ if (fill < 1) return null;
85
+ groups = [...left, ...new Array(fill).fill(0), ...right];
86
+ } else {
87
+ groups = left;
88
+ }
89
+ if (groups.length !== 8) return null;
90
+ if (embedded !== null) {
91
+ groups[6] = embedded[0] << 8 | embedded[1];
92
+ groups[7] = embedded[2] << 8 | embedded[3];
93
+ }
94
+ return groups;
95
+ }
96
+ function toNumeric(value) {
97
+ const text = String(value).trim();
98
+ if (text === "") return null;
99
+ const zoneParts = text.split("%");
100
+ if (zoneParts.length > 2) return null;
101
+ const addr = zoneParts[0];
102
+ const zone = zoneParts.length === 2 ? zoneParts[1] : null;
103
+ if (zone !== null && (!ZONE.test(zone) || !addr.includes(":"))) return null;
104
+ const v4 = parseIpv4(addr);
105
+ if (v4 !== null) {
106
+ return {
107
+ bits: 32,
108
+ value: v4.reduce((acc, octet) => acc << 8n | BigInt(octet), 0n),
109
+ canonical: v4.join(".")
110
+ };
111
+ }
112
+ if (!addr.includes(":")) return null;
113
+ const v6 = parseIpv6(addr);
114
+ if (v6 === null) return null;
115
+ const mapped = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 65535;
116
+ if (mapped) {
117
+ if (zone !== null) return null;
118
+ const octets = [v6[6] >> 8, v6[6] & 255, v6[7] >> 8, v6[7] & 255];
119
+ return { bits: 32, value: BigInt(v6[6]) << 16n | BigInt(v6[7]), canonical: octets.join(".") };
120
+ }
121
+ return {
122
+ bits: 128,
123
+ value: v6.reduce((acc, group) => acc << 16n | BigInt(group), 0n),
124
+ // The zone survives: without it a link-local address does not identify an interface.
125
+ canonical: zone === null ? canonicalIpv6(v6) : `${canonicalIpv6(v6)}%${zone}`
126
+ };
127
+ }
128
+ function canonicalIpv6(groups) {
129
+ let bestStart = -1;
130
+ let bestLength = 0;
131
+ let runStart = -1;
132
+ for (let i = 0; i <= groups.length; i++) {
133
+ if (i < groups.length && groups[i] === 0) {
134
+ if (runStart === -1) runStart = i;
135
+ } else if (runStart !== -1) {
136
+ const length = i - runStart;
137
+ if (length > bestLength) {
138
+ bestLength = length;
139
+ bestStart = runStart;
140
+ }
141
+ runStart = -1;
142
+ }
143
+ }
144
+ const hex = groups.map((g) => g.toString(16));
145
+ if (bestLength < 2) return hex.join(":");
146
+ const head = hex.slice(0, bestStart).join(":");
147
+ const tail = hex.slice(bestStart + bestLength).join(":");
148
+ return `${head}::${tail}`;
149
+ }
150
+ function canonicalIp(value) {
151
+ return toNumeric(value)?.canonical ?? null;
152
+ }
153
+ function parseCidr(entry) {
154
+ if (typeof entry !== "string") return null;
155
+ if (entry.includes("%")) return null;
156
+ const parts = entry.trim().split("/");
157
+ if (parts.length > 2) return null;
158
+ const numeric = toNumeric(parts[0]);
159
+ if (numeric === null) return null;
160
+ let length = numeric.bits;
161
+ if (parts.length === 2) {
162
+ if (!/^\d{1,3}$/.test(parts[1])) return null;
163
+ length = Number(parts[1]);
164
+ if (length > numeric.bits) return null;
165
+ }
166
+ const shift = BigInt(numeric.bits - length);
167
+ return { bits: numeric.bits, network: numeric.value >> shift, shift };
168
+ }
169
+ function readTrustPolicy(trustedProxy) {
170
+ if (trustedProxy === null || typeof trustedProxy !== "object" || Array.isArray(trustedProxy)) return null;
171
+ const has = (field) => Object.hasOwn(trustedProxy, field);
172
+ for (const key of Object.keys(trustedProxy)) {
173
+ if (!POLICY_FIELDS.includes(key)) return null;
174
+ }
175
+ if (has("header") && (typeof trustedProxy.header !== "string" || trustedProxy.header.trim() === "")) {
176
+ return null;
177
+ }
178
+ if (has("hops") && (!Number.isInteger(trustedProxy.hops) || trustedProxy.hops < 1)) return null;
179
+ if (has("isTrusted") && typeof trustedProxy.isTrusted !== "function") return null;
180
+ const header = has("header") ? trustedProxy.header.trim().toLowerCase() : DEFAULT_FORWARDED_HEADER;
181
+ const cidrs = [];
182
+ if (has("peers")) {
183
+ if (!Array.isArray(trustedProxy.peers) || trustedProxy.peers.length === 0) return null;
184
+ for (const entry of trustedProxy.peers) {
185
+ const parsed = parseCidr(entry);
186
+ if (parsed === null) return null;
187
+ cidrs.push(parsed);
188
+ }
189
+ }
190
+ const hops = has("hops") ? trustedProxy.hops : null;
191
+ const predicate = has("isTrusted") ? trustedProxy.isTrusted : null;
192
+ if (cidrs.length === 0 && hops === null && predicate === null) return null;
193
+ return { header, cidrs, hops, predicate };
194
+ }
195
+ function isTrustedPeer(policy, value) {
196
+ const numeric = toNumeric(value);
197
+ if (numeric === null) return false;
198
+ for (const cidr of policy.cidrs) {
199
+ if (cidr.bits === numeric.bits && numeric.value >> cidr.shift === cidr.network) return true;
200
+ }
201
+ if (policy.predicate !== null) {
202
+ try {
203
+ if (policy.predicate(String(value)) === true) return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+ return false;
209
+ }
210
+ var UNSPECIFIED = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
211
+ function identifyingIp(value) {
212
+ const canonical = canonicalIp(value);
213
+ return canonical === null || UNSPECIFIED.has(canonical) ? null : canonical;
214
+ }
215
+ function chainFrom(headers, header) {
216
+ if (headers === null || typeof headers !== "object" || !Object.hasOwn(headers, header)) return [];
217
+ const raw = headers[header];
218
+ const value = Array.isArray(raw) ? raw.join(",") : raw;
219
+ if (typeof value !== "string" || value.trim() === "") return [];
220
+ return value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
221
+ }
222
+ function resolveClientIp(input) {
223
+ const headers = input.headers ?? {};
224
+ const peer = identifyingIp(input.peer);
225
+ const policy = readTrustPolicy(input.trustedProxy);
226
+ if (peer === null) return { ip: null, source: "unavailable" };
227
+ if (policy === null) return { ip: peer, source: "runtime" };
228
+ const gatedByAddress = policy.cidrs.length > 0 || policy.predicate !== null;
229
+ const peerTrusted = gatedByAddress ? isTrustedPeer(policy, peer) : policy.hops !== null;
230
+ if (!peerTrusted) return { ip: peer, source: "runtime" };
231
+ const chain = chainFrom(headers, policy.header);
232
+ if (chain.length === 0) return { ip: peer, source: "runtime" };
233
+ if (policy.hops !== null) {
234
+ const index = chain.length - policy.hops;
235
+ const candidate = index >= 0 ? chain[index] : void 0;
236
+ const canonical = identifyingIp(candidate);
237
+ return canonical === null ? { ip: peer, source: "runtime" } : { ip: canonical, source: "trusted-proxy" };
238
+ }
239
+ for (let i = chain.length - 1; i >= 0; i--) {
240
+ const canonical = identifyingIp(chain[i]);
241
+ if (canonical === null) return { ip: peer, source: "runtime" };
242
+ if (!isTrustedPeer(policy, canonical)) return { ip: canonical, source: "trusted-proxy" };
243
+ }
244
+ return { ip: peer, source: "runtime" };
245
+ }
246
+ function clientIpFields(resolved) {
247
+ const established = resolved?.source === "runtime" || resolved?.source === "trusted-proxy";
248
+ const address = identifyingIp(resolved?.ip);
249
+ return established && address !== null ? { client_ip: address, client_ip_source: resolved.source } : { client_ip_source: "unavailable" };
250
+ }
251
+
34
252
  // src/protect/safe-origin.js
35
253
  function isSafeOrigin(value) {
36
254
  try {
@@ -387,7 +605,7 @@ var RequestResolver = class {
387
605
  return req.headers?.host ? [req.headers.host] : [];
388
606
  case "REMOTE_ADDR":
389
607
  case "ip":
390
- return [req.ip ?? req.socket?.remoteAddress ?? ""];
608
+ return [typeof req.ip === "string" ? req.ip : ""];
391
609
  case "CONTENT_TYPE":
392
610
  return req.headers?.["content-type"] ? [req.headers["content-type"]] : [];
393
611
  case "CONTENT_LENGTH":
@@ -728,14 +946,29 @@ function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isR
728
946
  });
729
947
  return "{" + parts.join(",") + "}";
730
948
  }
949
+ var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
950
+ function requestField(req, key) {
951
+ if (req === null || typeof req !== "object") return void 0;
952
+ if (Object.hasOwn(req, key)) return req[key];
953
+ if (!INHERITED_ACCESSORS.has(key)) return void 0;
954
+ let holder = Object.getPrototypeOf(req);
955
+ while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
956
+ if (holder === null || holder === Object.prototype) return void 0;
957
+ const descriptor = Object.getOwnPropertyDescriptor(holder, key);
958
+ if (typeof descriptor?.get !== "function") return void 0;
959
+ return req[key];
960
+ }
731
961
  function normalizeRequest(req, options = {}) {
732
- const rawBody = typeof req._rawBody === "string" ? req._rawBody : serializeForRawDetection(req.body ?? null);
962
+ const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
963
+ const body = requestField(req, "body");
964
+ const url = requestField(req, "url");
965
+ const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
733
966
  return {
734
- query: normalizeObject(req.query || {}, options),
735
- body: normalizeObject(req.body || {}, options),
736
- headers: normalizeObject(req.headers || {}, options),
737
- url: normalize(req.url || "", options),
738
- originalUrl: normalize(req.originalUrl || req.url || "", options),
967
+ query: normalizeObject(requestField(req, "query") || {}, options),
968
+ body: normalizeObject(body || {}, options),
969
+ headers: normalizeObject(requestField(req, "headers") || {}, options),
970
+ url: normalize(url || "", options),
971
+ originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
739
972
  _rawBody: rawBody
740
973
  };
741
974
  }
@@ -990,6 +1223,30 @@ function expandIPv6(host) {
990
1223
  }
991
1224
  return out;
992
1225
  }
1226
+ var JWT_CANDIDATE = /(?<![A-Za-z0-9_-])(?<![A-Za-z0-9_-]\.)eyJ[A-Za-z0-9_-]{2,2048}\.[A-Za-z0-9_-]{2,8192}\.[A-Za-z0-9_-]{0,2048}(?![A-Za-z0-9_-])(?!\.[A-Za-z0-9_-])/g;
1227
+ function base64UrlToBase64(segment) {
1228
+ const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
1229
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
1230
+ }
1231
+ function jwtClaimSpans(text, claim, value) {
1232
+ const found = /* @__PURE__ */ new Set();
1233
+ if (typeof text !== "string" || typeof claim !== "string" || claim === "") return [];
1234
+ const wanted = String(value);
1235
+ for (const [token] of text.matchAll(JWT_CANDIDATE)) {
1236
+ const parts = token.split(".");
1237
+ let payload;
1238
+ try {
1239
+ payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
1240
+ } catch {
1241
+ continue;
1242
+ }
1243
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
1244
+ if (!Object.prototype.hasOwnProperty.call(payload, claim)) continue;
1245
+ const actual = payload[claim];
1246
+ if (typeof actual === "string" && actual === wanted) found.add(token);
1247
+ }
1248
+ return [...found];
1249
+ }
993
1250
  var warnedScopes = /* @__PURE__ */ new Set();
994
1251
  function warnUnrecognisedScope(when) {
995
1252
  const key = Object.keys(when).sort().join(",");
@@ -1018,16 +1275,30 @@ function ruleAppliesTo(when, resolver) {
1018
1275
  return true;
1019
1276
  }
1020
1277
  }
1278
+ function foldPathForScope(value) {
1279
+ const out = String(value).toLowerCase();
1280
+ return out.endsWith("/") ? out.slice(0, -1) : out;
1281
+ }
1282
+ var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
1283
+ var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
1284
+ function isRegexForm(pattern) {
1285
+ const parts = REGEX_FORM.exec(pattern);
1286
+ return parts !== null && REGEX_SYNTAX.test(parts[1]);
1287
+ }
1021
1288
  function pathMatches(pattern, path) {
1022
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
1289
+ if (isRegexForm(pattern)) {
1023
1290
  const re = safeRegExp(pattern);
1024
1291
  return re ? re.test(path) : false;
1025
1292
  }
1026
- if (pattern.includes("*")) {
1027
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
1028
- return re ? re.test(path) : false;
1293
+ const target = foldPathForScope(path);
1294
+ const folded = foldPathForScope(pattern);
1295
+ if (folded.includes("*")) {
1296
+ const trailing = folded.endsWith("/*");
1297
+ const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
1298
+ const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
1299
+ return re ? re.test(target) : false;
1029
1300
  }
1030
- return path === pattern;
1301
+ return target === folded;
1031
1302
  }
1032
1303
  function normalizeDefaultPort(host) {
1033
1304
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -1180,6 +1451,8 @@ function matchValue(type, value, matchVal, matchObj) {
1180
1451
  const stripped = strValue.replace(/[ _\-,]/g, "");
1181
1452
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
1182
1453
  }
1454
+ case "jwt_claim_equals":
1455
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
1183
1456
  case "array_key_value":
1184
1457
  return arrayKeyValue(value, matchObj);
1185
1458
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -1257,7 +1530,12 @@ var RuleEngine = class {
1257
1530
  return {
1258
1531
  blocked: true,
1259
1532
  rule,
1260
- message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`
1533
+ message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`,
1534
+ // The resolver this match was decided by, for whatever needs to report on it. A consumer
1535
+ // that re-read the request instead would be reading it a second time: a getter, a stream or
1536
+ // anything else that answers once can give a different value, and evidence that disagrees
1537
+ // with the match it belongs to is worse than none.
1538
+ resolver
1261
1539
  };
1262
1540
  }
1263
1541
  } catch (err) {
@@ -1352,1387 +1630,1706 @@ var RuleEngine = class {
1352
1630
  }
1353
1631
  };
1354
1632
 
1355
- // src/pulse-token.ts
1356
- var TOKEN_SKEW_MS = 6e4;
1357
- function buildTokenUrl(manifestEndpoint) {
1358
- const url = new URL(manifestEndpoint);
1359
- const path = url.pathname.replace(/\/$/, "");
1360
- url.pathname = path.endsWith("/manifest") ? `${path.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
1361
- url.search = "";
1362
- url.hash = "";
1363
- return url.toString();
1364
- }
1365
- function parsePulseAuth(credential) {
1366
- const index = credential.lastIndexOf("-");
1367
- if (index <= 0 || index === credential.length - 1) return null;
1368
- const clientId = credential.slice(index + 1);
1369
- if (!/^\d+$/.test(clientId)) return null;
1370
- return { clientId, clientSecret: credential.slice(0, index) };
1371
- }
1372
- var cached = null;
1373
- var inflight = null;
1374
- async function getPulseToken(config, fetchImpl = fetch) {
1375
- if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
1376
- if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
1377
- return cached.token;
1378
- }
1379
- if (inflight !== null) return inflight;
1380
- const credentials = parsePulseAuth(config.pulseAuth);
1381
- if (credentials === null) return null;
1382
- inflight = (async () => {
1383
- try {
1384
- const response = await fetchImpl(buildTokenUrl(config.endpoint), {
1385
- method: "POST",
1386
- headers: {
1387
- "Content-Type": "application/json",
1388
- Accept: "application/json",
1389
- "User-Agent": "@patchstack/connect"
1390
- },
1391
- body: JSON.stringify({
1392
- grant_type: "client_credentials",
1393
- client_id: credentials.clientId,
1394
- client_secret: credentials.clientSecret
1395
- }),
1396
- signal: AbortSignal.timeout(config.timeoutMs)
1397
- });
1398
- if (!response.ok) return null;
1399
- const body = await response.json();
1400
- if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
1401
- const expiresIn = Number(body.expires_in);
1402
- const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
1403
- cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
1404
- return body.access_token;
1405
- } catch {
1406
- return null;
1407
- } finally {
1408
- inflight = null;
1633
+ // src/protect/rules/contract.js
1634
+ var SOURCES = Object.freeze({
1635
+ raw: Object.freeze({ keyed: false }),
1636
+ all: Object.freeze({ keyed: false }),
1637
+ false: Object.freeze({ keyed: false }),
1638
+ rules: Object.freeze({ keyed: false, group: true }),
1639
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1640
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1641
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1642
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1643
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
1644
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
1645
+ files: Object.freeze({
1646
+ keyed: true,
1647
+ keys: "any",
1648
+ wildcard: true,
1649
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
1650
+ }),
1651
+ server: Object.freeze({
1652
+ keyed: true,
1653
+ keys: Object.freeze([
1654
+ "REQUEST_URI",
1655
+ "REQUEST_METHOD",
1656
+ "HTTP_USER_AGENT",
1657
+ "HTTP_REFERER",
1658
+ "HTTP_HOST",
1659
+ "REMOTE_ADDR",
1660
+ "ip",
1661
+ "CONTENT_TYPE",
1662
+ "CONTENT_LENGTH"
1663
+ ]),
1664
+ key_prefixes: Object.freeze(["HTTP_"])
1665
+ }),
1666
+ response: Object.freeze({
1667
+ keyed: true,
1668
+ keys: Object.freeze(["status", "body", "headers"]),
1669
+ key_prefixes: Object.freeze(["header."])
1670
+ }),
1671
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
1672
+ });
1673
+ var MATCH_TYPES = Object.freeze([
1674
+ "array_in_array",
1675
+ "array_key_value",
1676
+ "contains",
1677
+ "cors_reflected",
1678
+ "cross_origin",
1679
+ "ctype_alnum",
1680
+ "ctype_digit",
1681
+ "ctype_special",
1682
+ "equals",
1683
+ "equals_strict",
1684
+ "hostname",
1685
+ "in_array",
1686
+ "inline_js_xss",
1687
+ "inline_xss",
1688
+ "internal_host",
1689
+ "is_numeric",
1690
+ "isset",
1691
+ "jwt_claim_equals",
1692
+ "less_than",
1693
+ "more_than",
1694
+ "not_contains",
1695
+ "not_in_array",
1696
+ "off_origin",
1697
+ "quotes",
1698
+ "regex",
1699
+ "stripos"
1700
+ ]);
1701
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
1702
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
1703
+ "isset",
1704
+ "quotes",
1705
+ "is_numeric",
1706
+ "ctype_alnum",
1707
+ "ctype_digit",
1708
+ "ctype_special",
1709
+ "inline_xss",
1710
+ "inline_js_xss",
1711
+ "internal_host",
1712
+ "cross_origin",
1713
+ "off_origin",
1714
+ "cors_reflected"
1715
+ ]);
1716
+ var OPERAND_SHAPES = Object.freeze([
1717
+ "positive-number",
1718
+ "boolean",
1719
+ "non-empty-string",
1720
+ "regex-literal",
1721
+ "string-or-string-list",
1722
+ "scalar",
1723
+ "string",
1724
+ "numeric",
1725
+ "scalar-or-non-empty-list",
1726
+ "non-empty-list",
1727
+ "object",
1728
+ "non-empty-object",
1729
+ "non-empty-string-list",
1730
+ "header-name-map",
1731
+ "header-name-list",
1732
+ "cookie-flags"
1733
+ ]);
1734
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
1735
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
1736
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
1737
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
1738
+ var MATCH_OPERANDS = Object.freeze({
1739
+ array_key_value: Object.freeze({
1740
+ required: Object.freeze(["key", "match"]),
1741
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
1742
+ }),
1743
+ // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
1744
+ // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
1745
+ // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
1746
+ // oversized, claim missing, claim not a string, or a different value — is a no-match.
1747
+ jwt_claim_equals: Object.freeze({
1748
+ required: Object.freeze(["claim", "value"]),
1749
+ shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
1750
+ }),
1751
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
1752
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1753
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1754
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1755
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
1756
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
1757
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
1758
+ // `array_in_array` inert and `not_in_array` true for every value.
1759
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1760
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
1761
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
1762
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
1763
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
1764
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
1765
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
1766
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
1767
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
1768
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
1769
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
1770
+ });
1771
+ function operandShapeProblem(shape, value) {
1772
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
1773
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
1774
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
1775
+ switch (shape) {
1776
+ case "positive-number":
1777
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
1778
+ case "boolean":
1779
+ return typeof value === "boolean" ? null : "must be true or false";
1780
+ case "non-empty-string":
1781
+ return isFilledString(value) ? null : "must be a non-empty string";
1782
+ case "regex-literal":
1783
+ if (!isFilledString(value)) return "must be a non-empty string";
1784
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
1785
+ case "string-or-string-list":
1786
+ if (isFilledString(value)) return null;
1787
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
1788
+ return "must be a non-empty string, or a non-empty list of them";
1789
+ case "scalar":
1790
+ return isScalar(value) ? null : "must be a string, number or boolean";
1791
+ case "string":
1792
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
1793
+ case "numeric":
1794
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
1795
+ case "scalar-or-non-empty-list":
1796
+ if (isScalar(value)) return null;
1797
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
1798
+ return "must be a scalar, or a non-empty list of them";
1799
+ case "non-empty-list":
1800
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
1801
+ case "header-name-map": {
1802
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
1803
+ const names = Object.keys(value);
1804
+ if (names.length === 0) return "must be an object with at least one entry";
1805
+ const bad = names.find((name) => !isHeaderName(name));
1806
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
1409
1807
  }
1410
- })();
1411
- return inflight;
1412
- }
1413
- async function pulseAuthHeader(config, fetchImpl = fetch) {
1414
- const token = await getPulseToken(config, fetchImpl);
1415
- return token === null ? {} : { Authorization: `Bearer ${token}` };
1416
- }
1417
-
1418
- // src/protect/engine/pulse-client.js
1419
- var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
1420
- var DEFAULT_CACHE_TTL2 = 3e5;
1421
- var JITTER_FRACTION2 = 0.1;
1422
- var PulseRuleClient = class {
1423
- #siteUuid;
1424
- #timeoutMs;
1425
- #baseUrl;
1426
- #cacheTtl;
1427
- #cache = null;
1428
- #cacheTime = null;
1429
- #ttlEffective = 0;
1430
- #etag;
1431
- #pulseAuth;
1432
- #reportsDetections;
1433
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
1434
- this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
1435
- this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
1436
- this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
1437
- this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
1438
- this.#etag = etag ?? null;
1439
- this.#pulseAuth = pulseAuth ?? null;
1440
- this.#reportsDetections = reportsDetections === true;
1441
- if (!this.#siteUuid) {
1442
- throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
1443
- }
1444
- }
1445
- async getRules() {
1446
- const now = Date.now();
1447
- if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
1448
- return this.#cache;
1808
+ case "header-name-list": {
1809
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
1810
+ const bad = value.find((name) => !isHeaderName(name));
1811
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
1449
1812
  }
1450
- const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
1451
- try {
1452
- const auth = await pulseAuthHeader(
1453
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
1454
- fetch
1455
- );
1456
- const headers = { Accept: "application/json", ...auth };
1457
- if (this.#reportsDetections && typeof auth.Authorization === "string") {
1458
- headers["X-Patchstack-Detections"] = "enabled";
1459
- }
1460
- if (this.#etag) headers["If-None-Match"] = this.#etag;
1461
- const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
1462
- if (response.status === 304) {
1463
- this.#touch(now);
1464
- return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
1813
+ case "cookie-flags": {
1814
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
1815
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
1816
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
1817
+ for (const flag of ["httpOnly", "secure"]) {
1818
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
1819
+ return `flag "${flag}" must be true or false`;
1820
+ }
1465
1821
  }
1466
- if (!response.ok) {
1467
- return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
1822
+ const sameSite = value.sameSite;
1823
+ const sameSiteOff = sameSite === false;
1824
+ if (sameSite !== void 0 && !sameSiteOff) {
1825
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
1826
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
1827
+ }
1468
1828
  }
1469
- const data = await response.json();
1470
- if (!data || !Array.isArray(data.firewall)) {
1471
- return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
1829
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
1830
+ return "turns off every flag, so it would harden nothing";
1472
1831
  }
1473
- const result = {
1474
- success: true,
1475
- notModified: false,
1476
- etag: response.headers?.get?.("etag") ?? null,
1477
- firewall: data.firewall,
1478
- whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
1479
- whitelist_keys: data.whitelist_keys ?? {},
1480
- ...enforcementField(data)
1481
- };
1482
- this.#cache = result;
1483
- this.#etag = result.etag;
1484
- this.#touch(now);
1485
- return result;
1486
- } catch (err) {
1487
- return {
1488
- success: false,
1489
- error: err.name === "TimeoutError" ? "Request timed out" : err.message,
1490
- firewall: [],
1491
- whitelists: [],
1492
- whitelist_keys: {}
1493
- };
1832
+ return null;
1494
1833
  }
1834
+ case "object":
1835
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
1836
+ case "non-empty-object":
1837
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
1838
+ case "non-empty-string-list":
1839
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
1840
+ default:
1841
+ return null;
1495
1842
  }
1496
- #touch(now) {
1497
- this.#cacheTime = now;
1498
- this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
1843
+ }
1844
+ var MUTATIONS = Object.freeze([
1845
+ "base64_decode",
1846
+ "getArrayValues",
1847
+ "htmlentitydecode",
1848
+ "intval",
1849
+ "json_decode",
1850
+ "json_encode",
1851
+ "urldecode"
1852
+ ]);
1853
+ var PHASES = Object.freeze(["request", "response", "egress"]);
1854
+ var ACTIONS = Object.freeze([
1855
+ "block",
1856
+ "redact",
1857
+ "encode",
1858
+ "set-header",
1859
+ "remove-header",
1860
+ "harden-cookie"
1861
+ ]);
1862
+ var RULE_PROPERTY_SHAPES = Object.freeze({
1863
+ max_bytes: "positive-number",
1864
+ bypass_limit: "boolean",
1865
+ ensure: "boolean",
1866
+ prefilter: "non-empty-string-list"
1867
+ });
1868
+ var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
1869
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
1870
+ var RULE_PROPERTIES = Object.freeze([
1871
+ "id",
1872
+ "rule_id",
1873
+ "title",
1874
+ "category",
1875
+ "phase",
1876
+ "action",
1877
+ "rule_v2",
1878
+ "when",
1879
+ "message",
1880
+ "enforcement",
1881
+ "source_revision",
1882
+ "prefilter",
1883
+ "max_bytes",
1884
+ "bypass_limit",
1885
+ "set_headers",
1886
+ "remove_headers",
1887
+ "cookie_flags",
1888
+ "ensure",
1889
+ "capture"
1890
+ ]);
1891
+ var CAPTURE_VERSION = 1;
1892
+ var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
1893
+ var CAPTURE_RAW_CHARS_MAX = 512;
1894
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
1895
+ function captureProblem(capture) {
1896
+ if (capture === void 0 || capture === null) return null;
1897
+ if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
1898
+ for (const key of Object.keys(capture)) {
1899
+ if (!CAPTURE_KEYS.includes(key)) return `capture has no "${key}" \u2014 it takes ${CAPTURE_KEYS.join(", ")}`;
1900
+ }
1901
+ if (!Object.hasOwn(capture, "version")) return "capture must name the version it was written against";
1902
+ if (capture.version !== CAPTURE_VERSION) {
1903
+ return `capture version ${String(capture.version)} is not one this guard understands`;
1904
+ }
1905
+ if (!Object.hasOwn(capture, "raw_chars")) return "capture must say what it is opting into";
1906
+ const chars = capture.raw_chars;
1907
+ if (typeof chars !== "number" || !Number.isInteger(chars) || chars <= 0) {
1908
+ return "capture.raw_chars must be a positive whole number of characters";
1499
1909
  }
1500
- clearCache() {
1501
- this.#cache = null;
1502
- this.#cacheTime = null;
1503
- this.#etag = null;
1910
+ return null;
1911
+ }
1912
+ var ACTION_PROPERTIES = Object.freeze({
1913
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
1914
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
1915
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
1916
+ "set-header": Object.freeze({
1917
+ required: Object.freeze(["set_headers"]),
1918
+ defaulted: Object.freeze(["ensure"]),
1919
+ phases: Object.freeze(["response"]),
1920
+ shapes: Object.freeze({ set_headers: "header-name-map" })
1921
+ }),
1922
+ "remove-header": Object.freeze({
1923
+ required: Object.freeze(["remove_headers"]),
1924
+ defaulted: Object.freeze([]),
1925
+ phases: Object.freeze(["response"]),
1926
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
1927
+ }),
1928
+ "harden-cookie": Object.freeze({
1929
+ required: Object.freeze([]),
1930
+ defaulted: Object.freeze(["cookie_flags"]),
1931
+ phases: Object.freeze(["response"]),
1932
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
1933
+ })
1934
+ });
1935
+ var LIMITS = Object.freeze({
1936
+ maxRules: 5e3,
1937
+ maxWhitelists: 2e3,
1938
+ maxConditionsPerRule: 250,
1939
+ maxNestingDepth: 12,
1940
+ maxRegexLength: 1e3,
1941
+ maxValueLength: 8192
1942
+ });
1943
+ var GROUP_PARAMETER = "rules";
1944
+ function isGroup(condition) {
1945
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
1946
+ }
1947
+ function conditionShapeProblem(condition) {
1948
+ if (!condition || typeof condition !== "object") return "condition is not an object";
1949
+ const named = condition.parameter === GROUP_PARAMETER;
1950
+ const nested = Array.isArray(condition.rules);
1951
+ if (named && !nested) {
1952
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
1504
1953
  }
1505
- };
1506
- function enforcementField(data) {
1507
- if (!data || typeof data !== "object") return {};
1508
- const v = data.enforcement ?? data.mode;
1509
- return v === "block" || v === "dry-run" ? { enforcement: v } : {};
1954
+ if (nested && !named) {
1955
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
1956
+ }
1957
+ if (named && nested) {
1958
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
1959
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
1960
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
1961
+ }
1962
+ return null;
1510
1963
  }
1511
-
1512
- // src/protect/engine/fetch.js
1513
- var MAX_BODY_BYTES = 1024 * 1024;
1514
- async function fromFetchRequest(request, options = {}) {
1515
- const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
1516
- const url = new URL(request.url);
1517
- const method = (request.method || "GET").toUpperCase();
1518
- const headers = {};
1519
- request.headers.forEach((value, key) => {
1520
- headers[key.toLowerCase()] = value;
1521
- });
1522
- const query = {};
1523
- for (const [key, value] of url.searchParams) {
1524
- if (key in query) {
1525
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
1526
- } else {
1527
- query[key] = value;
1964
+ function parameterProblem(parameter) {
1965
+ if (parameter === void 0 || parameter === null) return null;
1966
+ if (Array.isArray(parameter)) {
1967
+ if (parameter.length === 0) return "parameter list is empty";
1968
+ for (const member of parameter) {
1969
+ if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
1970
+ const problem = parameterProblem(member);
1971
+ if (problem !== null) return problem;
1528
1972
  }
1973
+ return null;
1529
1974
  }
1530
- let rawBody = "";
1531
- if (method !== "GET" && method !== "HEAD") {
1532
- rawBody = await readCappedText(request, maxBodyBytes);
1975
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
1976
+ if (parameter === GROUP_PARAMETER) {
1977
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
1533
1978
  }
1534
- const contentType = headers["content-type"] || "";
1535
- let body = {};
1536
- let files;
1537
- if (rawBody) {
1538
- const parsed = parseBody(rawBody, contentType);
1539
- body = parsed.body;
1540
- files = parsed.files;
1979
+ const dot = parameter.indexOf(".");
1980
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
1981
+ const source = SOURCES[name];
1982
+ if (source === void 0) return `unknown parameter source "${name}"`;
1983
+ if (dot === -1) {
1984
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
1541
1985
  }
1542
- const uri = url.pathname + url.search;
1543
- const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
1544
- return {
1545
- method,
1546
- url: uri,
1547
- originalUrl: uri,
1548
- query,
1549
- body,
1550
- files,
1551
- headers,
1552
- ip: forwarded.split(",")[0].trim(),
1553
- cookies: parseCookies(headers.cookie),
1554
- // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
1555
- // drops, so prototype-pollution rules on `raw` are robust.
1556
- _rawBody: rawBody
1557
- };
1986
+ if (source.keyed !== true) return `source "${name}" takes no key`;
1987
+ const key = parameter.slice(dot + 1);
1988
+ if (key === "") return `source "${name}" needs a key after the dot`;
1989
+ return keyProblem(name, source, key);
1558
1990
  }
1559
- async function readCappedText(request, max) {
1560
- let clone;
1561
- try {
1562
- clone = request.clone();
1563
- } catch {
1564
- return "";
1991
+ function keyProblem(name, source, key) {
1992
+ if (key.endsWith("*") && source.wildcard !== true) {
1993
+ return `"${name}" does not fan out over a wildcard key`;
1565
1994
  }
1566
- const body = clone.body;
1567
- if (body && typeof body.getReader === "function") {
1568
- const reader = body.getReader();
1569
- const chunks = [];
1570
- let buffered = 0;
1571
- try {
1572
- for (; ; ) {
1573
- const { done, value } = await reader.read();
1574
- if (done) break;
1575
- if (!value || buffered >= max) continue;
1576
- const take = Math.min(value.byteLength, max - buffered);
1577
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
1578
- buffered += take;
1995
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
1996
+ if (source.keys === "any") return null;
1997
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
1998
+ for (const prefix of source.key_prefixes ?? []) {
1999
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
2000
+ }
2001
+ const accepted = [
2002
+ ...Array.isArray(source.keys) ? source.keys : [],
2003
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
2004
+ ].join(", ");
2005
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
2006
+ }
2007
+ function matchProblem(match, parameter) {
2008
+ if (!match || typeof match !== "object") return "condition has no match object";
2009
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2010
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2011
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2012
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
2013
+ return `match type "${match.type}" needs a parameter`;
2014
+ }
2015
+ if (parameterless && parameter !== void 0 && parameter !== null) {
2016
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
2017
+ }
2018
+ const operands = MATCH_OPERANDS[match.type];
2019
+ if (operands) {
2020
+ for (const required of operands.required) {
2021
+ if (match[required] === void 0 || match[required] === null) {
2022
+ return `match type "${match.type}" needs "${required}"`;
1579
2023
  }
1580
- } catch {
1581
- return "";
1582
2024
  }
1583
- try {
1584
- return new TextDecoder().decode(concatChunks(chunks, buffered));
1585
- } catch {
1586
- return "";
2025
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
2026
+ if (match[operand] === void 0) continue;
2027
+ const problem = operandShapeProblem(shape, match[operand]);
2028
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
1587
2029
  }
2030
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2031
+ return `match type "${match.type}" needs a value`;
1588
2032
  }
1589
- try {
1590
- const text = await clone.text();
1591
- return text.length > max ? text.slice(0, max) : text;
1592
- } catch {
1593
- return "";
1594
- }
2033
+ return null;
1595
2034
  }
1596
- function concatChunks(chunks, total) {
1597
- const out = new Uint8Array(total);
1598
- let offset = 0;
1599
- for (const c of chunks) {
1600
- out.set(c, offset);
1601
- offset += c.byteLength;
2035
+ function mutationsProblem(mutations) {
2036
+ if (mutations === void 0 || mutations === null) return null;
2037
+ if (!Array.isArray(mutations)) return "mutations must be an array";
2038
+ for (const mutation of mutations) {
2039
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2040
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
2041
+ }
1602
2042
  }
1603
- return out;
2043
+ return null;
1604
2044
  }
1605
- function parseBody(rawBody, contentType) {
1606
- const ct = String(contentType || "").toLowerCase();
1607
- const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
1608
- const isForm = ct.includes("application/x-www-form-urlencoded");
1609
- const isMultipart = ct.includes("multipart/form-data");
1610
- const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
1611
- if (isMultipart) {
1612
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
1613
- if (boundary) return parseMultipart(rawBody, boundary);
1614
- return { body: {}, files: void 0 };
2045
+ function whenProblem(when) {
2046
+ if (when === void 0 || when === null) return null;
2047
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2048
+ const keys = Object.keys(when);
2049
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2050
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2051
+ if (unknown.length > 0) {
2052
+ return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
1615
2053
  }
1616
- if (isForm) {
1617
- const body = {};
1618
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
1619
- return { body, files: void 0 };
2054
+ if ("path" in when && !isNonEmptyString(when.path)) {
2055
+ return "when.path must be a non-empty string";
1620
2056
  }
1621
- if (isJson || isAmbiguous) {
1622
- try {
1623
- const parsed = JSON.parse(rawBody);
1624
- if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
1625
- } catch {
1626
- }
2057
+ if ("method" in when) {
2058
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
2059
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2060
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
1627
2061
  }
1628
- return { body: {}, files: void 0 };
2062
+ return null;
1629
2063
  }
1630
- function parseMultipart(rawBody, boundary) {
1631
- const body = {};
1632
- const files = {};
1633
- for (const part of rawBody.split("--" + boundary)) {
1634
- const sep = /\r?\n\r?\n/.exec(part);
1635
- if (!sep) continue;
1636
- const rawHeaders = part.slice(0, sep.index);
1637
- const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
1638
- if (!disposition) continue;
1639
- const name = /name="([^"]*)"/i.exec(disposition)?.[1];
1640
- if (name == null) continue;
1641
- const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
1642
- const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
1643
- if (filename !== void 0) {
1644
- const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
1645
- const file = { filename, type: partType, content };
1646
- files[name] = name in files ? [].concat(files[name], file) : file;
1647
- } else {
1648
- body[name] = name in body ? [].concat(body[name], content) : content;
2064
+ function isNonEmptyString(value) {
2065
+ return typeof value === "string" && value.trim() !== "";
2066
+ }
2067
+ function nullPropertyProblem(rule) {
2068
+ if (!rule || typeof rule !== "object") return null;
2069
+ for (const property of RULE_PROPERTIES) {
2070
+ if (NULL_EXEMPT_PROPERTIES.includes(property)) continue;
2071
+ if (property in rule && rule[property] === null) {
2072
+ return `"${property}" is present but null; omit it to mean the default, since a null is a value that was meant to be something and is not`;
1649
2073
  }
1650
2074
  }
1651
- return { body, files };
2075
+ return null;
1652
2076
  }
1653
- function parseCookies(header) {
1654
- const cookies = {};
1655
- if (!header) {
1656
- return cookies;
2077
+ function rulePropertyProblem(rule) {
2078
+ if (!rule || typeof rule !== "object") return null;
2079
+ for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
2080
+ if (rule[property] === void 0 || rule[property] === null) continue;
2081
+ const problem = operandShapeProblem(shape, rule[property]);
2082
+ if (problem) return `"${property}" ${problem}`;
1657
2083
  }
1658
- for (const pair of header.split(";")) {
1659
- const idx = pair.indexOf("=");
1660
- if (idx === -1) {
1661
- continue;
2084
+ return null;
2085
+ }
2086
+ function actionProblem(action, rule) {
2087
+ if (action === void 0 || action === null) return null;
2088
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2089
+ const spec = ACTION_PROPERTIES[action];
2090
+ for (const required of spec?.required ?? []) {
2091
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
2092
+ return `action "${action}" needs "${required}"`;
1662
2093
  }
1663
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
1664
2094
  }
1665
- return cookies;
2095
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2096
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2097
+ const problem = operandShapeProblem(shape, rule[property]);
2098
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
2099
+ }
2100
+ const phase = rule?.phase ?? "request";
2101
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
2102
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2103
+ }
2104
+ return null;
1666
2105
  }
1667
2106
 
1668
- // src/protect/engine/node.js
1669
- function fromNodeRequest(req, rawBody = "") {
1670
- const method = (req.method || "GET").toUpperCase();
1671
- const headers = {};
1672
- for (const [key, value] of Object.entries(req.headers || {})) {
1673
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
1674
- }
1675
- const host = headers.host || "localhost";
1676
- let url;
1677
- try {
1678
- url = new URL(req.url || "/", `http://${host}`);
1679
- } catch {
1680
- try {
1681
- url = new URL(req.url || "/", "http://localhost");
1682
- } catch {
1683
- url = new URL("http://localhost/");
2107
+ // src/protect/rules/validate.js
2108
+ var LIMITS2 = LIMITS;
2109
+ var PHASES2 = new Set(PHASES);
2110
+ var ACTIONS2 = new Set(ACTIONS);
2111
+ function validateBundle(bundle, opts = {}) {
2112
+ const rejected = [];
2113
+ const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2114
+ const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2115
+ const firewall = [];
2116
+ for (const rule of inFirewall) {
2117
+ if (firewall.length >= LIMITS2.maxRules) {
2118
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2119
+ continue;
1684
2120
  }
2121
+ const reason = enforceableRuleProblem(rule);
2122
+ if (reason) rejected.push({ id: idOf(rule), reason });
2123
+ else firewall.push(rule);
1685
2124
  }
1686
- const query = {};
1687
- for (const [key, value] of url.searchParams) {
1688
- if (key in query) {
1689
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
1690
- } else {
1691
- query[key] = value;
2125
+ const whitelists = [];
2126
+ for (const wl of inWhitelists) {
2127
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
2128
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2129
+ continue;
1692
2130
  }
2131
+ if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
2132
+ rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2133
+ continue;
2134
+ }
2135
+ const reason = conditionsProblem(wl?.rule_v2);
2136
+ if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2137
+ else whitelists.push(wl);
1693
2138
  }
1694
- const contentType = headers["content-type"] || "";
1695
- let body = {};
1696
- let files;
1697
- if (rawBody) {
1698
- const parsed = parseBody(rawBody, contentType);
1699
- body = parsed.body;
1700
- files = parsed.files;
1701
- }
1702
- const uri = url.pathname + url.search;
1703
- const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
1704
2139
  return {
1705
- method,
1706
- url: uri,
1707
- originalUrl: uri,
1708
- query,
1709
- body,
1710
- files,
1711
- headers,
1712
- ip: forwarded.split(",")[0].trim() || req.socket && req.socket.remoteAddress || "",
1713
- cookies: parseCookies2(headers.cookie),
1714
- // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
1715
- _rawBody: rawBody
2140
+ bundle: { ...bundle, firewall, whitelists },
2141
+ rejected
1716
2142
  };
1717
2143
  }
1718
- function parseCookies2(header) {
1719
- const cookies = {};
1720
- if (!header) {
1721
- return cookies;
2144
+ function idOf(rule) {
2145
+ const id = rule?.id ?? rule?.rule_id;
2146
+ return id === void 0 || id === null ? "(unidentified)" : String(id);
2147
+ }
2148
+ function enforceableRuleProblem(rule) {
2149
+ if (!rule || typeof rule !== "object") return "not an object";
2150
+ const nullReason = nullPropertyProblem(rule);
2151
+ if (nullReason) return nullReason;
2152
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2153
+ const actionReason = actionProblem(rule.action, rule);
2154
+ if (actionReason) return actionReason;
2155
+ const scopeReason = whenProblem(rule.when);
2156
+ if (scopeReason) return scopeReason;
2157
+ const propertyReason = rulePropertyProblem(rule);
2158
+ if (propertyReason) return propertyReason;
2159
+ return conditionsProblem(rule.rule_v2);
2160
+ }
2161
+ function conditionsProblem(conditions, depth = 0) {
2162
+ if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2163
+ if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2164
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2165
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
2166
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
1722
2167
  }
1723
- for (const pair of header.split(";")) {
1724
- const idx = pair.indexOf("=");
1725
- if (idx === -1) {
2168
+ for (const c of conditions) {
2169
+ const shapeReason = conditionShapeProblem(c);
2170
+ if (shapeReason) return shapeReason;
2171
+ if (isGroup(c)) {
2172
+ const nested = conditionsProblem(c.rules, depth + 1);
2173
+ if (nested) return nested;
1726
2174
  continue;
1727
2175
  }
1728
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
2176
+ const parameterReason = parameterProblem(c.parameter);
2177
+ if (parameterReason) return parameterReason;
2178
+ const mutationReason = mutationsProblem(c.mutations);
2179
+ if (mutationReason) return mutationReason;
2180
+ const matchReason = matchProblem(c.match, c.parameter);
2181
+ if (matchReason) return matchReason;
2182
+ const m = c.match;
2183
+ if (m.type === "regex") {
2184
+ if (typeof m.value !== "string") return "regex match.value must be a string";
2185
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2186
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2187
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2188
+ }
2189
+ if (m.match) {
2190
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2191
+ if (nested) return nested;
2192
+ }
1729
2193
  }
1730
- return cookies;
2194
+ return null;
1731
2195
  }
1732
2196
 
1733
- // src/protect/egress.js
1734
- async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
1735
- const restores = [];
1736
- if (typeof shouldBlock !== "function") return () => {
1737
- };
1738
- const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
1739
- const block = (url, host, method) => {
1740
- if (!shouldBlock(url, host, method)) return false;
1741
- notify(onBlock, { url, host, method }, "onEgressBlock");
1742
- return true;
2197
+ // src/protect/capture-plan.js
2198
+ var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
2199
+ var CAPTURE_LIMITS = Object.freeze({
2200
+ /** Named and prefix values in one event, together. Raw has its own allowance. */
2201
+ capturedValues: 10,
2202
+ /** Characters of any single captured value. */
2203
+ valueChars: 512,
2204
+ /** Resolved values one prefix permission may contribute. */
2205
+ prefixValues: 5
2206
+ });
2207
+ function parametersOf(rule) {
2208
+ const out = /* @__PURE__ */ new Set();
2209
+ const add = (parameter) => {
2210
+ if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
1743
2211
  };
1744
- let screen = null;
1745
- if (dnsScreen) {
1746
- try {
1747
- const resolveLookup = lookup ?? (await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))).lookup;
1748
- const { isIP } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1));
1749
- if (typeof resolveLookup === "function" && typeof isIP === "function") {
1750
- screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
1751
- }
1752
- } catch {
1753
- screen = null;
2212
+ const collect = (parameter) => {
2213
+ if (Array.isArray(parameter)) {
2214
+ for (const member of parameter) add(member);
2215
+ return;
1754
2216
  }
1755
- }
1756
- const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
1757
- const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
1758
- if (!screen) {
1759
- if (host && dnsScreen) skip("resolver-unavailable", { host });
1760
- return resolve(false);
2217
+ add(parameter);
2218
+ };
2219
+ const walk = (conditions, depth) => {
2220
+ if (!Array.isArray(conditions) || depth > 20) return;
2221
+ for (const condition of conditions) {
2222
+ if (!condition || typeof condition !== "object") continue;
2223
+ collect(condition.parameter);
2224
+ if (Array.isArray(condition.rules)) walk(condition.rules, depth + 1);
1761
2225
  }
1762
- if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
1763
- try {
1764
- screen.lookup(host, { all: true }, (err, addresses) => {
1765
- if (err || !Array.isArray(addresses)) {
1766
- skip("resolver-failed", { host });
1767
- return resolve(false);
1768
- }
1769
- for (const a of addresses) {
1770
- const ip = a && typeof a === "object" ? a.address : a;
1771
- if (ip && block(url, ip, method)) return resolve(true);
1772
- }
1773
- resolve(false);
1774
- });
1775
- } catch {
1776
- skip("resolver-failed", { host });
1777
- resolve(false);
2226
+ };
2227
+ walk(rule?.rule_v2, 0);
2228
+ return [...out];
2229
+ }
2230
+ function rawOptIn(rule) {
2231
+ if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
2232
+ const capture = rule.capture;
2233
+ if (capture === null || typeof capture !== "object") return null;
2234
+ if (captureProblem(capture) !== null) return null;
2235
+ return { chars: Math.min(capture.raw_chars, CAPTURE_RAW_CHARS_MAX) };
2236
+ }
2237
+ var NOTHING = Object.freeze({
2238
+ named: Object.freeze([]),
2239
+ prefixes: Object.freeze([]),
2240
+ raw: null,
2241
+ limits: CAPTURE_LIMITS
2242
+ });
2243
+ function derivePlan(rule) {
2244
+ const named = /* @__PURE__ */ new Set();
2245
+ const prefixes = /* @__PURE__ */ new Set();
2246
+ if (enforceableRuleProblem(rule) !== null) return NOTHING;
2247
+ const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
2248
+ for (const parameter of parameters) {
2249
+ const dot = parameter.indexOf(".");
2250
+ if (dot === -1) continue;
2251
+ const source = parameter.slice(0, dot);
2252
+ const key = parameter.slice(dot + 1);
2253
+ if (SOURCES[source]?.keyed !== true || NEVER_CAPTURABLE.has(source)) continue;
2254
+ if (key.endsWith("*")) {
2255
+ const prefix = key.slice(0, -1);
2256
+ if (prefix !== "") prefixes.add(`${source}.${prefix}`);
2257
+ continue;
1778
2258
  }
2259
+ named.add(`${source}.${key}`);
2260
+ }
2261
+ const raw = rawOptIn(rule);
2262
+ return Object.freeze({
2263
+ named: Object.freeze([...named].sort()),
2264
+ prefixes: Object.freeze([...prefixes].sort()),
2265
+ raw: raw === null ? null : Object.freeze(raw),
2266
+ limits: CAPTURE_LIMITS
1779
2267
  });
1780
- const originalFetch = globalThis.fetch;
1781
- if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
1782
- const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
1783
- const MAX_REDIRECTS = 20;
1784
- const screenUrl = async (u, method) => {
1785
- let host = null;
1786
- try {
1787
- host = new URL(u).hostname;
1788
- } catch {
1789
- host = null;
1790
- }
1791
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
1792
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
1793
- }
1794
- };
1795
- const guarded = async (input, init) => {
1796
- let cur;
1797
- try {
1798
- cur = new Request(input, { ...init || {}, redirect: "manual" });
1799
- } catch {
1800
- return originalFetch(input, init);
1801
- }
1802
- const callerRedirect = init && init.redirect || input && input.redirect || "follow";
1803
- let url = cur.url;
1804
- let method = cur.method;
1805
- await screenUrl(url, method);
1806
- if (callerRedirect !== "follow") return originalFetch(input, init);
1807
- const headers = new Headers(cur.headers);
1808
- const signal = cur.signal;
1809
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
1810
- for (let hop = 0; ; hop++) {
1811
- const resp = await originalFetch(
1812
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
1813
- );
1814
- const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
1815
- if (!location) return resp;
1816
- if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
1817
- const next = new URL(location, url).href;
1818
- if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
1819
- method = "GET";
1820
- body = void 0;
1821
- headers.delete("content-type");
1822
- headers.delete("content-length");
1823
- }
1824
- if (new URL(next).origin !== new URL(url).origin) {
1825
- headers.delete("authorization");
1826
- headers.delete("cookie");
1827
- }
1828
- await screenUrl(next, method);
1829
- url = next;
2268
+ }
2269
+ function permitsAnything(plan) {
2270
+ return plan.named.length > 0 || plan.prefixes.length > 0 || plan.raw !== null;
2271
+ }
2272
+ var LANES = Object.freeze([2166136261, 16777619, 2654435769, 2246822507]);
2273
+ function planReference(plan) {
2274
+ const limits = plan?.limits ?? {};
2275
+ const canonical = JSON.stringify([
2276
+ [...plan?.named ?? []].sort(),
2277
+ [...plan?.prefixes ?? []].sort(),
2278
+ plan?.raw?.chars ?? 0,
2279
+ Object.entries(limits).map(([key, value]) => [key, value]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
2280
+ ]);
2281
+ const digest = LANES.map((base) => {
2282
+ let hash = base >>> 0;
2283
+ for (let i = 0; i < canonical.length; i++) {
2284
+ hash ^= canonical.charCodeAt(i);
2285
+ hash = Math.imul(hash, 16777619) >>> 0;
2286
+ }
2287
+ return hash.toString(16).padStart(8, "0");
2288
+ }).join("");
2289
+ return `cp2-${digest}`;
2290
+ }
2291
+ function createPlanCache() {
2292
+ const byRule = /* @__PURE__ */ new WeakMap();
2293
+ let derivations = 0;
2294
+ return {
2295
+ for(rule) {
2296
+ if (rule === null || typeof rule !== "object") {
2297
+ const plan2 = derivePlan(rule);
2298
+ derivations += 1;
2299
+ return Object.freeze({ plan: plan2, reference: planReference(plan2) });
1830
2300
  }
1831
- };
1832
- guarded.__patchstackGuarded = true;
1833
- globalThis.fetch = guarded;
1834
- restores.push(() => {
1835
- if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
1836
- });
1837
- }
1838
- for (const moduleName of ["node:http", "node:https"]) {
2301
+ const hit = byRule.get(rule);
2302
+ if (hit) return hit;
2303
+ const plan = derivePlan(rule);
2304
+ derivations += 1;
2305
+ const entry = Object.freeze({ plan, reference: planReference(plan) });
2306
+ byRule.set(rule, entry);
2307
+ return entry;
2308
+ },
2309
+ /** How many plans have been derived — the observable difference between a hit and a miss. */
2310
+ get derivations() {
2311
+ return derivations;
2312
+ }
2313
+ };
2314
+ }
2315
+ function captureValues(plan, resolver) {
2316
+ const nothing = { values: [], omitted: 0, unsupported: 0, failed: 0, unavailable: false, raw: null };
2317
+ if (!plan || !permitsAnything(plan)) return nothing;
2318
+ if (!resolver || typeof resolver.resolve !== "function") return { ...nothing, unavailable: true };
2319
+ const limits = plan.limits ?? CAPTURE_LIMITS;
2320
+ const values = [];
2321
+ let omitted = 0;
2322
+ let unsupported = 0;
2323
+ let failed = 0;
2324
+ let attempted = 0;
2325
+ const read = (parameter) => {
2326
+ attempted += 1;
1839
2327
  try {
1840
- const mod = await import(moduleName);
1841
- const restore = patchHttpModule(mod.default ?? mod, block, screen);
1842
- if (restore) restores.push(restore);
2328
+ const resolved = resolver.resolve(parameter);
2329
+ return Array.isArray(resolved) ? resolved : [];
1843
2330
  } catch {
2331
+ failed += 1;
2332
+ return null;
1844
2333
  }
1845
- }
1846
- return () => {
1847
- for (const restore of restores) {
1848
- try {
1849
- restore();
1850
- } catch {
1851
- }
2334
+ };
2335
+ const classify = (value) => {
2336
+ const text = asText(value);
2337
+ if (text === null) {
2338
+ unsupported += 1;
2339
+ return null;
1852
2340
  }
2341
+ return text;
1853
2342
  };
1854
- }
1855
- function patchHttpModule(http, block, screen) {
1856
- if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
1857
- const originalRequest = http.request;
1858
- const originalGet = http.get;
1859
- const wrap = (original) => function(...args) {
1860
- const target = extractHttpTarget(args);
1861
- if (target && block(target.url, target.host, target.method)) {
1862
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
2343
+ const record = (parameter, text) => {
2344
+ if (values.length >= limits.capturedValues) {
2345
+ omitted += 1;
2346
+ return false;
1863
2347
  }
1864
- if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
1865
- try {
1866
- args = withScreeningLookup(args, target, block, screen.lookup);
1867
- } catch {
2348
+ const capped = text.length > limits.valueChars;
2349
+ values.push({
2350
+ parameter,
2351
+ value: capped ? text.slice(0, limits.valueChars) : text,
2352
+ ...capped ? { truncated: true } : {}
2353
+ });
2354
+ return true;
2355
+ };
2356
+ for (const parameter of plan.named) {
2357
+ for (const value of read(parameter) ?? []) {
2358
+ const text = classify(value);
2359
+ if (text !== null) record(parameter, text);
2360
+ }
2361
+ }
2362
+ for (const prefix of plan.prefixes) {
2363
+ const pattern = `${prefix}*`;
2364
+ let taken = 0;
2365
+ for (const value of read(pattern) ?? []) {
2366
+ const text = classify(value);
2367
+ if (text === null) continue;
2368
+ if (taken >= limits.prefixValues) {
2369
+ omitted += 1;
2370
+ continue;
1868
2371
  }
2372
+ if (record(pattern, text)) taken += 1;
1869
2373
  }
1870
- return original.apply(this, args);
1871
- };
1872
- const guardedRequest = wrap(originalRequest);
1873
- http.request = guardedRequest;
1874
- let guardedGet;
1875
- if (typeof originalGet === "function") {
1876
- guardedGet = wrap(originalGet);
1877
- http.get = guardedGet;
1878
2374
  }
1879
- http.__patchstackGuarded = true;
1880
- return () => {
1881
- if (http.request === guardedRequest) http.request = originalRequest;
1882
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
1883
- delete http.__patchstackGuarded;
1884
- };
1885
- }
1886
- function extractHttpTarget(args) {
1887
- const first = args[0];
1888
- try {
1889
- if (typeof first === "string" || first instanceof URL) {
1890
- const url = new URL(String(first));
1891
- const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
1892
- return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
2375
+ let raw = null;
2376
+ if (plan.raw !== null) {
2377
+ const text = asText((read("raw") ?? [])[0]);
2378
+ if (text !== null) {
2379
+ const capped = text.length > plan.raw.chars;
2380
+ raw = { value: capped ? text.slice(0, plan.raw.chars) : text, ...capped ? { truncated: true } : {} };
1893
2381
  }
1894
- if (first && typeof first === "object") {
1895
- const host = normalizeHost(first.hostname || first.host) || "localhost";
1896
- const protocol = first.protocol || "http:";
1897
- const port = first.port ? `:${first.port}` : "";
1898
- const path = first.path || "/";
1899
- return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
1900
- }
1901
- } catch {
1902
2382
  }
1903
- return null;
1904
- }
1905
- function normalizeHost(raw) {
1906
- const host = String(raw || "").trim();
1907
- const bracketed = /^\[([^\]]+)\]/.exec(host);
1908
- if (bracketed) return bracketed[1];
1909
- if ((host.match(/:/g) || []).length > 1) return host;
1910
- return host.split(":")[0];
2383
+ const unavailable = attempted > 0 && failed === attempted;
2384
+ return { values, omitted, unsupported, failed, unavailable, raw };
1911
2385
  }
1912
- function screenResolved(addresses, target, block) {
1913
- for (const a of addresses || []) {
1914
- const ip = a && typeof a === "object" ? a.address : a;
1915
- if (ip && block(target.url, ip, target.method)) return ip;
1916
- }
2386
+ function asText(value) {
2387
+ if (typeof value === "string") return value;
2388
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
2389
+ if (typeof value === "boolean") return String(value);
1917
2390
  return null;
1918
2391
  }
1919
- function withScreeningLookup(args, target, block, lookup) {
1920
- const screeningLookup = (hostname, options, callback) => {
1921
- let opts = options;
1922
- let cb = callback;
1923
- if (typeof opts === "function") {
1924
- cb = opts;
1925
- opts = {};
1926
- }
1927
- if (!opts || typeof opts !== "object") opts = {};
1928
- try {
1929
- lookup(hostname, { ...opts, all: true }, (err, addresses) => {
1930
- if (err) return cb(err);
1931
- const list = Array.isArray(addresses) ? addresses : [];
1932
- const blocked = screenResolved(list, target, block);
1933
- if (blocked) {
1934
- return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
1935
- }
1936
- if (opts.all) return cb(null, list);
1937
- const first = list[0];
1938
- if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
1939
- return cb(null, first.address, first.family);
2392
+
2393
+ // src/pulse-token.ts
2394
+ var TOKEN_SKEW_MS = 6e4;
2395
+ function buildTokenUrl(manifestEndpoint) {
2396
+ const url = new URL(manifestEndpoint);
2397
+ const path = url.pathname.replace(/\/$/, "");
2398
+ url.pathname = path.endsWith("/manifest") ? `${path.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
2399
+ url.search = "";
2400
+ url.hash = "";
2401
+ return url.toString();
2402
+ }
2403
+ function parsePulseAuth(credential) {
2404
+ const index = credential.lastIndexOf("-");
2405
+ if (index <= 0 || index === credential.length - 1) return null;
2406
+ const clientId = credential.slice(index + 1);
2407
+ if (!/^\d+$/.test(clientId)) return null;
2408
+ return { clientId, clientSecret: credential.slice(0, index) };
2409
+ }
2410
+ var cached = null;
2411
+ var inflight = null;
2412
+ function clearPulseToken() {
2413
+ cached = null;
2414
+ }
2415
+ async function getPulseToken(config, fetchImpl = fetch) {
2416
+ if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
2417
+ if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
2418
+ return cached.token;
2419
+ }
2420
+ if (inflight !== null) return inflight;
2421
+ const credentials = parsePulseAuth(config.pulseAuth);
2422
+ if (credentials === null) return null;
2423
+ inflight = (async () => {
2424
+ try {
2425
+ const response = await fetchImpl(buildTokenUrl(config.endpoint), {
2426
+ method: "POST",
2427
+ headers: {
2428
+ "Content-Type": "application/json",
2429
+ Accept: "application/json",
2430
+ "User-Agent": "@patchstack/connect"
2431
+ },
2432
+ body: JSON.stringify({
2433
+ grant_type: "client_credentials",
2434
+ client_id: credentials.clientId,
2435
+ client_secret: credentials.clientSecret
2436
+ }),
2437
+ signal: AbortSignal.timeout(config.timeoutMs)
1940
2438
  });
2439
+ if (!response.ok) return null;
2440
+ const body = await response.json();
2441
+ if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
2442
+ const expiresIn = Number(body.expires_in);
2443
+ const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
2444
+ cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
2445
+ return body.access_token;
1941
2446
  } catch {
1942
- try {
1943
- lookup(hostname, opts, cb);
1944
- } catch {
1945
- cb(new Error(`Patchstack: lookup failed for ${hostname}`));
1946
- }
2447
+ return null;
2448
+ } finally {
2449
+ inflight = null;
1947
2450
  }
2451
+ })();
2452
+ return inflight;
2453
+ }
2454
+ async function pulseAuthHeader(config, fetchImpl = fetch) {
2455
+ const token = await getPulseToken(config, fetchImpl);
2456
+ return token === null ? {} : { Authorization: `Bearer ${token}` };
2457
+ }
2458
+ async function pulseFetch(config, url, init, fetchImpl = fetch) {
2459
+ const send = async () => {
2460
+ const auth = await pulseAuthHeader(config, fetchImpl);
2461
+ const response = await fetchImpl(url, {
2462
+ ...init,
2463
+ headers: { ...init.headers, ...auth }
2464
+ });
2465
+ return { response, authenticated: auth.Authorization !== void 0 };
1948
2466
  };
1949
- return injectLookupOption(args, screeningLookup);
2467
+ const first = await send();
2468
+ if (first.response.status === 401 && first.authenticated) {
2469
+ clearPulseToken();
2470
+ return (await send()).response;
2471
+ }
2472
+ return first.response;
1950
2473
  }
1951
- function injectLookupOption(args, lookup) {
1952
- const first = args[0];
1953
- if (first && typeof first === "object" && !(first instanceof URL)) {
1954
- return [{ ...first, lookup }, ...args.slice(1)];
2474
+
2475
+ // src/protect/engine/pulse-client.js
2476
+ var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2477
+ var DEFAULT_CACHE_TTL2 = 3e5;
2478
+ var JITTER_FRACTION2 = 0.1;
2479
+ var PulseRuleClient = class {
2480
+ #siteUuid;
2481
+ #timeoutMs;
2482
+ #baseUrl;
2483
+ #cacheTtl;
2484
+ #cache = null;
2485
+ #cacheTime = null;
2486
+ #ttlEffective = 0;
2487
+ #etag;
2488
+ #pulseAuth;
2489
+ #detectionState;
2490
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
2491
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2492
+ this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2493
+ this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
2494
+ this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
2495
+ this.#etag = etag ?? null;
2496
+ this.#pulseAuth = pulseAuth ?? null;
2497
+ this.#detectionState = typeof detectionState === "string" ? detectionState : null;
2498
+ if (!this.#siteUuid) {
2499
+ throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
2500
+ }
1955
2501
  }
1956
- const rest = args.slice(1);
1957
- const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
1958
- if (optIdx !== -1) {
1959
- const next = [...rest];
1960
- next[optIdx] = { ...rest[optIdx], lookup };
1961
- return [first, ...next];
2502
+ async getRules() {
2503
+ const now = Date.now();
2504
+ if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
2505
+ return this.#cache;
2506
+ }
2507
+ const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
2508
+ try {
2509
+ const auth = await pulseAuthHeader(
2510
+ { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
2511
+ fetch
2512
+ );
2513
+ const headers = { Accept: "application/json", ...auth };
2514
+ if (this.#detectionState !== null && typeof auth.Authorization === "string") {
2515
+ headers["X-Patchstack-Detections"] = this.#detectionState;
2516
+ }
2517
+ if (this.#etag) headers["If-None-Match"] = this.#etag;
2518
+ const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2519
+ if (response.status === 304) {
2520
+ this.#touch(now);
2521
+ return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
2522
+ }
2523
+ if (!response.ok) {
2524
+ return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
2525
+ }
2526
+ const data = await response.json();
2527
+ if (!data || !Array.isArray(data.firewall)) {
2528
+ return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2529
+ }
2530
+ const result = {
2531
+ success: true,
2532
+ notModified: false,
2533
+ etag: response.headers?.get?.("etag") ?? null,
2534
+ firewall: data.firewall,
2535
+ whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
2536
+ whitelist_keys: data.whitelist_keys ?? {},
2537
+ ...enforcementField(data)
2538
+ };
2539
+ this.#cache = result;
2540
+ this.#etag = result.etag;
2541
+ this.#touch(now);
2542
+ return result;
2543
+ } catch (err) {
2544
+ return {
2545
+ success: false,
2546
+ error: err.name === "TimeoutError" ? "Request timed out" : err.message,
2547
+ firewall: [],
2548
+ whitelists: [],
2549
+ whitelist_keys: {}
2550
+ };
2551
+ }
1962
2552
  }
1963
- const cbIdx = rest.findIndex((a) => typeof a === "function");
1964
- if (cbIdx === -1) return [first, { lookup }, ...rest];
1965
- return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
2553
+ #touch(now) {
2554
+ this.#cacheTime = now;
2555
+ this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
2556
+ }
2557
+ clearCache() {
2558
+ this.#cache = null;
2559
+ this.#cacheTime = null;
2560
+ this.#etag = null;
2561
+ }
2562
+ };
2563
+ function enforcementField(data) {
2564
+ if (!data || typeof data !== "object") return {};
2565
+ const v = data.enforcement ?? data.mode;
2566
+ return v === "block" || v === "dry-run" ? { enforcement: v } : {};
1966
2567
  }
1967
2568
 
1968
- // src/protect/defaults.js
1969
- var DEFAULT_RESPONSE_RULES = [
1970
- {
1971
- id: "resp-private-key",
1972
- title: "Private key in response body",
1973
- phase: "response",
1974
- category: "secret-exposure",
1975
- action: "redact",
1976
- prefilter: ["PRIVATE KEY"],
1977
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
1978
- },
1979
- {
1980
- id: "resp-aws-access-key",
1981
- title: "AWS access key id in response body",
1982
- phase: "response",
1983
- category: "secret-exposure",
1984
- action: "redact",
1985
- prefilter: ["AKIA", "ASIA"],
1986
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
1987
- },
1988
- {
1989
- id: "resp-gcp-api-key",
1990
- title: "Google API key in response body",
1991
- phase: "response",
1992
- category: "secret-exposure",
1993
- action: "redact",
1994
- prefilter: ["AIza"],
1995
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
1996
- },
1997
- {
1998
- id: "resp-vendor-api-key",
1999
- title: "Vendor API key / token in response body",
2000
- phase: "response",
2001
- category: "secret-exposure",
2002
- action: "redact",
2003
- // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
2004
- // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
2005
- // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
2006
- // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
2007
- prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
2008
- 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])/" } }]
2009
- },
2010
- {
2011
- id: "resp-jwt",
2012
- title: "JWT in response body",
2013
- phase: "response",
2014
- category: "secret-exposure",
2015
- action: "redact",
2016
- prefilter: ["eyJ"],
2017
- 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/" } }]
2018
- },
2019
- {
2020
- id: "resp-db-connection-string",
2021
- title: "Database connection string with credentials in response body",
2022
- phase: "response",
2023
- category: "secret-exposure",
2024
- action: "redact",
2025
- prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
2026
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
2027
- },
2028
- {
2029
- id: "resp-stack-trace",
2030
- title: "Node stack trace leaking in response body",
2031
- phase: "response",
2032
- category: "info-exposure",
2033
- action: "redact",
2034
- // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
2035
- // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
2036
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
2037
- },
2038
- {
2039
- id: "resp-sql-error",
2040
- title: "SQL / ORM error disclosure in response body",
2041
- phase: "response",
2042
- category: "info-exposure",
2043
- action: "redact",
2044
- prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
2045
- 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" } }]
2046
- },
2047
- {
2048
- id: "resp-exception-trace",
2049
- title: "Backend exception / stack trace disclosure in response body",
2050
- phase: "response",
2051
- category: "info-exposure",
2052
- action: "redact",
2053
- // Multi-language exception/traceback signatures a normal API response never carries:
2054
- // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
2055
- // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
2056
- prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
2057
- 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+ \\[)/' } }]
2058
- }
2059
- ];
2060
- var DEFAULT_EGRESS_RULES = [
2061
- {
2062
- id: "egress-internal-address",
2063
- title: "Outbound request to an internal / metadata address (SSRF)",
2064
- phase: "egress",
2065
- category: "ssrf",
2066
- rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
2067
- }
2068
- ];
2069
-
2070
- // src/protect/block-page.js
2071
- function escapeHtml(s) {
2072
- return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2073
- }
2074
- function renderBlockPage({ url = "", code = "patchstack" } = {}) {
2075
- const safeUrl = escapeHtml(url);
2076
- const safeCode = escapeHtml(code || "patchstack");
2077
- return `<!DOCTYPE html>
2078
- <html lang="en">
2079
- <head>
2080
- <meta charset="UTF-8">
2081
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
2082
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
2083
- <style type="text/css">
2084
- .access-denied,.site-url-text{letter-spacing:.01em;color:#fff}body{background:#111216}.main-container{display:flex;justify-content:center;flex-direction:column;align-items:center}.container{width:688px;height:456px;background:#1c1e22;border-radius:16px;margin-top:120px}.content{height:330px;margin:64px}.access-denied{font-family:Verdana;font-style:normal;font-weight:600;font-size:24px;line-height:32px;display:flex;align-items:center}.descp-text,.site-url{display:flex;align-items:center}.site-url{margin-top:32px;width:560px;height:64px;background:#26282c;border-radius:4px}.reason-code-text,.site-url-text{font-family:Verdana;font-style:normal;border:none;background:#26282c}.site-url-text{text-overflow: ellipsis;font-weight:400;font-size:16px;line-height:24px;margin-left:16px;width:85%}.copy-icon{width:19px;height:22px;margin-left:20px}.copy-icon:hover{cursor:pointer}.reason-code-text{font-weight:600;font-size:24px;line-height:32px;letter-spacing:.01em;color:#AFE614;margin-left:16px;width:85%}.descp-text,.return-text{font-size:16px;line-height:24px;letter-spacing:.01em;font-weight:400}.return-text:hover{color: #AFE614}.descp-text,.logoText,.return-text,.secured-text{font-family:Verdana;font-style:normal;color:#fff}.descp{margin-top:32px;width:560px}.bottom-second-containers,.bottom-text-containers{align-items:center;display:flex}.bottom-container{margin-top:40px;display:flex;width:688px}.bottom-second-containers{margin-left:auto}.arrow-vector{margin-left:20px}.return-text{text-align:center;margin-left:12.59px;text-decoration:none}.secured-text{font-weight:400;font-size:11.2px;line-height:16px;display:flex;align-items:center;letter-spacing:.02em;margin-right:8px}.logoText{font-weight:600;font-size:16px;line-height:24px;letter-spacing:.01em;flex:none;order:1;flex-grow:0}.patch-logo{width:120px}.mobile-bottom-container{margin-top:40px;width:372px;display:none}.mobile-bottom-second-containers{margin-top:64px;display:flex;margin-left:16.5px}@media only screen and (max-width:900px){.container{background:#111216;width:370px;height:auto;margin-top:0}.content{margin-left:16.5px}.descp,.site-url{width:340px}.site-url-text{font-weight:400;font-size:14.4px;line-height:20px;letter-spacing:.02em;width:80%}.copy-icon{margin-left:20px}.reason-code-text{font-size:16px;line-height:32px;letter-spacing:.02em;margin-left:16px;width:80%}.descp-text,.return-text{font-size:14.4px;line-height:20px}.descp-text{display:flex;align-items:center;letter-spacing:.02em}.bottom-container{display:none}.mobile-bottom-container{display:block}.return-text{text-align:center;letter-spacing:.01em;color:#fff;margin-left:9.73px}}
2085
- </style>
2086
- <title>Access Denied</title>
2087
- </head>
2088
- <body>
2089
- <div class="main-container">
2090
- <div class="container">
2091
- <div class="content">
2092
- <span class="access-denied">Access Denied</span>
2093
- <div class="site-url">
2094
- <input id="url" class="site-url-text" type="text"
2095
- value="${safeUrl}" readonly>
2096
- <img class="copy-icon" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" alt="Copy Text" onmouseover="hover(this);" onmouseout="unhover(this);" onclick="copyText(id='url')">
2097
- </div>
2098
- <div class="site-url">
2099
- <input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
2100
- <img class="copy-icon" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" alt="Copy Text" onmouseover="hover(this);" onmouseout="unhover(this);" onclick="copyText(id='response')">
2101
- </div>
2102
- <div class="descp">
2103
- <span class="descp-text">This request has been blocked by Patchstack. If this message persists and you are a legitimate user, contact the site administrator with the reference above.</span>
2104
- </div>
2105
- </div>
2106
- </div>
2107
- <div class="bottom-container">
2108
- <div class="bottom-text-containers">
2109
- <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
2110
- <a href="/" class="return-text">Return to homepage</a>
2111
- </div>
2112
- <div class="bottom-second-containers">
2113
- <span class="secured-text">This website is secured by</span>
2114
- <a href="https://patchstack.com" class="logoText">
2115
- <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
2116
- </a>
2117
- </div>
2118
- </div>
2119
-
2120
- <div class="mobile-bottom-container">
2121
- <div class="mobile-bottom-text-containers">
2122
- <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
2123
- <a href="/" class="return-text">Return to homepage</a>
2124
- </div>
2125
- <div class="mobile-bottom-second-containers">
2126
- <span class="secured-text">This website is secured by</span>
2127
- <a href="https://patchstack.com" class="logoText">
2128
- <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
2129
- </a>
2130
- </div>
2131
- </div>
2132
- </div>
2133
- </body>
2134
- <script>
2135
- function copyText(id) {
2136
- var copyText = document.getElementById(id)
2137
- copyText.select();
2138
- copyText.setSelectionRange(0, 99999);
2139
- navigator.clipboard.writeText(copyText.value);
2140
- }
2141
-
2142
- function hover(element) {
2143
- element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
2144
- }
2145
-
2146
- function unhover(element) {
2147
- element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
2569
+ // src/protect/engine/fetch.js
2570
+ var MAX_BODY_BYTES = 1024 * 1024;
2571
+ async function fromFetchRequest(request, options = {}) {
2572
+ const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
2573
+ const url = new URL(request.url);
2574
+ const method = (request.method || "GET").toUpperCase();
2575
+ const headers = {};
2576
+ request.headers.forEach((value, key) => {
2577
+ headers[key.toLowerCase()] = value;
2578
+ });
2579
+ const query = {};
2580
+ for (const [key, value] of url.searchParams) {
2581
+ if (key in query) {
2582
+ query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2583
+ } else {
2584
+ query[key] = value;
2148
2585
  }
2149
- <\/script>
2150
- </html>`;
2151
- }
2152
-
2153
- // src/protect/rules/store.js
2154
- var fsMod;
2155
- async function loadFs() {
2156
- if (fsMod !== void 0) return fsMod;
2157
- try {
2158
- const [fs, path] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
2159
- fsMod = {
2160
- readFileSync: fs.readFileSync,
2161
- writeFileSync: fs.writeFileSync,
2162
- mkdirSync: fs.mkdirSync,
2163
- join: path.join
2164
- };
2165
- } catch {
2166
- fsMod = null;
2167
2586
  }
2168
- return fsMod;
2169
- }
2170
- function makeStore(options = {}) {
2171
- let mem = null;
2172
- const durable = durableTier(options);
2173
- return {
2174
- async read() {
2175
- if (mem) return mem;
2176
- const env = await durable.read();
2177
- if (env) mem = env;
2178
- return env;
2179
- },
2180
- async write(env) {
2181
- if (env) mem = env;
2182
- await durable.write(env);
2183
- }
2184
- };
2185
- }
2186
- function durableTier(options) {
2187
- const adapter = options.ruleCache;
2188
- if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
2189
- return {
2190
- read: async () => {
2191
- try {
2192
- return toEnvelope(await adapter.read());
2193
- } catch {
2194
- return null;
2195
- }
2196
- },
2197
- write: async (env) => {
2198
- try {
2199
- await adapter.write(env);
2200
- } catch {
2201
- }
2202
- }
2203
- };
2587
+ let rawBody = "";
2588
+ if (method !== "GET" && method !== "HEAD") {
2589
+ rawBody = await readCappedText(request, maxBodyBytes);
2204
2590
  }
2205
- const dir = options.cacheDir;
2591
+ const contentType = headers["content-type"] || "";
2592
+ let body = {};
2593
+ let files;
2594
+ if (rawBody) {
2595
+ const parsed = parseBody(rawBody, contentType);
2596
+ body = parsed.body;
2597
+ files = parsed.files;
2598
+ }
2599
+ const uri = url.pathname + url.search;
2600
+ const client = resolveClientIp({ peer: options.peer, headers, trustedProxy: options.trustedProxy });
2206
2601
  return {
2207
- read: async () => cacheRead(dir),
2208
- write: async (env) => cacheWrite(dir, env)
2602
+ method,
2603
+ url: uri,
2604
+ originalUrl: uri,
2605
+ query,
2606
+ body,
2607
+ files,
2608
+ headers,
2609
+ // The resolved address, as an own property. Nothing downstream re-derives or falls back: a consumer
2610
+ // that guessed differently would attribute one request to two addresses.
2611
+ ip: client.ip ?? "",
2612
+ _clientIp: client,
2613
+ cookies: parseCookies(headers.cookie),
2614
+ // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
2615
+ // drops, so prototype-pollution rules on `raw` are robust.
2616
+ _rawBody: rawBody
2209
2617
  };
2210
2618
  }
2211
- async function cacheWrite(dir, env) {
2212
- if (!dir) return;
2213
- const fs = await loadFs();
2214
- if (!fs) return;
2619
+ async function readCappedText(request, max) {
2620
+ let clone;
2215
2621
  try {
2216
- fs.mkdirSync(dir, { recursive: true });
2217
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
2622
+ clone = request.clone();
2218
2623
  } catch {
2624
+ return "";
2625
+ }
2626
+ const body = clone.body;
2627
+ if (body && typeof body.getReader === "function") {
2628
+ const reader = body.getReader();
2629
+ const chunks = [];
2630
+ let buffered = 0;
2631
+ try {
2632
+ for (; ; ) {
2633
+ const { done, value } = await reader.read();
2634
+ if (done) break;
2635
+ if (!value || buffered >= max) continue;
2636
+ const take = Math.min(value.byteLength, max - buffered);
2637
+ chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2638
+ buffered += take;
2639
+ }
2640
+ } catch {
2641
+ return "";
2642
+ }
2643
+ try {
2644
+ return new TextDecoder().decode(concatChunks(chunks, buffered));
2645
+ } catch {
2646
+ return "";
2647
+ }
2219
2648
  }
2220
- }
2221
- async function cacheRead(dir) {
2222
- if (!dir) return null;
2223
- const fs = await loadFs();
2224
- if (!fs) return null;
2225
2649
  try {
2226
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
2650
+ const text = await clone.text();
2651
+ return text.length > max ? text.slice(0, max) : text;
2227
2652
  } catch {
2228
- return null;
2653
+ return "";
2229
2654
  }
2230
2655
  }
2231
- function toEnvelope(value) {
2232
- if (!value || typeof value !== "object") return null;
2233
- if (value.bundle && typeof value.bundle === "object") {
2234
- return { bundle: value.bundle, etag: value.etag ?? null };
2235
- }
2236
- if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
2237
- return { bundle: value, etag: null };
2656
+ function concatChunks(chunks, total) {
2657
+ const out = new Uint8Array(total);
2658
+ let offset = 0;
2659
+ for (const c of chunks) {
2660
+ out.set(c, offset);
2661
+ offset += c.byteLength;
2238
2662
  }
2239
- return null;
2663
+ return out;
2240
2664
  }
2241
-
2242
- // src/protect/rules/contract.js
2243
- var SOURCES = Object.freeze({
2244
- raw: Object.freeze({ keyed: false }),
2245
- all: Object.freeze({ keyed: false }),
2246
- false: Object.freeze({ keyed: false }),
2247
- rules: Object.freeze({ keyed: false, group: true }),
2248
- get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2249
- post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2250
- request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2251
- cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2252
- // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
2253
- // of the upload. Optional, not required — filename-scoped rules use the bare form.
2254
- files: Object.freeze({
2255
- keyed: true,
2256
- keys: "any",
2257
- wildcard: true,
2258
- optional_key_suffixes: Object.freeze(["content", "filename", "type"])
2259
- }),
2260
- server: Object.freeze({
2261
- keyed: true,
2262
- keys: Object.freeze([
2263
- "REQUEST_URI",
2264
- "REQUEST_METHOD",
2265
- "HTTP_USER_AGENT",
2266
- "HTTP_REFERER",
2267
- "HTTP_HOST",
2268
- "REMOTE_ADDR",
2269
- "ip",
2270
- "CONTENT_TYPE",
2271
- "CONTENT_LENGTH"
2272
- ]),
2273
- key_prefixes: Object.freeze(["HTTP_"])
2274
- }),
2275
- response: Object.freeze({
2276
- keyed: true,
2277
- keys: Object.freeze(["status", "body", "headers"]),
2278
- key_prefixes: Object.freeze(["header."])
2279
- }),
2280
- egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
2281
- });
2282
- var MATCH_TYPES = Object.freeze([
2283
- "array_in_array",
2284
- "array_key_value",
2285
- "contains",
2286
- "cors_reflected",
2287
- "cross_origin",
2288
- "ctype_alnum",
2289
- "ctype_digit",
2290
- "ctype_special",
2291
- "equals",
2292
- "equals_strict",
2293
- "hostname",
2294
- "in_array",
2295
- "inline_js_xss",
2296
- "inline_xss",
2297
- "internal_host",
2298
- "is_numeric",
2299
- "isset",
2300
- "less_than",
2301
- "more_than",
2302
- "not_contains",
2303
- "not_in_array",
2304
- "off_origin",
2305
- "quotes",
2306
- "regex",
2307
- "stripos"
2308
- ]);
2309
- var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
2310
- var OPERANDLESS_MATCH_TYPES = Object.freeze([
2311
- "isset",
2312
- "quotes",
2313
- "is_numeric",
2314
- "ctype_alnum",
2315
- "ctype_digit",
2316
- "ctype_special",
2317
- "inline_xss",
2318
- "inline_js_xss",
2319
- "internal_host",
2320
- "cross_origin",
2321
- "off_origin",
2322
- "cors_reflected"
2323
- ]);
2324
- var OPERAND_SHAPES = Object.freeze([
2325
- "non-empty-string",
2326
- "regex-literal",
2327
- "string-or-string-list",
2328
- "scalar",
2329
- "string",
2330
- "numeric",
2331
- "scalar-or-non-empty-list",
2332
- "non-empty-list",
2333
- "object",
2334
- "non-empty-object",
2335
- "non-empty-string-list",
2336
- "header-name-map",
2337
- "header-name-list",
2338
- "cookie-flags"
2339
- ]);
2340
- var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
2341
- var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
2342
- var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2343
- var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
2344
- var MATCH_OPERANDS = Object.freeze({
2345
- array_key_value: Object.freeze({
2346
- required: Object.freeze(["key", "match"]),
2347
- shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2348
- }),
2349
- regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2350
- contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2351
- stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2352
- not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2353
- // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
2354
- // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
2355
- // on those. The other three are the list-membership family: an empty list makes `in_array` and
2356
- // `array_in_array` inert and `not_in_array` true for every value.
2357
- hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2358
- in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2359
- not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2360
- array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
2361
- // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
2362
- // subject is always a string by the time it is compared — so a number operand is unconditionally
2363
- // inert, which is the trap the engine's own note about `'1' !== 1` describes.
2364
- equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
2365
- equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
2366
- less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
2367
- more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
2368
- });
2369
- function operandShapeProblem(shape, value) {
2370
- const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
2371
- const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2372
- const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2373
- switch (shape) {
2374
- case "non-empty-string":
2375
- return isFilledString(value) ? null : "must be a non-empty string";
2376
- case "regex-literal":
2377
- if (!isFilledString(value)) return "must be a non-empty string";
2378
- return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
2379
- case "string-or-string-list":
2380
- if (isFilledString(value)) return null;
2381
- if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
2382
- return "must be a non-empty string, or a non-empty list of them";
2383
- case "scalar":
2384
- return isScalar(value) ? null : "must be a string, number or boolean";
2385
- case "string":
2386
- return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
2387
- case "numeric":
2388
- return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
2389
- case "scalar-or-non-empty-list":
2390
- if (isScalar(value)) return null;
2391
- if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
2392
- return "must be a scalar, or a non-empty list of them";
2393
- case "non-empty-list":
2394
- return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
2395
- case "header-name-map": {
2396
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2397
- const names = Object.keys(value);
2398
- if (names.length === 0) return "must be an object with at least one entry";
2399
- const bad = names.find((name) => !isHeaderName(name));
2400
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2665
+ function parseBody(rawBody, contentType) {
2666
+ const ct = String(contentType || "").toLowerCase();
2667
+ const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
2668
+ const isForm = ct.includes("application/x-www-form-urlencoded");
2669
+ const isMultipart = ct.includes("multipart/form-data");
2670
+ const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
2671
+ if (isMultipart) {
2672
+ const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
2673
+ if (boundary) return parseMultipart(rawBody, boundary);
2674
+ return { body: {}, files: void 0 };
2675
+ }
2676
+ if (isForm) {
2677
+ const body = {};
2678
+ for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
2679
+ return { body, files: void 0 };
2680
+ }
2681
+ if (isJson || isAmbiguous) {
2682
+ try {
2683
+ const parsed = JSON.parse(rawBody);
2684
+ if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
2685
+ } catch {
2401
2686
  }
2402
- case "header-name-list": {
2403
- if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
2404
- const bad = value.find((name) => !isHeaderName(name));
2405
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2687
+ }
2688
+ return { body: {}, files: void 0 };
2689
+ }
2690
+ function parseMultipart(rawBody, boundary) {
2691
+ const body = {};
2692
+ const files = {};
2693
+ for (const part of rawBody.split("--" + boundary)) {
2694
+ const sep = /\r?\n\r?\n/.exec(part);
2695
+ if (!sep) continue;
2696
+ const rawHeaders = part.slice(0, sep.index);
2697
+ const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
2698
+ if (!disposition) continue;
2699
+ const name = /name="([^"]*)"/i.exec(disposition)?.[1];
2700
+ if (name == null) continue;
2701
+ const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
2702
+ const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
2703
+ if (filename !== void 0) {
2704
+ const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2705
+ const file = { filename, type: partType, content };
2706
+ files[name] = name in files ? [].concat(files[name], file) : file;
2707
+ } else {
2708
+ body[name] = name in body ? [].concat(body[name], content) : content;
2406
2709
  }
2407
- case "cookie-flags": {
2408
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2409
- const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
2410
- if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
2411
- for (const flag of ["httpOnly", "secure"]) {
2412
- if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
2413
- return `flag "${flag}" must be true or false`;
2414
- }
2415
- }
2416
- const sameSite = value.sameSite;
2417
- const sameSiteOff = sameSite === false;
2418
- if (sameSite !== void 0 && !sameSiteOff) {
2419
- if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
2420
- return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
2421
- }
2422
- }
2423
- if (value.httpOnly === false && value.secure === false && sameSiteOff) {
2424
- return "turns off every flag, so it would harden nothing";
2425
- }
2426
- return null;
2710
+ }
2711
+ return { body, files };
2712
+ }
2713
+ function parseCookies(header) {
2714
+ const cookies = {};
2715
+ if (!header) {
2716
+ return cookies;
2717
+ }
2718
+ for (const pair of header.split(";")) {
2719
+ const idx = pair.indexOf("=");
2720
+ if (idx === -1) {
2721
+ continue;
2427
2722
  }
2428
- case "object":
2429
- return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
2430
- case "non-empty-object":
2431
- return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
2432
- case "non-empty-string-list":
2433
- return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
2434
- default:
2435
- return null;
2723
+ cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
2436
2724
  }
2725
+ return cookies;
2437
2726
  }
2438
- var MUTATIONS = Object.freeze([
2439
- "base64_decode",
2440
- "getArrayValues",
2441
- "htmlentitydecode",
2442
- "intval",
2443
- "json_decode",
2444
- "json_encode",
2445
- "urldecode"
2446
- ]);
2447
- var PHASES = Object.freeze(["request", "response", "egress"]);
2448
- var ACTIONS = Object.freeze([
2449
- "block",
2450
- "redact",
2451
- "encode",
2452
- "set-header",
2453
- "remove-header",
2454
- "harden-cookie"
2455
- ]);
2456
- var WHEN_KEYS = Object.freeze(["path", "method"]);
2457
- var RULE_PROPERTIES = Object.freeze([
2458
- "id",
2459
- "rule_id",
2460
- "title",
2461
- "category",
2462
- "phase",
2463
- "action",
2464
- "rule_v2",
2465
- "when",
2466
- "message",
2467
- "enforcement",
2468
- "source_revision",
2469
- "prefilter",
2470
- "max_bytes",
2471
- "bypass_limit",
2472
- "set_headers",
2473
- "remove_headers",
2474
- "cookie_flags",
2475
- "ensure"
2476
- ]);
2477
- var ACTION_PROPERTIES = Object.freeze({
2478
- block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
2479
- redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2480
- encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2481
- "set-header": Object.freeze({
2482
- required: Object.freeze(["set_headers"]),
2483
- defaulted: Object.freeze(["ensure"]),
2484
- phases: Object.freeze(["response"]),
2485
- shapes: Object.freeze({ set_headers: "header-name-map" })
2486
- }),
2487
- "remove-header": Object.freeze({
2488
- required: Object.freeze(["remove_headers"]),
2489
- defaulted: Object.freeze([]),
2490
- phases: Object.freeze(["response"]),
2491
- shapes: Object.freeze({ remove_headers: "header-name-list" })
2492
- }),
2493
- "harden-cookie": Object.freeze({
2494
- required: Object.freeze([]),
2495
- defaulted: Object.freeze(["cookie_flags"]),
2496
- phases: Object.freeze(["response"]),
2497
- shapes: Object.freeze({ cookie_flags: "cookie-flags" })
2498
- })
2499
- });
2500
- var LIMITS = Object.freeze({
2501
- maxRules: 5e3,
2502
- maxWhitelists: 2e3,
2503
- maxConditionsPerRule: 250,
2504
- maxNestingDepth: 12,
2505
- maxRegexLength: 1e3,
2506
- maxValueLength: 8192
2507
- });
2508
- var GROUP_PARAMETER = "rules";
2509
- function isGroup(condition) {
2510
- return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
2727
+
2728
+ // src/protect/engine/node.js
2729
+ function fromNodeRequest(req, rawBody = "", options = {}) {
2730
+ const method = (req.method || "GET").toUpperCase();
2731
+ const headers = {};
2732
+ for (const [key, value] of Object.entries(req.headers || {})) {
2733
+ headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
2734
+ }
2735
+ const host = headers.host || "localhost";
2736
+ let url;
2737
+ try {
2738
+ url = new URL(req.url || "/", `http://${host}`);
2739
+ } catch {
2740
+ try {
2741
+ url = new URL(req.url || "/", "http://localhost");
2742
+ } catch {
2743
+ url = new URL("http://localhost/");
2744
+ }
2745
+ }
2746
+ const query = {};
2747
+ for (const [key, value] of url.searchParams) {
2748
+ if (key in query) {
2749
+ query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2750
+ } else {
2751
+ query[key] = value;
2752
+ }
2753
+ }
2754
+ const contentType = headers["content-type"] || "";
2755
+ let body = {};
2756
+ let files;
2757
+ if (rawBody) {
2758
+ const parsed = parseBody(rawBody, contentType);
2759
+ body = parsed.body;
2760
+ files = parsed.files;
2761
+ }
2762
+ const uri = url.pathname + url.search;
2763
+ const client = resolveClientIp({
2764
+ peer: req.socket?.remoteAddress,
2765
+ headers,
2766
+ trustedProxy: options.trustedProxy
2767
+ });
2768
+ return {
2769
+ method,
2770
+ url: uri,
2771
+ originalUrl: uri,
2772
+ query,
2773
+ body,
2774
+ files,
2775
+ headers,
2776
+ ip: client.ip ?? "",
2777
+ _clientIp: client,
2778
+ cookies: parseCookies2(headers.cookie),
2779
+ // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
2780
+ _rawBody: rawBody
2781
+ };
2511
2782
  }
2512
- function conditionShapeProblem(condition) {
2513
- if (!condition || typeof condition !== "object") return "condition is not an object";
2514
- const named = condition.parameter === GROUP_PARAMETER;
2515
- const nested = Array.isArray(condition.rules);
2516
- if (named && !nested) {
2517
- return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
2783
+ function parseCookies2(header) {
2784
+ const cookies = {};
2785
+ if (!header) {
2786
+ return cookies;
2518
2787
  }
2519
- if (nested && !named) {
2520
- return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2788
+ for (const pair of header.split(";")) {
2789
+ const idx = pair.indexOf("=");
2790
+ if (idx === -1) {
2791
+ continue;
2792
+ }
2793
+ cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
2794
+ }
2795
+ return cookies;
2796
+ }
2797
+
2798
+ // src/protect/egress.js
2799
+ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
2800
+ const restores = [];
2801
+ if (typeof shouldBlock !== "function") return () => {
2802
+ };
2803
+ const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
2804
+ const block = (url, host, method) => {
2805
+ if (!shouldBlock(url, host, method)) return false;
2806
+ notify(onBlock, { url, host, method }, "onEgressBlock");
2807
+ return true;
2808
+ };
2809
+ let screen = null;
2810
+ if (dnsScreen) {
2811
+ try {
2812
+ const resolveLookup = lookup ?? (await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))).lookup;
2813
+ const { isIP } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1));
2814
+ if (typeof resolveLookup === "function" && typeof isIP === "function") {
2815
+ screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
2816
+ }
2817
+ } catch {
2818
+ screen = null;
2819
+ }
2820
+ }
2821
+ const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
2822
+ const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
2823
+ if (!screen) {
2824
+ if (host && dnsScreen) skip("resolver-unavailable", { host });
2825
+ return resolve(false);
2826
+ }
2827
+ if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
2828
+ try {
2829
+ screen.lookup(host, { all: true }, (err, addresses) => {
2830
+ if (err || !Array.isArray(addresses)) {
2831
+ skip("resolver-failed", { host });
2832
+ return resolve(false);
2833
+ }
2834
+ for (const a of addresses) {
2835
+ const ip = a && typeof a === "object" ? a.address : a;
2836
+ if (ip && block(url, ip, method)) return resolve(true);
2837
+ }
2838
+ resolve(false);
2839
+ });
2840
+ } catch {
2841
+ skip("resolver-failed", { host });
2842
+ resolve(false);
2843
+ }
2844
+ });
2845
+ const originalFetch = globalThis.fetch;
2846
+ if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2847
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
2848
+ const MAX_REDIRECTS = 20;
2849
+ const screenUrl = async (u, method) => {
2850
+ let host = null;
2851
+ try {
2852
+ host = new URL(u).hostname;
2853
+ } catch {
2854
+ host = null;
2855
+ }
2856
+ if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
2857
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
2858
+ }
2859
+ };
2860
+ const guarded = async (input, init) => {
2861
+ let cur;
2862
+ try {
2863
+ cur = new Request(input, { ...init || {}, redirect: "manual" });
2864
+ } catch {
2865
+ return originalFetch(input, init);
2866
+ }
2867
+ const callerRedirect = init && init.redirect || input && input.redirect || "follow";
2868
+ let url = cur.url;
2869
+ let method = cur.method;
2870
+ await screenUrl(url, method);
2871
+ if (callerRedirect !== "follow") return originalFetch(input, init);
2872
+ const headers = new Headers(cur.headers);
2873
+ const signal = cur.signal;
2874
+ let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
2875
+ for (let hop = 0; ; hop++) {
2876
+ const resp = await originalFetch(
2877
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
2878
+ );
2879
+ const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
2880
+ if (!location) return resp;
2881
+ if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
2882
+ const next = new URL(location, url).href;
2883
+ if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
2884
+ method = "GET";
2885
+ body = void 0;
2886
+ headers.delete("content-type");
2887
+ headers.delete("content-length");
2888
+ }
2889
+ if (new URL(next).origin !== new URL(url).origin) {
2890
+ headers.delete("authorization");
2891
+ headers.delete("cookie");
2892
+ }
2893
+ await screenUrl(next, method);
2894
+ url = next;
2895
+ }
2896
+ };
2897
+ guarded.__patchstackGuarded = true;
2898
+ globalThis.fetch = guarded;
2899
+ restores.push(() => {
2900
+ if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
2901
+ });
2521
2902
  }
2522
- if (named && nested) {
2523
- if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2524
- if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
2525
- if (condition.rules.length === 0) return "group has no conditions (would never match)";
2903
+ for (const moduleName of ["node:http", "node:https"]) {
2904
+ try {
2905
+ const mod = await import(moduleName);
2906
+ const restore = patchHttpModule(mod.default ?? mod, block, screen);
2907
+ if (restore) restores.push(restore);
2908
+ } catch {
2909
+ }
2526
2910
  }
2527
- return null;
2911
+ return () => {
2912
+ for (const restore of restores) {
2913
+ try {
2914
+ restore();
2915
+ } catch {
2916
+ }
2917
+ }
2918
+ };
2528
2919
  }
2529
- function parameterProblem(parameter) {
2530
- if (parameter === void 0 || parameter === null) return null;
2531
- if (Array.isArray(parameter)) {
2532
- if (parameter.length === 0) return "parameter list is empty";
2533
- for (const member of parameter) {
2534
- const problem = parameterProblem(member);
2535
- if (problem !== null) return problem;
2920
+ function patchHttpModule(http, block, screen) {
2921
+ if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
2922
+ const originalRequest = http.request;
2923
+ const originalGet = http.get;
2924
+ const wrap = (original) => function(...args) {
2925
+ const target = extractHttpTarget(args);
2926
+ if (target && block(target.url, target.host, target.method)) {
2927
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
2536
2928
  }
2537
- return null;
2929
+ if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
2930
+ try {
2931
+ args = withScreeningLookup(args, target, block, screen.lookup);
2932
+ } catch {
2933
+ }
2934
+ }
2935
+ return original.apply(this, args);
2936
+ };
2937
+ const guardedRequest = wrap(originalRequest);
2938
+ http.request = guardedRequest;
2939
+ let guardedGet;
2940
+ if (typeof originalGet === "function") {
2941
+ guardedGet = wrap(originalGet);
2942
+ http.get = guardedGet;
2538
2943
  }
2539
- if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2540
- if (parameter === GROUP_PARAMETER) {
2541
- return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
2944
+ http.__patchstackGuarded = true;
2945
+ return () => {
2946
+ if (http.request === guardedRequest) http.request = originalRequest;
2947
+ if (guardedGet && http.get === guardedGet) http.get = originalGet;
2948
+ delete http.__patchstackGuarded;
2949
+ };
2950
+ }
2951
+ function extractHttpTarget(args) {
2952
+ const first = args[0];
2953
+ try {
2954
+ if (typeof first === "string" || first instanceof URL) {
2955
+ const url = new URL(String(first));
2956
+ const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
2957
+ return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
2958
+ }
2959
+ if (first && typeof first === "object") {
2960
+ const host = normalizeHost(first.hostname || first.host) || "localhost";
2961
+ const protocol = first.protocol || "http:";
2962
+ const port = first.port ? `:${first.port}` : "";
2963
+ const path = first.path || "/";
2964
+ return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
2965
+ }
2966
+ } catch {
2542
2967
  }
2543
- const dot = parameter.indexOf(".");
2544
- const name = dot === -1 ? parameter : parameter.slice(0, dot);
2545
- const source = SOURCES[name];
2546
- if (source === void 0) return `unknown parameter source "${name}"`;
2547
- if (dot === -1) {
2548
- return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
2968
+ return null;
2969
+ }
2970
+ function normalizeHost(raw) {
2971
+ const host = String(raw || "").trim();
2972
+ const bracketed = /^\[([^\]]+)\]/.exec(host);
2973
+ if (bracketed) return bracketed[1];
2974
+ if ((host.match(/:/g) || []).length > 1) return host;
2975
+ return host.split(":")[0];
2976
+ }
2977
+ function screenResolved(addresses, target, block) {
2978
+ for (const a of addresses || []) {
2979
+ const ip = a && typeof a === "object" ? a.address : a;
2980
+ if (ip && block(target.url, ip, target.method)) return ip;
2549
2981
  }
2550
- if (source.keyed !== true) return `source "${name}" takes no key`;
2551
- const key = parameter.slice(dot + 1);
2552
- if (key === "") return `source "${name}" needs a key after the dot`;
2553
- return keyProblem(name, source, key);
2982
+ return null;
2554
2983
  }
2555
- function keyProblem(name, source, key) {
2556
- if (key.endsWith("*") && source.wildcard !== true) {
2557
- return `"${name}" does not fan out over a wildcard key`;
2984
+ function withScreeningLookup(args, target, block, lookup) {
2985
+ const screeningLookup = (hostname, options, callback) => {
2986
+ let opts = options;
2987
+ let cb = callback;
2988
+ if (typeof opts === "function") {
2989
+ cb = opts;
2990
+ opts = {};
2991
+ }
2992
+ if (!opts || typeof opts !== "object") opts = {};
2993
+ try {
2994
+ lookup(hostname, { ...opts, all: true }, (err, addresses) => {
2995
+ if (err) return cb(err);
2996
+ const list = Array.isArray(addresses) ? addresses : [];
2997
+ const blocked = screenResolved(list, target, block);
2998
+ if (blocked) {
2999
+ return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
3000
+ }
3001
+ if (opts.all) return cb(null, list);
3002
+ const first = list[0];
3003
+ if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
3004
+ return cb(null, first.address, first.family);
3005
+ });
3006
+ } catch {
3007
+ try {
3008
+ lookup(hostname, opts, cb);
3009
+ } catch {
3010
+ cb(new Error(`Patchstack: lookup failed for ${hostname}`));
3011
+ }
3012
+ }
3013
+ };
3014
+ return injectLookupOption(args, screeningLookup);
3015
+ }
3016
+ function injectLookupOption(args, lookup) {
3017
+ const first = args[0];
3018
+ if (first && typeof first === "object" && !(first instanceof URL)) {
3019
+ return [{ ...first, lookup }, ...args.slice(1)];
2558
3020
  }
2559
- const bare = key.endsWith("*") ? key.slice(0, -1) : key;
2560
- if (source.keys === "any") return null;
2561
- if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
2562
- for (const prefix of source.key_prefixes ?? []) {
2563
- if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
3021
+ const rest = args.slice(1);
3022
+ const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
3023
+ if (optIdx !== -1) {
3024
+ const next = [...rest];
3025
+ next[optIdx] = { ...rest[optIdx], lookup };
3026
+ return [first, ...next];
2564
3027
  }
2565
- const accepted = [
2566
- ...Array.isArray(source.keys) ? source.keys : [],
2567
- ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
2568
- ].join(", ");
2569
- return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
3028
+ const cbIdx = rest.findIndex((a) => typeof a === "function");
3029
+ if (cbIdx === -1) return [first, { lookup }, ...rest];
3030
+ return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
2570
3031
  }
2571
- function matchProblem(match, parameter) {
2572
- if (!match || typeof match !== "object") return "condition has no match object";
2573
- if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2574
- if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2575
- const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2576
- if (!parameterless && (parameter === void 0 || parameter === null)) {
2577
- return `match type "${match.type}" needs a parameter`;
3032
+
3033
+ // src/protect/defaults.js
3034
+ var DEFAULT_RESPONSE_RULES = [
3035
+ {
3036
+ id: "resp-private-key",
3037
+ title: "Private key in response body",
3038
+ phase: "response",
3039
+ category: "secret-exposure",
3040
+ action: "redact",
3041
+ prefilter: ["PRIVATE KEY"],
3042
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
3043
+ },
3044
+ {
3045
+ id: "resp-aws-access-key",
3046
+ title: "AWS access key id in response body",
3047
+ phase: "response",
3048
+ category: "secret-exposure",
3049
+ action: "redact",
3050
+ prefilter: ["AKIA", "ASIA"],
3051
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
3052
+ },
3053
+ {
3054
+ id: "resp-gcp-api-key",
3055
+ title: "Google API key in response body",
3056
+ phase: "response",
3057
+ category: "secret-exposure",
3058
+ action: "redact",
3059
+ prefilter: ["AIza"],
3060
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
3061
+ },
3062
+ {
3063
+ id: "resp-vendor-api-key",
3064
+ title: "Vendor API key / token in response body",
3065
+ phase: "response",
3066
+ category: "secret-exposure",
3067
+ action: "redact",
3068
+ // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
3069
+ // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
3070
+ // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
3071
+ // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
3072
+ prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
3073
+ 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])/" } }]
3074
+ },
3075
+ {
3076
+ id: "resp-supabase-secret-key",
3077
+ title: "Supabase secret key in response body",
3078
+ phase: "response",
3079
+ category: "secret-exposure",
3080
+ action: "redact",
3081
+ // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
3082
+ // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
3083
+ // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
3084
+ // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
3085
+ // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
3086
+ //
3087
+ // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
3088
+ // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
3089
+ // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
3090
+ // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
3091
+ // characters, which is not this format.
3092
+ prefilter: ["sb_secret_"],
3093
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bsb_secret_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{8}(?![A-Za-z0-9_-])/" } }]
3094
+ },
3095
+ {
3096
+ id: "resp-supabase-service-role-key",
3097
+ title: "Supabase service_role key in response body",
3098
+ phase: "response",
3099
+ category: "secret-exposure",
3100
+ action: "redact",
3101
+ // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
3102
+ // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
3103
+ // by design and a login response carries the user's own access token — so this decides on the
3104
+ // decoded payload rather than on the shape of the token.
3105
+ //
3106
+ // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
3107
+ // tokens and serve the rest of the response.
3108
+ //
3109
+ // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
3110
+ // an opt-in, observe-only rule rather than an on-by-default mask.
3111
+ prefilter: ["eyJ"],
3112
+ rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
3113
+ },
3114
+ {
3115
+ id: "resp-db-connection-string",
3116
+ title: "Database connection string with credentials in response body",
3117
+ phase: "response",
3118
+ category: "secret-exposure",
3119
+ action: "redact",
3120
+ prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
3121
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
3122
+ },
3123
+ {
3124
+ id: "resp-stack-trace",
3125
+ title: "Node stack trace leaking in response body",
3126
+ phase: "response",
3127
+ category: "info-exposure",
3128
+ action: "redact",
3129
+ // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
3130
+ // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
3131
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3132
+ },
3133
+ {
3134
+ id: "resp-sql-error",
3135
+ title: "SQL / ORM error disclosure in response body",
3136
+ phase: "response",
3137
+ category: "info-exposure",
3138
+ action: "redact",
3139
+ prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
3140
+ 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" } }]
3141
+ },
3142
+ {
3143
+ id: "resp-exception-trace",
3144
+ title: "Backend exception / stack trace disclosure in response body",
3145
+ phase: "response",
3146
+ category: "info-exposure",
3147
+ action: "redact",
3148
+ // Multi-language exception/traceback signatures a normal API response never carries:
3149
+ // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
3150
+ // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
3151
+ prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
3152
+ 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+ \\[)/' } }]
2578
3153
  }
2579
- if (parameterless && parameter !== void 0 && parameter !== null) {
2580
- return `match type "${match.type}" reads the whole request and takes no parameter`;
3154
+ ];
3155
+ var DEFAULT_EGRESS_RULES = [
3156
+ {
3157
+ id: "egress-internal-address",
3158
+ title: "Outbound request to an internal / metadata address (SSRF)",
3159
+ phase: "egress",
3160
+ category: "ssrf",
3161
+ rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
2581
3162
  }
2582
- const operands = MATCH_OPERANDS[match.type];
2583
- if (operands) {
2584
- for (const required of operands.required) {
2585
- if (match[required] === void 0 || match[required] === null) {
2586
- return `match type "${match.type}" needs "${required}"`;
2587
- }
3163
+ ];
3164
+
3165
+ // src/protect/block-page.js
3166
+ function escapeHtml(s) {
3167
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3168
+ }
3169
+ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
3170
+ const safeUrl = escapeHtml(url);
3171
+ const safeCode = escapeHtml(code || "patchstack");
3172
+ return `<!DOCTYPE html>
3173
+ <html lang="en">
3174
+ <head>
3175
+ <meta charset="UTF-8">
3176
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
3177
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3178
+ <style type="text/css">
3179
+ .access-denied,.site-url-text{letter-spacing:.01em;color:#fff}body{background:#111216}.main-container{display:flex;justify-content:center;flex-direction:column;align-items:center}.container{width:688px;height:456px;background:#1c1e22;border-radius:16px;margin-top:120px}.content{height:330px;margin:64px}.access-denied{font-family:Verdana;font-style:normal;font-weight:600;font-size:24px;line-height:32px;display:flex;align-items:center}.descp-text,.site-url{display:flex;align-items:center}.site-url{margin-top:32px;width:560px;height:64px;background:#26282c;border-radius:4px}.reason-code-text,.site-url-text{font-family:Verdana;font-style:normal;border:none;background:#26282c}.site-url-text{text-overflow: ellipsis;font-weight:400;font-size:16px;line-height:24px;margin-left:16px;width:85%}.copy-icon{width:19px;height:22px;margin-left:20px}.copy-icon:hover{cursor:pointer}.reason-code-text{font-weight:600;font-size:24px;line-height:32px;letter-spacing:.01em;color:#AFE614;margin-left:16px;width:85%}.descp-text,.return-text{font-size:16px;line-height:24px;letter-spacing:.01em;font-weight:400}.return-text:hover{color: #AFE614}.descp-text,.logoText,.return-text,.secured-text{font-family:Verdana;font-style:normal;color:#fff}.descp{margin-top:32px;width:560px}.bottom-second-containers,.bottom-text-containers{align-items:center;display:flex}.bottom-container{margin-top:40px;display:flex;width:688px}.bottom-second-containers{margin-left:auto}.arrow-vector{margin-left:20px}.return-text{text-align:center;margin-left:12.59px;text-decoration:none}.secured-text{font-weight:400;font-size:11.2px;line-height:16px;display:flex;align-items:center;letter-spacing:.02em;margin-right:8px}.logoText{font-weight:600;font-size:16px;line-height:24px;letter-spacing:.01em;flex:none;order:1;flex-grow:0}.patch-logo{width:120px}.mobile-bottom-container{margin-top:40px;width:372px;display:none}.mobile-bottom-second-containers{margin-top:64px;display:flex;margin-left:16.5px}@media only screen and (max-width:900px){.container{background:#111216;width:370px;height:auto;margin-top:0}.content{margin-left:16.5px}.descp,.site-url{width:340px}.site-url-text{font-weight:400;font-size:14.4px;line-height:20px;letter-spacing:.02em;width:80%}.copy-icon{margin-left:20px}.reason-code-text{font-size:16px;line-height:32px;letter-spacing:.02em;margin-left:16px;width:80%}.descp-text,.return-text{font-size:14.4px;line-height:20px}.descp-text{display:flex;align-items:center;letter-spacing:.02em}.bottom-container{display:none}.mobile-bottom-container{display:block}.return-text{text-align:center;letter-spacing:.01em;color:#fff;margin-left:9.73px}}
3180
+ </style>
3181
+ <title>Access Denied</title>
3182
+ </head>
3183
+ <body>
3184
+ <div class="main-container">
3185
+ <div class="container">
3186
+ <div class="content">
3187
+ <span class="access-denied">Access Denied</span>
3188
+ <div class="site-url">
3189
+ <input id="url" class="site-url-text" type="text"
3190
+ value="${safeUrl}" readonly>
3191
+ <img class="copy-icon" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" alt="Copy Text" onmouseover="hover(this);" onmouseout="unhover(this);" onclick="copyText(id='url')">
3192
+ </div>
3193
+ <div class="site-url">
3194
+ <input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
3195
+ <img class="copy-icon" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" alt="Copy Text" onmouseover="hover(this);" onmouseout="unhover(this);" onclick="copyText(id='response')">
3196
+ </div>
3197
+ <div class="descp">
3198
+ <span class="descp-text">This request has been blocked by Patchstack. If this message persists and you are a legitimate user, contact the site administrator with the reference above.</span>
3199
+ </div>
3200
+ </div>
3201
+ </div>
3202
+ <div class="bottom-container">
3203
+ <div class="bottom-text-containers">
3204
+ <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
3205
+ <a href="/" class="return-text">Return to homepage</a>
3206
+ </div>
3207
+ <div class="bottom-second-containers">
3208
+ <span class="secured-text">This website is secured by</span>
3209
+ <a href="https://patchstack.com" class="logoText">
3210
+ <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
3211
+ </a>
3212
+ </div>
3213
+ </div>
3214
+
3215
+ <div class="mobile-bottom-container">
3216
+ <div class="mobile-bottom-text-containers">
3217
+ <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
3218
+ <a href="/" class="return-text">Return to homepage</a>
3219
+ </div>
3220
+ <div class="mobile-bottom-second-containers">
3221
+ <span class="secured-text">This website is secured by</span>
3222
+ <a href="https://patchstack.com" class="logoText">
3223
+ <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
3224
+ </a>
3225
+ </div>
3226
+ </div>
3227
+ </div>
3228
+ </body>
3229
+ <script>
3230
+ function copyText(id) {
3231
+ var copyText = document.getElementById(id)
3232
+ copyText.select();
3233
+ copyText.setSelectionRange(0, 99999);
3234
+ navigator.clipboard.writeText(copyText.value);
2588
3235
  }
2589
- for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
2590
- if (match[operand] === void 0) continue;
2591
- const problem = operandShapeProblem(shape, match[operand]);
2592
- if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
3236
+
3237
+ function hover(element) {
3238
+ element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
2593
3239
  }
2594
- } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2595
- return `match type "${match.type}" needs a value`;
2596
- }
2597
- return null;
2598
- }
2599
- function mutationsProblem(mutations) {
2600
- if (mutations === void 0 || mutations === null) return null;
2601
- if (!Array.isArray(mutations)) return "mutations must be an array";
2602
- for (const mutation of mutations) {
2603
- if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2604
- return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
3240
+
3241
+ function unhover(element) {
3242
+ element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
2605
3243
  }
2606
- }
2607
- return null;
3244
+ <\/script>
3245
+ </html>`;
2608
3246
  }
2609
- function whenProblem(when) {
2610
- if (when === void 0 || when === null) return null;
2611
- if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2612
- const keys = Object.keys(when);
2613
- if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2614
- const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2615
- if (unknown.length > 0) {
2616
- return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
2617
- }
2618
- if ("path" in when && !isNonEmptyString(when.path)) {
2619
- return "when.path must be a non-empty string";
2620
- }
2621
- if ("method" in when) {
2622
- const methods = Array.isArray(when.method) ? when.method : [when.method];
2623
- if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2624
- if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
3247
+
3248
+ // src/protect/rules/store.js
3249
+ var fsMod;
3250
+ async function loadFs() {
3251
+ if (fsMod !== void 0) return fsMod;
3252
+ try {
3253
+ const [fs, path] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3254
+ fsMod = {
3255
+ readFileSync: fs.readFileSync,
3256
+ writeFileSync: fs.writeFileSync,
3257
+ mkdirSync: fs.mkdirSync,
3258
+ join: path.join
3259
+ };
3260
+ } catch {
3261
+ fsMod = null;
2625
3262
  }
2626
- return null;
2627
- }
2628
- function isNonEmptyString(value) {
2629
- return typeof value === "string" && value.trim() !== "";
3263
+ return fsMod;
2630
3264
  }
2631
- function actionProblem(action, rule) {
2632
- if (action === void 0 || action === null) return null;
2633
- if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2634
- const spec = ACTION_PROPERTIES[action];
2635
- for (const required of spec?.required ?? []) {
2636
- if (rule?.[required] === void 0 || rule?.[required] === null) {
2637
- return `action "${action}" needs "${required}"`;
3265
+ function makeStore(options = {}) {
3266
+ let mem = null;
3267
+ const durable = durableTier(options);
3268
+ return {
3269
+ async read() {
3270
+ if (mem) return mem;
3271
+ const env = await durable.read();
3272
+ if (env) mem = env;
3273
+ return env;
3274
+ },
3275
+ async write(env) {
3276
+ if (env) mem = env;
3277
+ await durable.write(env);
2638
3278
  }
2639
- }
2640
- for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2641
- if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2642
- const problem = operandShapeProblem(shape, rule[property]);
2643
- if (problem) return `action "${action}" property "${property}" ${problem}`;
2644
- }
2645
- const phase = rule?.phase ?? "request";
2646
- if (spec !== void 0 && !spec.phases.includes(phase)) {
2647
- return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2648
- }
2649
- return null;
3279
+ };
2650
3280
  }
2651
-
2652
- // src/protect/rules/validate.js
2653
- var LIMITS2 = LIMITS;
2654
- var PHASES2 = new Set(PHASES);
2655
- var ACTIONS2 = new Set(ACTIONS);
2656
- function validateBundle(bundle, opts = {}) {
2657
- const rejected = [];
2658
- const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2659
- const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2660
- const firewall = [];
2661
- for (const rule of inFirewall) {
2662
- if (firewall.length >= LIMITS2.maxRules) {
2663
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2664
- continue;
2665
- }
2666
- const reason = ruleProblem(rule);
2667
- if (reason) rejected.push({ id: idOf(rule), reason });
2668
- else firewall.push(rule);
2669
- }
2670
- const whitelists = [];
2671
- for (const wl of inWhitelists) {
2672
- if (whitelists.length >= LIMITS2.maxWhitelists) {
2673
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2674
- continue;
2675
- }
2676
- if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
2677
- rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2678
- continue;
2679
- }
2680
- const reason = conditionsProblem(wl?.rule_v2);
2681
- if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2682
- else whitelists.push(wl);
3281
+ function durableTier(options) {
3282
+ const adapter = options.ruleCache;
3283
+ if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
3284
+ return {
3285
+ read: async () => {
3286
+ try {
3287
+ return toEnvelope(await adapter.read());
3288
+ } catch {
3289
+ return null;
3290
+ }
3291
+ },
3292
+ write: async (env) => {
3293
+ try {
3294
+ await adapter.write(env);
3295
+ } catch {
3296
+ }
3297
+ }
3298
+ };
2683
3299
  }
3300
+ const dir = options.cacheDir;
2684
3301
  return {
2685
- bundle: { ...bundle, firewall, whitelists },
2686
- rejected
3302
+ read: async () => cacheRead(dir),
3303
+ write: async (env) => cacheWrite(dir, env)
2687
3304
  };
2688
3305
  }
2689
- function idOf(rule) {
2690
- const id = rule?.id ?? rule?.rule_id;
2691
- return id === void 0 || id === null ? "(unidentified)" : String(id);
3306
+ async function cacheWrite(dir, env) {
3307
+ if (!dir) return;
3308
+ const fs = await loadFs();
3309
+ if (!fs) return;
3310
+ try {
3311
+ fs.mkdirSync(dir, { recursive: true });
3312
+ fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
3313
+ } catch {
3314
+ }
2692
3315
  }
2693
- function ruleProblem(rule) {
2694
- if (!rule || typeof rule !== "object") return "not an object";
2695
- if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2696
- const actionReason = actionProblem(rule.action, rule);
2697
- if (actionReason) return actionReason;
2698
- const scopeReason = whenProblem(rule.when);
2699
- if (scopeReason) return scopeReason;
2700
- const capOverride = rule.max_bytes;
2701
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2702
- return conditionsProblem(rule.rule_v2);
3316
+ async function cacheRead(dir) {
3317
+ if (!dir) return null;
3318
+ const fs = await loadFs();
3319
+ if (!fs) return null;
3320
+ try {
3321
+ return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
3322
+ } catch {
3323
+ return null;
3324
+ }
2703
3325
  }
2704
- function conditionsProblem(conditions, depth = 0) {
2705
- if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2706
- if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2707
- if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2708
- if (conditions.length > LIMITS2.maxConditionsPerRule) {
2709
- return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
3326
+ function toEnvelope(value) {
3327
+ if (!value || typeof value !== "object") return null;
3328
+ if (value.bundle && typeof value.bundle === "object") {
3329
+ return { bundle: value.bundle, etag: value.etag ?? null };
2710
3330
  }
2711
- for (const c of conditions) {
2712
- const shapeReason = conditionShapeProblem(c);
2713
- if (shapeReason) return shapeReason;
2714
- if (isGroup(c)) {
2715
- const nested = conditionsProblem(c.rules, depth + 1);
2716
- if (nested) return nested;
2717
- continue;
2718
- }
2719
- const parameterReason = parameterProblem(c.parameter);
2720
- if (parameterReason) return parameterReason;
2721
- const mutationReason = mutationsProblem(c.mutations);
2722
- if (mutationReason) return mutationReason;
2723
- const matchReason = matchProblem(c.match, c.parameter);
2724
- if (matchReason) return matchReason;
2725
- const m = c.match;
2726
- if (m.type === "regex") {
2727
- if (typeof m.value !== "string") return "regex match.value must be a string";
2728
- if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2729
- } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2730
- return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2731
- }
2732
- if (m.match) {
2733
- const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2734
- if (nested) return nested;
2735
- }
3331
+ if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
3332
+ return { bundle: value, etag: null };
2736
3333
  }
2737
3334
  return null;
2738
3335
  }
@@ -2758,67 +3355,70 @@ function reportRejections(rejected, options, label) {
2758
3355
  `${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" : ""}`
2759
3356
  ), "onError");
2760
3357
  }
2761
- function fromSource(bundle, reason) {
2762
- return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
3358
+ function fromSource(bundle, origin, reason) {
3359
+ return {
3360
+ ...bundle,
3361
+ source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
3362
+ };
2763
3363
  }
2764
3364
  async function resolveRules(options, store, ctx = {}) {
2765
3365
  const timeoutMs = ctx.timeoutMs;
2766
3366
  if (options.siteUuid) {
2767
3367
  const prior = await store.read();
2768
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
3368
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState });
2769
3369
  const res = await client.getRules();
2770
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3370
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
2771
3371
  if (res.success && !res.notModified) {
2772
3372
  const rejected = liveUpdateRejections(res, options);
2773
3373
  if (rejected.length > 0) {
2774
3374
  reportRejections(rejected, options, "rule update rejected");
2775
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2776
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2777
- return fromSource(emptyBundle(), "update rejected");
3375
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3376
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3377
+ return fromSource(emptyBundle(), "empty", "update rejected");
2778
3378
  }
2779
3379
  const bundle = normalizeBundle(res, options);
2780
3380
  await store.write({ bundle, etag: res.etag ?? null });
2781
- return fromSource(bundle);
3381
+ return fromSource(bundle, "api");
2782
3382
  }
2783
3383
  if (prior?.bundle) {
2784
3384
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2785
- return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3385
+ return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
2786
3386
  }
2787
3387
  if (options.rules) {
2788
3388
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
2789
- return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
3389
+ return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
2790
3390
  }
2791
3391
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2792
- return fromSource(emptyBundle(), res.error ?? "no usable response");
3392
+ return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
2793
3393
  }
2794
3394
  if (options.token) {
2795
3395
  const prior = await store.read();
2796
3396
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
2797
3397
  const res = await client.getRules();
2798
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3398
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
2799
3399
  if (res.success && !res.notModified) {
2800
3400
  const rejected = liveUpdateRejections(res, options);
2801
3401
  if (rejected.length > 0) {
2802
3402
  reportRejections(rejected, options, "rule update rejected");
2803
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2804
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2805
- return fromSource(emptyBundle(), "update rejected");
3403
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3404
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3405
+ return fromSource(emptyBundle(), "empty", "update rejected");
2806
3406
  }
2807
3407
  const bundle = normalizeBundle(res, options);
2808
3408
  await store.write({ bundle, etag: res.etag ?? null });
2809
- return fromSource(bundle);
3409
+ return fromSource(bundle, "api");
2810
3410
  }
2811
3411
  if (prior?.bundle) {
2812
3412
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2813
- return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3413
+ return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
2814
3414
  }
2815
3415
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2816
- return fromSource(emptyBundle(), res.error ?? "no usable response");
3416
+ return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
2817
3417
  }
2818
3418
  if (options.rules) {
2819
- return fromSource(normalizeBundle(options.rules, options));
3419
+ return fromSource(normalizeBundle(options.rules, options), "bundled");
2820
3420
  }
2821
- return fromSource(emptyBundle());
3421
+ return fromSource(emptyBundle(), "empty");
2822
3422
  }
2823
3423
  function normalizeBundle(b, options = {}) {
2824
3424
  const enforcement = b?.enforcement ?? b?.mode;
@@ -2914,6 +3514,164 @@ var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
2914
3514
  var DEFAULT_FLUSH_MS = 5e3;
2915
3515
  var MAX_BATCH = 50;
2916
3516
  var MAX_QUEUE = 500;
3517
+ var MAX_ATTEMPTS = 4;
3518
+ var RETRY_BASE_MS = 1e3;
3519
+ var RETRY_CAP_MS = 3e4;
3520
+ var RETRYABLE_EXPLICIT = /* @__PURE__ */ new Set([408, 425, 429]);
3521
+ function worthRetrying(status) {
3522
+ if (status === null) return true;
3523
+ return RETRYABLE_EXPLICIT.has(status) || status >= 500 && status <= 599;
3524
+ }
3525
+ var ATTEMPT_TIMEOUT_MS = 1e4;
3526
+ var STOP_BUDGET_MS = 5e3;
3527
+ var MAX_ROUTE_CHARS = 256;
3528
+ var MAX_PARAMETERS = 25;
3529
+ var MAX_PARAMETER_CHARS = 64;
3530
+ var MAX_IDENTIFIER_CHARS = 256;
3531
+ var MAX_CAPTURED_VALUES = 10;
3532
+ var MAX_CAPTURED_VALUE_CHARS = 512;
3533
+ var MAX_QUERY_KEYS = 10;
3534
+ var MAX_METHOD_CHARS = 16;
3535
+ var MAX_BODY_BYTES2 = 64 * 1024;
3536
+ function queryKeysOf(path) {
3537
+ if (typeof path !== "string") return { keys: [], total: 0 };
3538
+ const start = path.indexOf("?");
3539
+ if (start === -1) return { keys: [], total: 0 };
3540
+ let names;
3541
+ try {
3542
+ names = [...new URLSearchParams(path.slice(start + 1)).keys()];
3543
+ } catch {
3544
+ return { keys: [], total: 0 };
3545
+ }
3546
+ const seen = /* @__PURE__ */ new Set();
3547
+ const keys = [];
3548
+ for (const name of names) {
3549
+ if (name === "") continue;
3550
+ if (seen.has(name)) continue;
3551
+ seen.add(name);
3552
+ if (keys.length < MAX_QUERY_KEYS) keys.push(name);
3553
+ }
3554
+ return { keys, total: seen.size };
3555
+ }
3556
+ function boundCapture(capture) {
3557
+ if (!capture || typeof capture !== "object" || Array.isArray(capture)) return null;
3558
+ const truncated = [];
3559
+ const own = (object, key) => Object.hasOwn(object, key) ? object[key] : void 0;
3560
+ const declaredPlan = own(capture, "plan");
3561
+ const plan = typeof declaredPlan === "string" ? capText(declaredPlan, MAX_IDENTIFIER_CHARS) : null;
3562
+ if (plan === null) return null;
3563
+ if (plan.truncated) truncated.push("plan");
3564
+ const out = { plan: plan.value };
3565
+ let overCap = 0;
3566
+ let rejected = 0;
3567
+ const declaredValues = own(capture, "values");
3568
+ if (Array.isArray(declaredValues) && declaredValues.length > 0) {
3569
+ const values = [];
3570
+ for (const entry of declaredValues) {
3571
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
3572
+ rejected += 1;
3573
+ continue;
3574
+ }
3575
+ const declaredParameter = own(entry, "parameter");
3576
+ const text = scalarText(own(entry, "value"));
3577
+ if (typeof declaredParameter !== "string" || text === null) {
3578
+ rejected += 1;
3579
+ continue;
3580
+ }
3581
+ if (values.length >= MAX_CAPTURED_VALUES) {
3582
+ overCap += 1;
3583
+ continue;
3584
+ }
3585
+ const label = capText(declaredParameter, MAX_PARAMETER_CHARS);
3586
+ const value = capText(text, MAX_CAPTURED_VALUE_CHARS);
3587
+ if (label.truncated && !truncated.includes("parameter")) truncated.push("parameter");
3588
+ if (value.truncated && !truncated.includes("value")) truncated.push("value");
3589
+ values.push({
3590
+ parameter: label.value,
3591
+ value: value.value,
3592
+ ...own(entry, "truncated") === true || value.truncated ? { truncated: true } : {}
3593
+ });
3594
+ }
3595
+ if (overCap > 0) truncated.push("values");
3596
+ if (values.length > 0) out.values = values;
3597
+ }
3598
+ const declaredRaw = own(capture, "raw");
3599
+ if (declaredRaw !== void 0 && declaredRaw !== null) {
3600
+ const rawValue = declaredRaw && typeof declaredRaw === "object" ? own(declaredRaw, "value") : void 0;
3601
+ if (typeof rawValue === "string") {
3602
+ const raw = capText(rawValue, MAX_CAPTURED_VALUE_CHARS);
3603
+ out.raw = {
3604
+ value: raw.value,
3605
+ ...own(declaredRaw, "truncated") === true || raw.truncated ? { truncated: true } : {}
3606
+ };
3607
+ } else {
3608
+ rejected += 1;
3609
+ }
3610
+ }
3611
+ const declaredCount = (key) => {
3612
+ const value = own(capture, key);
3613
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
3614
+ };
3615
+ const totals = {
3616
+ omitted: declaredCount("omitted") + overCap,
3617
+ unsupported: declaredCount("unsupported") + rejected,
3618
+ failed: declaredCount("failed")
3619
+ };
3620
+ for (const [key, total] of Object.entries(totals)) {
3621
+ if (total > 0) out[key] = total;
3622
+ }
3623
+ if (own(capture, "unavailable") === true) out.unavailable = true;
3624
+ if (truncated.length > 0) out.truncated = truncated;
3625
+ return out;
3626
+ }
3627
+ function scalarText(value) {
3628
+ if (typeof value === "string") return value;
3629
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
3630
+ if (typeof value === "boolean") return String(value);
3631
+ return null;
3632
+ }
3633
+ function makeInstanceId() {
3634
+ try {
3635
+ const uuid = globalThis.crypto?.randomUUID?.();
3636
+ if (typeof uuid === "string" && uuid !== "") return uuid;
3637
+ } catch {
3638
+ }
3639
+ return `${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
3640
+ }
3641
+ function capText(value, limit) {
3642
+ const text = typeof value === "string" ? value : "";
3643
+ return text.length > limit ? { value: text.slice(0, limit), truncated: true } : { value: text, truncated: false };
3644
+ }
3645
+ function retryDelayMs(attempts, retryAfter, random = Math.random) {
3646
+ const advertised = parseRetryAfter(retryAfter);
3647
+ if (advertised !== null) return Math.min(advertised, RETRY_CAP_MS);
3648
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempts - 1), RETRY_CAP_MS);
3649
+ return Math.min(RETRY_CAP_MS, Math.round(backoff * (0.75 + random() * 0.5)));
3650
+ }
3651
+ function parseRetryAfter(value) {
3652
+ if (typeof value !== "string" || value.trim() === "") return null;
3653
+ const seconds = Number(value.trim());
3654
+ if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1e3 : null;
3655
+ const at = Date.parse(value);
3656
+ if (Number.isNaN(at)) return null;
3657
+ return Math.max(0, at - Date.now());
3658
+ }
3659
+ var encoder = new TextEncoder();
3660
+ function byteLength(text) {
3661
+ return encoder.encode(text).length;
3662
+ }
3663
+ function splitToFit(events, maxBytes, wrap = (batch) => ({ detections: batch })) {
3664
+ const batch = events.slice();
3665
+ const rest = [];
3666
+ while (batch.length > 1 && byteLength(JSON.stringify(wrap(batch))) > maxBytes) {
3667
+ rest.unshift(batch.pop());
3668
+ }
3669
+ return [batch, rest];
3670
+ }
3671
+ function unattended(timer) {
3672
+ if (timer && typeof timer.unref === "function") timer.unref();
3673
+ return timer;
3674
+ }
2917
3675
  function ruleParameters(rule) {
2918
3676
  const out = /* @__PURE__ */ new Set();
2919
3677
  const walk = (conditions) => {
@@ -2948,12 +3706,22 @@ function createDetectionReporter(opts) {
2948
3706
  },
2949
3707
  flush() {
2950
3708
  },
2951
- stop() {
2952
- },
3709
+ stop: () => Promise.resolve(),
2953
3710
  setRulesEtag() {
2954
3711
  },
3712
+ announce() {
3713
+ },
2955
3714
  dropped: () => 0,
2956
- health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
3715
+ health: () => ({
3716
+ sent: 0,
3717
+ delivered: 0,
3718
+ failed: 0,
3719
+ dropped: 0,
3720
+ retried: 0,
3721
+ reauthorized: 0,
3722
+ lastDeliveredAt: null,
3723
+ capability: { announced: 0, acknowledged: 0, failed: 0, retried: 0, lastAcknowledgedAt: null }
3724
+ })
2957
3725
  };
2958
3726
  }
2959
3727
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
@@ -2967,47 +3735,233 @@ function createDetectionReporter(opts) {
2967
3735
  let failed = 0;
2968
3736
  let droppedTotal = 0;
2969
3737
  let lastDeliveredAt = null;
3738
+ let capabilityAnnounced = 0;
3739
+ let capabilityAcknowledged = 0;
3740
+ let capabilityFailed = 0;
3741
+ let lastCapabilityAckAt = null;
2970
3742
  let queue = [];
2971
3743
  let timer = null;
3744
+ let retryTimer = null;
2972
3745
  let stopped = false;
2973
3746
  let dropped = 0;
3747
+ let retried = 0;
3748
+ let capabilityRetried = 0;
3749
+ let flushRequested = false;
3750
+ let draining = false;
3751
+ let drainResolve = null;
3752
+ let drainPromise = null;
3753
+ let budgetTimer = null;
3754
+ let epoch = 0;
3755
+ let reauthorized = 0;
3756
+ let sending = false;
3757
+ let inFlight = null;
3758
+ let pendingState = null;
3759
+ const instanceId = makeInstanceId();
3760
+ let sequence = 0;
3761
+ const bodyFor = (events, droppedWith, state) => ({
3762
+ detections: events,
3763
+ // The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer
3764
+ // computing a false-positive rate needs to know its denominator is short, and silence about that
3765
+ // would make a truncated sample look like a complete one.
3766
+ dropped: droppedWith,
3767
+ ...state !== null ? { reporting_state: state } : {}
3768
+ });
3769
+ const takeBatch = (droppedWith, state) => {
3770
+ const [batch, rest] = splitToFit(
3771
+ queue.splice(0, MAX_BATCH),
3772
+ MAX_BODY_BYTES2,
3773
+ (events) => bodyFor(events, droppedWith, state)
3774
+ );
3775
+ if (rest.length > 0) queue.unshift(...rest);
3776
+ return batch;
3777
+ };
3778
+ const authConfig = { pulseAuth: opts.pulseAuth, endpoint: baseUrl, timeoutMs: ATTEMPT_TIMEOUT_MS };
3779
+ const detectionsUrl = `${baseUrl}/detections/${encodeURIComponent(siteUuid)}`;
3780
+ const post = async (body, key, signal, transport) => (
3781
+ // Through the shared Pulse path, which attaches the token and — on a 401 — discards it and retries
3782
+ // once with a fresh one. A cached token can stop being valid before it expires, and the server's
3783
+ // refusal is authoritative over our own clock; the batch's own headers, this key included, are
3784
+ // carried through both sends, so the redelivery is still recognisable as the same batch.
3785
+ pulseFetch(
3786
+ authConfig,
3787
+ detectionsUrl,
3788
+ {
3789
+ method: "POST",
3790
+ ...signal ? { signal } : {},
3791
+ headers: {
3792
+ "Content-Type": "application/json",
3793
+ Accept: "application/json",
3794
+ "User-Agent": "@patchstack/connect",
3795
+ // The same key on every attempt of one batch: an acknowledgement can be lost after the server
3796
+ // has already taken the batch, and a redelivery has to be identifiable as the same one.
3797
+ "Idempotency-Key": key
3798
+ },
3799
+ body: JSON.stringify(body)
3800
+ },
3801
+ transport
3802
+ )
3803
+ );
3804
+ const abandon = () => {
3805
+ if (!inFlight) return;
3806
+ failed += inFlight.events.length;
3807
+ if (inFlight.state !== null) capabilityFailed += 1;
3808
+ inFlight = null;
3809
+ };
3810
+ const settle = () => {
3811
+ if (!inFlight) return;
3812
+ if (inFlight.events.length > 0) {
3813
+ delivered += inFlight.events.length;
3814
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
3815
+ }
3816
+ if (inFlight.state !== null) {
3817
+ capabilityAcknowledged += 1;
3818
+ lastCapabilityAckAt = (/* @__PURE__ */ new Date()).toISOString();
3819
+ }
3820
+ inFlight = null;
3821
+ };
3822
+ const attempt = async () => {
3823
+ if (!inFlight) return;
3824
+ const mine = epoch;
3825
+ sending = true;
3826
+ inFlight.attempts += 1;
3827
+ if (inFlight.attempts > 1) {
3828
+ if (inFlight.events.length > 0) retried += 1;
3829
+ if (inFlight.state !== null) capabilityRetried += 1;
3830
+ }
3831
+ const body = bodyFor(inFlight.events, inFlight.dropped, inFlight.state);
3832
+ let status = null;
3833
+ let retryAfter = null;
3834
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
3835
+ inFlight.controller = controller;
3836
+ const timeout = controller ? unattended(setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS)) : null;
3837
+ inFlight.timeout = timeout;
3838
+ let sends = 0;
3839
+ const counted = (url, init) => {
3840
+ if (String(url) === detectionsUrl) {
3841
+ sends += 1;
3842
+ if (sends > 1 && mine === epoch) reauthorized += 1;
3843
+ }
3844
+ return fetchImpl(url, init);
3845
+ };
3846
+ try {
3847
+ const res = await post(body, inFlight.key, controller?.signal, counted);
3848
+ if (mine !== epoch) return;
3849
+ if (res && res.ok) {
3850
+ settle();
3851
+ if (timeout) clearTimeout(timeout);
3852
+ finish();
3853
+ return;
3854
+ }
3855
+ status = typeof res?.status === "number" ? res.status : 0;
3856
+ retryAfter = res?.headers?.get?.("retry-after") ?? null;
3857
+ } catch {
3858
+ status = null;
3859
+ } finally {
3860
+ if (timeout) clearTimeout(timeout);
3861
+ if (inFlight && inFlight.controller === controller) {
3862
+ inFlight.controller = null;
3863
+ inFlight.timeout = null;
3864
+ }
3865
+ }
3866
+ if (mine !== epoch) return;
3867
+ const retryable = worthRetrying(status);
3868
+ if (retryable && inFlight.attempts < MAX_ATTEMPTS && !stopped) {
3869
+ const delay = retryDelayMs(inFlight.attempts, retryAfter);
3870
+ sending = false;
3871
+ retryTimer = unattended(
3872
+ setTimeout(() => {
3873
+ retryTimer = null;
3874
+ void attempt();
3875
+ }, delay)
3876
+ );
3877
+ return;
3878
+ }
3879
+ abandon();
3880
+ finish();
3881
+ };
3882
+ const finish = () => {
3883
+ sending = false;
3884
+ kick();
3885
+ };
3886
+ const arm = () => {
3887
+ if (!timer) {
3888
+ timer = unattended(
3889
+ setTimeout(() => {
3890
+ timer = null;
3891
+ flushRequested = true;
3892
+ kick();
3893
+ }, flushMs)
3894
+ );
3895
+ }
3896
+ };
3897
+ const due = () => draining || pendingState !== null || queue.length >= MAX_BATCH || flushRequested;
3898
+ const kick = () => {
3899
+ if (sending || inFlight) return;
3900
+ if (typeof fetchImpl !== "function") {
3901
+ if (draining) drained();
3902
+ return;
3903
+ }
3904
+ if (stopped && !draining) return;
3905
+ if (queue.length === 0 && pendingState === null) {
3906
+ flushRequested = false;
3907
+ if (draining) drained();
3908
+ return;
3909
+ }
3910
+ if (!due()) {
3911
+ arm();
3912
+ return;
3913
+ }
3914
+ flushRequested = false;
3915
+ const droppedWith = dropped;
3916
+ dropped = 0;
3917
+ droppedTotal += droppedWith;
3918
+ const state = pendingState;
3919
+ pendingState = null;
3920
+ const events = takeBatch(droppedWith, state);
3921
+ sent += events.length;
3922
+ if (state !== null) capabilityAnnounced += 1;
3923
+ sequence += 1;
3924
+ inFlight = { key: `${instanceId}-${sequence}`, events, dropped: droppedWith, state, attempts: 0 };
3925
+ void attempt();
3926
+ };
3927
+ const terminate = () => {
3928
+ epoch += 1;
3929
+ if (inFlight) {
3930
+ inFlight.controller?.abort();
3931
+ if (inFlight.timeout) clearTimeout(inFlight.timeout);
3932
+ failed += inFlight.events.length;
3933
+ if (inFlight.state !== null) capabilityFailed += 1;
3934
+ inFlight = null;
3935
+ }
3936
+ if (pendingState !== null) {
3937
+ pendingState = null;
3938
+ capabilityAnnounced += 1;
3939
+ capabilityFailed += 1;
3940
+ }
3941
+ sending = false;
3942
+ drained();
3943
+ };
3944
+ const drained = () => {
3945
+ draining = false;
3946
+ if (queue.length > 0) {
3947
+ droppedTotal += queue.length;
3948
+ queue = [];
3949
+ }
3950
+ if (budgetTimer) {
3951
+ clearTimeout(budgetTimer);
3952
+ budgetTimer = null;
3953
+ }
3954
+ const resolve = drainResolve;
3955
+ drainResolve = null;
3956
+ if (resolve) resolve();
3957
+ };
2974
3958
  const flush = () => {
2975
3959
  if (timer) {
2976
3960
  clearTimeout(timer);
2977
3961
  timer = null;
2978
3962
  }
2979
- if (queue.length === 0 || typeof fetchImpl !== "function") return;
2980
- const batch = queue.splice(0, MAX_BATCH);
2981
- const droppedWith = dropped;
2982
- dropped = 0;
2983
- droppedTotal += droppedWith;
2984
- sent += batch.length;
2985
- void (async () => {
2986
- try {
2987
- const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
2988
- method: "POST",
2989
- headers: {
2990
- "Content-Type": "application/json",
2991
- Accept: "application/json",
2992
- "User-Agent": "@patchstack/connect",
2993
- // Same credential path as the rules fetch. The detections endpoint is site-addressed and
2994
- // requires a verified, site-bound token, so a batch sent without one is refused — which is
2995
- // why the runtime does not build a reporter when no credential resolves, rather than
2996
- // posting into a 401.
2997
- ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
2998
- },
2999
- body: JSON.stringify({ detections: batch, dropped: droppedWith })
3000
- });
3001
- if (res && res.ok) {
3002
- delivered += batch.length;
3003
- lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
3004
- } else {
3005
- failed += batch.length;
3006
- }
3007
- } catch {
3008
- failed += batch.length;
3009
- }
3010
- })();
3963
+ flushRequested = true;
3964
+ kick();
3011
3965
  };
3012
3966
  return {
3013
3967
  /**
@@ -3026,32 +3980,144 @@ function createDetectionReporter(opts) {
3026
3980
  queue.shift();
3027
3981
  dropped++;
3028
3982
  }
3983
+ const query = queryKeysOf(detection.path);
3984
+ const method = typeof detection.method === "string" ? capText(detection.method, MAX_METHOD_CHARS) : null;
3985
+ const userAgent = typeof detection.userAgent === "string" && detection.userAgent !== "" ? capText(detection.userAgent, MAX_IDENTIFIER_CHARS) : null;
3986
+ const queryKeys = query.keys.map((name) => capText(name, MAX_PARAMETER_CHARS));
3987
+ const capture = boundCapture(detection.capture);
3988
+ const rawRoute = routeOf(detection.path);
3989
+ const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
3990
+ const allParameters = ruleParameters(detection.rule);
3991
+ const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
3992
+ const truncated = [];
3993
+ if (route.truncated) truncated.push("route");
3994
+ if (method?.truncated) truncated.push("method");
3995
+ if (userAgent?.truncated) truncated.push("user_agent");
3996
+ if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
3997
+ truncated.push("query_keys");
3998
+ }
3999
+ if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
4000
+ truncated.push("parameters");
4001
+ }
4002
+ const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
4003
+ const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
4004
+ const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
4005
+ for (const [name, field] of [
4006
+ ["rule_id", id],
4007
+ ["rule_revision", revision],
4008
+ ["rules_etag", etag]
4009
+ ]) {
4010
+ if (field.truncated) truncated.push(name);
4011
+ }
3029
4012
  queue.push({
3030
- rule_id: ruleId,
3031
- route: routeOf(detection.path),
3032
- parameters: ruleParameters(detection.rule),
4013
+ rule_id: id.value,
4014
+ route: route.value,
4015
+ parameters: parameters.map((entry) => entry.value),
4016
+ // Present only when something was shortened, so its absence is not a claim of its own.
4017
+ ...truncated.length > 0 ? { truncated } : {},
4018
+ // Only when parameters were actually left out. Reporting a total because some OTHER field was
4019
+ // shortened states that parameters were omitted when none were.
4020
+ ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
4021
+ method: method === null ? null : method.value,
4022
+ // The rest of the URL, as names only. `route` is the path; together they say what was requested
4023
+ // without saying what was in it.
4024
+ query_keys: queryKeys.map((entry) => entry.value),
4025
+ // Present only when the list is short, so its absence is not a claim of its own.
4026
+ ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
4027
+ // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
4028
+ user_agent: userAgent === null ? null : userAgent.value,
3033
4029
  phase: detection.phase ?? null,
3034
4030
  // The state this detection was handled under, which is the whole point: `false` is a rule that
3035
4031
  // saw traffic it would have stopped.
3036
4032
  enforced: detection.mode === "block",
3037
- rules_etag: rulesEtag,
4033
+ rules_etag: rulesEtag === null ? null : etag.value,
3038
4034
  // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
3039
4035
  // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
3040
4036
  // one rule describe the document that rule has now. Passed through untouched, and null when the
3041
4037
  // bundle carried none.
3042
- rule_revision: revisionOf(detection.rule),
4038
+ rule_revision: revisionOf(detection.rule) === null ? null : revision.value,
4039
+ // What the rule was permitted to show, and what it showed. Present only when a plan was derived,
4040
+ // which is to say only for a phase that has a reading to derive from.
4041
+ ...capture ? { capture } : {},
4042
+ // The client address and where it came from. `client_ip` is omitted entirely when there is none,
4043
+ // so a present-but-empty field cannot read as a failed lookup of a real address; the provenance is
4044
+ // always present, because "this could not be established" is the part a reader needs.
4045
+ ...clientIpFields({ ip: detection.ip ?? null, source: detection.clientIpSource ?? "unavailable" }),
3043
4046
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
3044
4047
  });
3045
- if (queue.length >= MAX_BATCH) {
3046
- flush();
3047
- return;
3048
- }
3049
- if (!timer) timer = setTimeout(flush, flushMs);
4048
+ kick();
3050
4049
  },
3051
4050
  flush,
4051
+ /**
4052
+ * Tell the platform which reporting state this guard settled on.
4053
+ *
4054
+ * The state also travels on the rules fetch, but that request is made BEFORE the fetch decides
4055
+ * whether the rules are the platform's — so a site booting with an empty cache declares that it holds
4056
+ * no managed rules, then receives them. Without this, the corrected state would wait for the next
4057
+ * refresh, and a guard with refreshing switched off has none.
4058
+ *
4059
+ * Goes through the same transport as events, so it inherits the retry, the idempotency key and the
4060
+ * single-flight bound. Only the newest state is kept: calling this twice before a send declares the
4061
+ * second, because what the platform needs is the state now, not how it got there. A declaration that
4062
+ * exhausts its retries is dropped rather than held — the state travels on every rules fetch too, so
4063
+ * the next one corrects it.
4064
+ *
4065
+ * @param {string} state
4066
+ */
4067
+ announce(state) {
4068
+ if (stopped || typeof fetchImpl !== "function" || typeof state !== "string") return;
4069
+ pendingState = state;
4070
+ queueMicrotask(() => {
4071
+ if (!stopped) kick();
4072
+ });
4073
+ },
4074
+ /**
4075
+ * Stop reporting, leaving nothing stranded and nothing scheduled.
4076
+ *
4077
+ * Three things can be outstanding, and each needs an answer:
4078
+ *
4079
+ * - a batch waiting on a retry timer — it holds the only send slot, so clearing the timer alone would
4080
+ * leave it neither delivered nor counted. It gets one final attempt, with no retry behind it.
4081
+ * - a request in flight — it is aborted, and its completion drains what is left rather than starting
4082
+ * open-ended work.
4083
+ * - events still queued — drained a batch at a time, each attempted once.
4084
+ *
4085
+ * Whatever remains unsent when the drain runs out is counted as dropped, so every recorded event ends
4086
+ * up delivered, refused or dropped, and none simply disappears.
4087
+ *
4088
+ * The drain is asynchronous, so this returns a promise that settles when nothing is outstanding —
4089
+ * which a host shutting down can await instead of racing the last batch against process exit. It is
4090
+ * best-effort and bounded: a runtime that terminates the process regardless, or a drain slower than
4091
+ * `STOP_BUDGET_MS`, still ends the wait. Ignoring the promise behaves exactly as before.
4092
+ */
3052
4093
  stop() {
4094
+ if (stopped) return drainPromise ?? Promise.resolve();
3053
4095
  stopped = true;
3054
- flush();
4096
+ drainPromise = new Promise((resolve) => {
4097
+ drainResolve = resolve;
4098
+ });
4099
+ budgetTimer = unattended(setTimeout(() => {
4100
+ budgetTimer = null;
4101
+ terminate();
4102
+ }, STOP_BUDGET_MS));
4103
+ if (timer) {
4104
+ clearTimeout(timer);
4105
+ timer = null;
4106
+ }
4107
+ draining = true;
4108
+ if (retryTimer) {
4109
+ clearTimeout(retryTimer);
4110
+ retryTimer = null;
4111
+ void attempt();
4112
+ return drainPromise;
4113
+ }
4114
+ if (sending) {
4115
+ inFlight?.controller?.abort();
4116
+ return drainPromise;
4117
+ }
4118
+ flushRequested = true;
4119
+ kick();
4120
+ return drainPromise;
3055
4121
  },
3056
4122
  /**
3057
4123
  * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
@@ -3070,12 +4136,70 @@ function createDetectionReporter(opts) {
3070
4136
  * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
3071
4137
  * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
3072
4138
  */
3073
- health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
4139
+ health: () => ({
4140
+ sent,
4141
+ delivered,
4142
+ failed,
4143
+ dropped: droppedTotal + dropped,
4144
+ // Attempts beyond the first, counted in ATTEMPTS rather than events: a path that only ever
4145
+ // succeeds on a second try is working, and is worth telling apart from one that never retries.
4146
+ retried,
4147
+ // Redeliveries the credential path made after a refused token, which are not backoff retries and
4148
+ // would otherwise appear nowhere: a rotated or revoked credential is worth seeing as itself.
4149
+ reauthorized,
4150
+ lastDeliveredAt,
4151
+ // Separate, because a capability announcement delivers no events. Reading zero here alongside a
4152
+ // non-zero `delivered` is a normal state, and so is the reverse.
4153
+ capability: {
4154
+ announced: capabilityAnnounced,
4155
+ acknowledged: capabilityAcknowledged,
4156
+ failed: capabilityFailed,
4157
+ retried: capabilityRetried,
4158
+ lastAcknowledgedAt: lastCapabilityAckAt
4159
+ }
4160
+ })
3074
4161
  };
3075
4162
  }
3076
4163
 
4164
+ // src/protect/reporting-state.js
4165
+ var REPORTING_STATES = Object.freeze([
4166
+ /** Enrolled, managed rules, credential present, not opted out. Events are sent. */
4167
+ "on",
4168
+ /** `PATCHSTACK_REPORT_DETECTIONS=0`. An explicit, per-deployment opt-out. */
4169
+ "disabled-by-config",
4170
+ /** `PATCHSTACK_TELEMETRY=0`. The broader switch, which covers this along with everything else. */
4171
+ "disabled-by-telemetry-opt-out",
4172
+ /** No site identity: a local or unenrolled install. Nothing to report against. */
4173
+ "not-enrolled",
4174
+ /**
4175
+ * A site identity, but the rules running are not the platform's — the caller's own bundle, or none.
4176
+ * There is no managed rule document to attribute a detection to, so a report would name a rule id the
4177
+ * platform never issued.
4178
+ */
4179
+ "no-managed-rules",
4180
+ /** Enrolled with managed rules, but no credential resolved, so a report would be refused. */
4181
+ "unavailable-no-credential"
4182
+ ]);
4183
+ function optedOut(value) {
4184
+ if (value === void 0 || value === null || value === "") return false;
4185
+ return /^(0|false|off|no)$/i.test(String(value));
4186
+ }
4187
+ function reportingState(input) {
4188
+ const env = input.env ?? (typeof process !== "undefined" ? process.env : void 0) ?? {};
4189
+ if (input.configOptOut === true) return { state: "disabled-by-config", reports: false };
4190
+ if (optedOut(env.PATCHSTACK_REPORT_DETECTIONS)) return { state: "disabled-by-config", reports: false };
4191
+ if (optedOut(env.PATCHSTACK_TELEMETRY)) return { state: "disabled-by-telemetry-opt-out", reports: false };
4192
+ const siteUuid = input.siteUuid;
4193
+ if (typeof siteUuid !== "string" || siteUuid === "") return { state: "not-enrolled", reports: false };
4194
+ if (input.hasCredential !== true) return { state: "unavailable-no-credential", reports: false };
4195
+ const managed = input.ruleOrigin === "api" || input.ruleOrigin === "cache";
4196
+ if (!managed) return { state: "no-managed-rules", reports: false };
4197
+ return { state: "on", reports: true };
4198
+ }
4199
+
3077
4200
  // src/protect/firewall-log.js
3078
4201
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4202
+ var STOP_BUDGET_MS2 = 5e3;
3079
4203
  var DEFAULT_FLUSH_MS2 = 1e3;
3080
4204
  var MAX_BATCH2 = 50;
3081
4205
  var TOKEN_SKEW_MS2 = 6e4;
@@ -3116,9 +4240,7 @@ function createFirewallLogReporter(opts) {
3116
4240
  const creds = parseApiKey(opts.apiKey);
3117
4241
  if (!creds) {
3118
4242
  return { record() {
3119
- }, flush() {
3120
- }, stop() {
3121
- } };
4243
+ }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
3122
4244
  }
3123
4245
  const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
3124
4246
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -3127,9 +4249,13 @@ function createFirewallLogReporter(opts) {
3127
4249
  let queue = [];
3128
4250
  let timer = null;
3129
4251
  let stopped = false;
4252
+ const outstanding = /* @__PURE__ */ new Set();
4253
+ let drainPromise = null;
4254
+ const lifetime = typeof AbortController === "function" ? new AbortController() : null;
4255
+ let ended = false;
3130
4256
  let cachedToken = null;
3131
4257
  let tokenInflight = null;
3132
- const fetchAccessToken = async () => {
4258
+ const fetchAccessToken = async (signal) => {
3133
4259
  if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
3134
4260
  return cachedToken.token;
3135
4261
  }
@@ -3138,6 +4264,7 @@ function createFirewallLogReporter(opts) {
3138
4264
  try {
3139
4265
  const res = await fetchImpl(`${apiBase}/oauth/token`, {
3140
4266
  method: "POST",
4267
+ ...signal ? { signal } : {},
3141
4268
  headers: {
3142
4269
  "Content-Type": "application/json",
3143
4270
  Accept: "application/json",
@@ -3170,15 +4297,16 @@ function createFirewallLogReporter(opts) {
3170
4297
  clearTimeout(timer);
3171
4298
  timer = null;
3172
4299
  }
3173
- if (queue.length === 0 || typeof fetchImpl !== "function") return;
4300
+ if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
3174
4301
  const batch = queue.splice(0, MAX_BATCH2);
3175
- void (async () => {
3176
- const token = await fetchAccessToken();
3177
- if (!token) return;
3178
- const body = new URLSearchParams();
3179
- body.set("type", "firewall");
3180
- body.set("logs", JSON.stringify(batch));
4302
+ let entry;
4303
+ const send = (async () => {
3181
4304
  try {
4305
+ const token = await fetchAccessToken(lifetime?.signal);
4306
+ if (!token || ended) return;
4307
+ const body = new URLSearchParams();
4308
+ body.set("type", "firewall");
4309
+ body.set("logs", JSON.stringify(batch));
3182
4310
  const p = fetchImpl(`${apiBase}/api/logs/log`, {
3183
4311
  method: "POST",
3184
4312
  headers: {
@@ -3188,13 +4316,20 @@ function createFirewallLogReporter(opts) {
3188
4316
  "User-Agent": "@patchstack/connect",
3189
4317
  ...sourceHost ? { "Source-Host": sourceHost } : {}
3190
4318
  },
3191
- body
4319
+ body,
4320
+ // Both phases carry it, so a shutdown that runs out of time can end either one.
4321
+ ...lifetime ? { signal: lifetime.signal } : {}
3192
4322
  });
3193
- if (p && typeof p.then === "function") p.catch(() => {
4323
+ if (p && typeof p.then === "function") await p.catch(() => {
3194
4324
  });
3195
4325
  } catch {
4326
+ } finally {
4327
+ outstanding.delete(entry);
3196
4328
  }
3197
4329
  })();
4330
+ entry = send;
4331
+ outstanding.add(send);
4332
+ return send;
3198
4333
  };
3199
4334
  return {
3200
4335
  /**
@@ -3225,9 +4360,50 @@ function createFirewallLogReporter(opts) {
3225
4360
  if (!timer) timer = setTimeout(flush, flushMs);
3226
4361
  },
3227
4362
  flush,
4363
+ /**
4364
+ * Stop, and hand back a wait for what was outstanding.
4365
+ *
4366
+ * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
4367
+ * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
4368
+ * spinning would be worse than leaving the records where they are.
4369
+ */
3228
4370
  stop() {
4371
+ if (drainPromise) return drainPromise;
3229
4372
  stopped = true;
3230
- flush();
4373
+ if (timer) {
4374
+ clearTimeout(timer);
4375
+ timer = null;
4376
+ }
4377
+ const terminate = () => {
4378
+ ended = true;
4379
+ lifetime?.abort();
4380
+ queue = [];
4381
+ outstanding.clear();
4382
+ };
4383
+ let budget = null;
4384
+ const spent = new Promise((resolve) => {
4385
+ budget = setTimeout(() => {
4386
+ terminate();
4387
+ resolve();
4388
+ }, STOP_BUDGET_MS2);
4389
+ if (typeof budget.unref === "function") budget.unref();
4390
+ });
4391
+ const work = (async () => {
4392
+ while (!ended) {
4393
+ if (outstanding.size > 0) {
4394
+ await Promise.all([...outstanding]);
4395
+ continue;
4396
+ }
4397
+ if (queue.length === 0) break;
4398
+ const before = queue.length;
4399
+ await flush();
4400
+ if (queue.length >= before) break;
4401
+ }
4402
+ })();
4403
+ drainPromise = Promise.race([work, spent]).then(() => {
4404
+ if (budget) clearTimeout(budget);
4405
+ });
4406
+ return drainPromise;
3231
4407
  }
3232
4408
  };
3233
4409
  }
@@ -3318,8 +4494,8 @@ async function createProtection(options = {}) {
3318
4494
  fetchImpl: options.fetchImpl
3319
4495
  }) : null;
3320
4496
  let detections = null;
4497
+ const ruleIdentity = (rule) => ({ id: rule?.id, category: rule?.category });
3321
4498
  const onDetect = (detection) => {
3322
- notify(userOnDetect, detection, "onDetect");
3323
4499
  if (detections) detections.record(detection);
3324
4500
  if (firewallLog && detection?.mode === "block") {
3325
4501
  firewallLog.record({
@@ -3330,6 +4506,12 @@ async function createProtection(options = {}) {
3330
4506
  userAgent: detection.userAgent
3331
4507
  });
3332
4508
  }
4509
+ const { capture: _evidence, ...forCallback } = detection ?? {};
4510
+ notify(
4511
+ userOnDetect,
4512
+ { ...forCallback, ...detection?.rule ? { rule: ruleIdentity(detection.rule) } : {} },
4513
+ "onDetect"
4514
+ );
3333
4515
  };
3334
4516
  const store = makeStore(options);
3335
4517
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
@@ -3339,29 +4521,58 @@ async function createProtection(options = {}) {
3339
4521
  notify(onError, new Error(message), "onError");
3340
4522
  console.warn(message);
3341
4523
  }
3342
- const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
3343
- let detectionReporting = "off";
3344
- if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
3345
- if (!pulseAuth) {
3346
- detectionReporting = "unavailable-no-credential";
3347
- const message = "Patchstack: detection reporting is enabled for site " + options.siteUuid + " but no API credential resolved, so no report could be delivered. Reporting is off.";
3348
- notify(onError, new Error(message), "onError");
3349
- console.warn(message);
3350
- } else {
3351
- detectionReporting = "on";
4524
+ const cachedOrigin = async () => {
4525
+ const prior = await store.read();
4526
+ if (prior?.bundle) return "cache";
4527
+ return options.rules ? "bundled" : "empty";
4528
+ };
4529
+ const stateFor = (origin) => reportingState({
4530
+ siteUuid: options.siteUuid,
4531
+ ruleOrigin: origin,
4532
+ hasCredential: Boolean(pulseAuth),
4533
+ configOptOut: options.reportDetections === false
4534
+ });
4535
+ const preFetchState = stateFor(await cachedOrigin()).state;
4536
+ const bundle = await resolveRules(options, store, {
4537
+ timeoutMs: bootTimeoutMs,
4538
+ pulseAuth,
4539
+ detectionState: preFetchState
4540
+ });
4541
+ let detectionReporting = "not-enrolled";
4542
+ let currentOrigin;
4543
+ const applyReportingState = async (origin) => {
4544
+ currentOrigin = origin;
4545
+ const next = stateFor(origin);
4546
+ const changed = next.state !== detectionReporting;
4547
+ detectionReporting = next.state;
4548
+ if (next.reports && !detections) {
3352
4549
  detections = createDetectionReporter({
3353
4550
  siteUuid: options.siteUuid,
3354
4551
  baseUrl: options.pulseRulesUrl,
3355
4552
  pulseAuth,
3356
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
3357
- // it rather than to whatever is current when the report is read. Kept current across refreshes —
3358
- // see the refresh tick below.
4553
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that
4554
+ // produced it rather than to whatever is current when the report is read.
3359
4555
  rulesEtag: (await store.read())?.etag ?? null,
3360
4556
  fetchImpl: options.fetchImpl,
3361
4557
  flushMs: options.detectionFlushMs
3362
4558
  });
4559
+ } else if (!next.reports && detections) {
4560
+ detections.stop();
4561
+ detections = void 0;
3363
4562
  }
3364
- }
4563
+ if (changed && next.state === "unavailable-no-credential") {
4564
+ const message = "Patchstack: this site is enrolled and running managed rules, but no API credential resolved, so no security event could be delivered. Reporting is off.";
4565
+ notify(onError, new Error(message), "onError");
4566
+ console.warn(message);
4567
+ }
4568
+ return next;
4569
+ };
4570
+ const applyAndAcknowledge = async (origin, declared) => {
4571
+ const settled = await applyReportingState(origin);
4572
+ if (settled.state !== declared && detections) detections.announce(settled.state);
4573
+ return settled;
4574
+ };
4575
+ await applyAndAcknowledge(bundle.source?.origin, preFetchState);
3365
4576
  let mode = resolveMode(options, bundle);
3366
4577
  let requestRules;
3367
4578
  let responseRules;
@@ -3408,6 +4619,19 @@ async function createProtection(options = {}) {
3408
4619
  };
3409
4620
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3410
4621
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
4622
+ const planCache = createPlanCache();
4623
+ const evidenceFrom = (result) => {
4624
+ if (!detections || !result?.rule) return void 0;
4625
+ let entry;
4626
+ try {
4627
+ entry = planCache.for(result.rule);
4628
+ } catch (err) {
4629
+ notify(onError, err, "onError");
4630
+ return void 0;
4631
+ }
4632
+ if (!permitsAnything(entry.plan)) return { plan: entry.reference };
4633
+ return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
4634
+ };
3411
4635
  const decide = (phase, result, block, allow2, ctx = {}) => {
3412
4636
  if (!result || !result.blocked) return allow2();
3413
4637
  const effectiveMode = ruleMode(result.rule);
@@ -3422,7 +4646,12 @@ async function createProtection(options = {}) {
3422
4646
  method: ctx.method,
3423
4647
  path: ctx.path,
3424
4648
  ip: ctx.ip,
3425
- userAgent: ctx.userAgent
4649
+ // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
4650
+ // value read out of a forwarded header, and `null` from "there was no address to establish".
4651
+ clientIpSource: ctx.clientIpSource,
4652
+ userAgent: ctx.userAgent,
4653
+ // Derived from the reading this decision was made on, before that reading goes out of scope.
4654
+ capture: evidenceFrom(result)
3426
4655
  });
3427
4656
  return effectiveMode === "block" ? block() : allow2();
3428
4657
  };
@@ -3445,7 +4674,18 @@ async function createProtection(options = {}) {
3445
4674
  }
3446
4675
  if (!result.blocked) continue;
3447
4676
  const responseMode = ruleMode(rule);
3448
- onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
4677
+ onDetect({
4678
+ phase: "response",
4679
+ mode: responseMode,
4680
+ category: rule.category,
4681
+ rule,
4682
+ message: result.message,
4683
+ ...requestMetaFromContext(reqCtx),
4684
+ // A response detection names its capture policy like any other. Response sources are never
4685
+ // capturable, so a response-only rule reports a plan that permitted nothing — which is the
4686
+ // policy, and a different fact from a rule that found nothing.
4687
+ capture: evidenceFrom(result)
4688
+ });
3449
4689
  if (responseMode !== "block") continue;
3450
4690
  if (redactors && redactors.length) {
3451
4691
  const spanRedactors = redactors.filter((r) => !r.jsonPath);
@@ -3482,17 +4722,88 @@ async function createProtection(options = {}) {
3482
4722
  for (const rule of headerMutations) applyHeaderMutation(headers, rule);
3483
4723
  return { verdict: "redact", body, headers };
3484
4724
  };
3485
- const reqContextFromFetch = (request) => {
4725
+ const shapeExpressRequest = (req) => {
4726
+ const client = resolveClientIp({
4727
+ peer: req?.socket?.remoteAddress,
4728
+ headers: req?.headers ?? {},
4729
+ trustedProxy: options.trustedProxy
4730
+ });
4731
+ return {
4732
+ client,
4733
+ shaped: {
4734
+ // Own copies of everything a rule can address. Listed rather than spread, so a field the engine
4735
+ // gains has to be added here deliberately instead of appearing to work by accident.
4736
+ // Through the same gate as the engine's own normalisation: this projection turns whatever it reads
4737
+ // into an OWN property, so reading a polluted prototype here would launder it into evidence that
4738
+ // no later own-property check could tell from the real thing.
4739
+ method: requestField(req, "method"),
4740
+ url: requestField(req, "url"),
4741
+ originalUrl: requestField(req, "originalUrl"),
4742
+ headers: requestField(req, "headers"),
4743
+ query: requestField(req, "query"),
4744
+ body: requestField(req, "body"),
4745
+ files: requestField(req, "files"),
4746
+ cookies: requestField(req, "cookies"),
4747
+ socket: requestField(req, "socket"),
4748
+ // The verbatim body, when a caller kept one. A `raw` rule reads it directly, and the engine
4749
+ // otherwise reconstructs raw by re-serialising the parsed body — which cannot carry what parsing
4750
+ // did not keep: a body that failed to parse at all, a duplicate key where only the last value
4751
+ // survives, or the exact bytes a signature was written against.
4752
+ //
4753
+ // Own property only. Evidence is what this request actually carried: a value reachable through a
4754
+ // polluted prototype is not, and materialising it here would turn it into evidence indistinguish-
4755
+ // able from real bytes — firing every raw rule that matches it. The adapters that capture real
4756
+ // bytes define `_rawBody` on the request object itself.
4757
+ ...Object.hasOwn(req ?? {}, "_rawBody") ? { _rawBody: req._rawBody } : {},
4758
+ // The resolved address, and the resolution itself for the consumers downstream.
4759
+ ip: client.ip ?? "",
4760
+ _clientIp: client
4761
+ }
4762
+ };
4763
+ };
4764
+ const screenFetchRequest = async (request) => {
4765
+ let result;
4766
+ let shaped;
4767
+ try {
4768
+ shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
4769
+ result = engine.evaluate(shaped);
4770
+ } catch (err) {
4771
+ notify(onError, err, "onError");
4772
+ return { blocked: null, client: void 0 };
4773
+ }
4774
+ const blocked = decide(
4775
+ "request",
4776
+ result,
4777
+ () => blockResponse(result, request),
4778
+ () => null,
4779
+ requestMeta(shaped, request)
4780
+ );
4781
+ return { blocked, client: shaped?._clientIp };
4782
+ };
4783
+ const reqContextFromFetch = (request, client) => {
3486
4784
  try {
3487
4785
  const u = new URL(request.url);
3488
4786
  const headers = headerObject(request.headers);
3489
4787
  if (!headers.host) headers.host = u.host;
3490
- return { method: request.method, originalUrl: u.pathname + u.search, headers };
4788
+ const resolved = client ?? { ip: null, source: "unavailable" };
4789
+ return {
4790
+ method: request.method,
4791
+ originalUrl: u.pathname + u.search,
4792
+ headers,
4793
+ ip: resolved.ip ?? "",
4794
+ _clientIp: resolved
4795
+ };
3491
4796
  } catch {
3492
4797
  return void 0;
3493
4798
  }
3494
4799
  };
3495
- const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
4800
+ const reqContextFromNode = (req, client) => req ? {
4801
+ method: req.method,
4802
+ originalUrl: req.url,
4803
+ headers: req.headers || {},
4804
+ ip: client?.ip ?? "",
4805
+ _clientIp: client ?? { ip: null, source: "unavailable" }
4806
+ } : void 0;
3496
4807
  const screenResp = async (response, reqCtx) => {
3497
4808
  const read = await readTextResponse(response, screenCap);
3498
4809
  if (read.skip) {
@@ -3619,7 +4930,23 @@ async function createProtection(options = {}) {
3619
4930
  }
3620
4931
  if (!result.blocked) return false;
3621
4932
  const egressMode = ruleMode(result.rule);
3622
- onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
4933
+ let egressPath = null;
4934
+ try {
4935
+ const u = new URL(url);
4936
+ egressPath = u.pathname + u.search;
4937
+ } catch {
4938
+ egressPath = typeof url === "string" ? url : null;
4939
+ }
4940
+ onDetect({
4941
+ phase: "egress",
4942
+ mode: egressMode,
4943
+ category: result.rule?.category,
4944
+ rule: result.rule,
4945
+ message: result.message,
4946
+ method: typeof method === "string" ? method : null,
4947
+ path: egressPath,
4948
+ capture: evidenceFrom(result)
4949
+ });
3623
4950
  return egressMode === "block";
3624
4951
  };
3625
4952
  const protection = {
@@ -3640,28 +4967,26 @@ async function createProtection(options = {}) {
3640
4967
  },
3641
4968
  // Screen a fetch Response through the response-phase rules (redact/block). Used by
3642
4969
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
3643
- screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
4970
+ // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
4971
+ // upstream response. It resolves once here, which is the only resolution for this call.
4972
+ screenResponse: (response, request) => screenResp(
4973
+ response,
4974
+ request ? reqContextFromFetch(
4975
+ request,
4976
+ resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
4977
+ ) : void 0
4978
+ ),
3644
4979
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
3645
4980
  fetchGuard() {
3646
- return async (request) => {
3647
- let result;
3648
- try {
3649
- result = engine.evaluate(await fromFetchRequest(request));
3650
- } catch (err) {
3651
- notify(onError, err, "onError");
3652
- return null;
3653
- }
3654
- return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
3655
- };
4981
+ return async (request) => (await screenFetchRequest(request)).blocked;
3656
4982
  },
3657
4983
  // Wrap a fetch handler: screens the request, then the response (redact/block).
3658
4984
  fetch(handler) {
3659
- const guard = protection.fetchGuard();
3660
4985
  return async (request, ...rest) => {
3661
- const blocked = await guard(request);
4986
+ const { blocked, client } = await screenFetchRequest(request);
3662
4987
  if (blocked) return blocked;
3663
4988
  const response = await handler(request, ...rest);
3664
- return screenResp(response, reqContextFromFetch(request));
4989
+ return screenResp(response, reqContextFromFetch(request, client));
3665
4990
  };
3666
4991
  },
3667
4992
  // Express middleware (request phase; expects express-parsed req.query/req.body).
@@ -3669,11 +4994,12 @@ async function createProtection(options = {}) {
3669
4994
  express(exprOptions = {}) {
3670
4995
  return (req, res, next) => {
3671
4996
  let result;
4997
+ const { shaped, client } = shapeExpressRequest(req);
3672
4998
  try {
3673
- result = engine.evaluate(req);
4999
+ result = engine.evaluate(shaped);
3674
5000
  } catch (err) {
3675
5001
  notify(onError, err, "onError");
3676
- if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
5002
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
3677
5003
  return next();
3678
5004
  }
3679
5005
  decide(
@@ -3687,10 +5013,10 @@ async function createProtection(options = {}) {
3687
5013
  }
3688
5014
  },
3689
5015
  () => {
3690
- if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
5016
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
3691
5017
  next();
3692
5018
  },
3693
- nodeRequestMeta(req)
5019
+ nodeRequestMeta(req, client)
3694
5020
  );
3695
5021
  };
3696
5022
  },
@@ -3729,7 +5055,7 @@ async function createProtection(options = {}) {
3729
5055
  let shaped;
3730
5056
  let result;
3731
5057
  try {
3732
- shaped = fromNodeRequest(req, rawBody);
5058
+ shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
3733
5059
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
3734
5060
  result = engine.evaluate(shaped);
3735
5061
  } catch (err) {
@@ -3751,10 +5077,10 @@ async function createProtection(options = {}) {
3751
5077
  },
3752
5078
  () => {
3753
5079
  if (req.body === void 0) req.body = shaped.body;
3754
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
5080
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
3755
5081
  next();
3756
5082
  },
3757
- nodeRequestMeta(req)
5083
+ nodeRequestMeta(req, shaped?._clientIp)
3758
5084
  );
3759
5085
  }
3760
5086
  }
@@ -3790,9 +5116,15 @@ async function createProtection(options = {}) {
3790
5116
  notify(onError, err, "onError");
3791
5117
  }
3792
5118
  }
3793
- const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
5119
+ const declaredState = stateFor(currentOrigin).state;
5120
+ const next = await resolveRules(options, store, {
5121
+ timeoutMs: options.refreshTimeoutMs,
5122
+ pulseAuth,
5123
+ detectionState: declaredState
5124
+ });
3794
5125
  mode = resolveMode(options, next);
3795
5126
  applyBundle(next);
5127
+ await applyAndAcknowledge(next.source?.origin, declaredState);
3796
5128
  if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
3797
5129
  return next.source ?? { ok: true };
3798
5130
  };
@@ -3803,12 +5135,20 @@ async function createProtection(options = {}) {
3803
5135
  const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
3804
5136
  protection.stop = () => {
3805
5137
  loop?.stop();
3806
- firewallLog?.stop();
3807
- detections?.stop();
5138
+ const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
5139
+ (wait) => wait && typeof wait.then === "function"
5140
+ );
5141
+ return Promise.all(outstanding).then(() => void 0);
3808
5142
  };
3809
5143
  protection.stopRefresh = protection.stop;
3810
- protection.detectionReporting = detectionReporting;
3811
- if (detections) protection.detectionHealth = () => detections.health();
5144
+ Object.defineProperty(protection, "detectionReporting", {
5145
+ get: () => detectionReporting,
5146
+ enumerable: true
5147
+ });
5148
+ Object.defineProperty(protection, "detectionHealth", {
5149
+ get: () => detections ? () => detections.health() : void 0,
5150
+ enumerable: true
5151
+ });
3812
5152
  return protection;
3813
5153
  }
3814
5154
  function resolveMode(options, bundle) {
@@ -3989,6 +5329,8 @@ function extractRedactors(rule) {
3989
5329
  }
3990
5330
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
3991
5331
  out.push({ literal: String(m.value) });
5332
+ } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
5333
+ out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
3992
5334
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
3993
5335
  const keys = Array.isArray(m.key) ? m.key : [m.key];
3994
5336
  for (const key of keys) {
@@ -4007,7 +5349,11 @@ function applyRedactors(body, redactors, mask, transform) {
4007
5349
  let out = body;
4008
5350
  for (const r of redactors) {
4009
5351
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
4010
- else if (r.literal) {
5352
+ else if (r.jwtClaim) {
5353
+ for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
5354
+ out = out.split(token).join(transform ? transform(token) : mask);
5355
+ }
5356
+ } else if (r.literal) {
4011
5357
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
4012
5358
  out = out.replace(re, (m) => transform ? transform(m) : mask);
4013
5359
  }
@@ -4208,30 +5554,48 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
4208
5554
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
4209
5555
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
4210
5556
  }
4211
- function fetchRequestMeta(request) {
4212
- if (!request) return {};
4213
- let path = null;
4214
- try {
4215
- path = new URL(request.url).pathname;
4216
- } catch {
4217
- path = typeof request.url === "string" ? request.url : null;
4218
- }
5557
+ function requestMetaFromContext(reqCtx) {
5558
+ if (!reqCtx) return {};
5559
+ const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
4219
5560
  return {
4220
- method: request.method ?? null,
4221
- path,
4222
- ip: request.headers?.get?.("x-forwarded-for") ?? null,
4223
- userAgent: request.headers?.get?.("user-agent") ?? null
5561
+ method: reqCtx.method ?? null,
5562
+ // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
5563
+ // trimming it here would leave a Fetch or response detection unable to say what was requested.
5564
+ path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
5565
+ ip: client.ip,
5566
+ clientIpSource: client.source,
5567
+ userAgent: reqCtx.headers?.["user-agent"] ?? null
4224
5568
  };
4225
5569
  }
4226
- function nodeRequestMeta(req) {
5570
+ function requestMeta(shaped, request) {
5571
+ const client = shaped?._clientIp ?? { ip: null, source: "unavailable" };
5572
+ let path = null;
5573
+ let method = null;
5574
+ let userAgent = null;
5575
+ if (request) {
5576
+ try {
5577
+ const u = new URL(request.url);
5578
+ path = u.pathname + u.search;
5579
+ } catch {
5580
+ path = typeof request.url === "string" ? request.url : null;
5581
+ }
5582
+ method = request.method ?? null;
5583
+ userAgent = request.headers?.get?.("user-agent") ?? null;
5584
+ }
5585
+ return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
5586
+ }
5587
+ function nodeRequestMeta(req, client) {
4227
5588
  if (!req) return {};
4228
5589
  const headers = req.headers ?? {};
4229
5590
  const ua = headers["user-agent"] ?? headers["User-Agent"];
4230
- const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
5591
+ const resolved = client ?? { ip: null, source: "unavailable" };
4231
5592
  return {
4232
5593
  method: req.method ?? null,
4233
5594
  path: req.originalUrl || req.url || null,
4234
- ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
5595
+ // Never `req.ip`: under Express's `trust proxy` that is header-derived by a policy this guard has not
5596
+ // verified, and a second derivation could disagree with the one the engine evaluated.
5597
+ ip: resolved.ip,
5598
+ clientIpSource: resolved.source,
4235
5599
  userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
4236
5600
  };
4237
5601
  }